aontu

Use cases

Rendered from docs/use-cases.md in the engine repository, where a correction belongs, and where the test suite executes every example on this page.

Documentation examples are small on purpose. Systems are not. The eighteen models in use-cases/ close that gap: each one is an enterprise-shaped system built as real aontu documents (a service catalog, a schema registry, an RBAC model) and each carries a check.sh that drives the actual CLI and asserts every outcome, with golden diffs for expected output and error-code greps for expected refusals. When a page in these docs shows a shape, this is where that shape lives, running. Run one case, or the whole suite:

$ ./use-cases/03-api-contract/check.sh   # one case
$ ./use-cases/run-all.sh                 # all eighteen, one verdict line per case

The scripts need Node with ts/node_modules installed, plus python3 and git for a few cases. Each case’s README.md is the worked example in full, with verbatim CLI output; the sections below compress it.

01. Service catalog

A Backstage-style catalog for eight services across three teams, where the org chart and the runtime each hold facts about the same things. catalog.aon says what each service is (owner, tier, dependencies); deploy.aon says what each cluster runs (image, replicas, ports); and the deployment view references the catalog, so one evaluation merges them field by field and any contradiction between the views is a located error instead of a silent fork. The case also drives refer() existence checks, declared relations, and change requests written as four-line overlay files. The same service, seen from both views:

# catalog.aon — the catalog view
services: payments: {
  tier: 1
  description: "Card payment orchestration and capture API."
  dependsOn: [
    "$.services.ledger"
    "$.services.risk"
    "$.services.auth"
    "$.services.notify"
  ]
  dependedOnBy: ["$.services.gateway"]
}

# deploy.aon — the deployment view
deploy: eu1: payments: $.services.payments & {
  image: "registry.acme.internal/payments:2.14.1"
  replicas: 6
  ports: http: { number:8080 protocol:http }
}

The reference joins them, and an overlay claiming tier: 2 refuses with [aontu/scalar_value]. The live model, refusals included: use-cases/01-service-catalog/.

02. Deploy config

Four services, three environments, four layers of authority: org policy, team defaults, a service catalog, per-environment overlays. This is Helm-values and Kustomize territory, and the layering mechanism is the preference-rank ladder: the fewer stars, the stronger the default. The org writes ***, teams **, environments *, a concrete pin beats them all, and two disagreeing defaults at the same rank are a conflict. The whole ladder, trimmed from the case’s probes/rank-ladder.aon:

org_team_env: ***info|string
org_team_env: **debug|string
org_team_env: *warn|string

pinned: ***info|string
pinned: **debug|string
pinned: error
{
  "org_team_env": "warn",
  "pinned": "error"
}

Statement order never matters, and aontu model why prints each surviving rung with the file and line that wrote it. The full six-file layering: use-cases/02-deploy-config/.

03. API contract

A REST contract for a project-management SaaS: entities, endpoints, request bodies keyed by status code, one error envelope. It is the document an agent codes against and is corrected by: the agent emits a candidate body, aontu vet reports what fails and where, and the case’s repair.py repairs it mechanically from the --format json findings. Exit codes are verdict classes (0 valid, 1 invalid, 3 incomplete, 4 schema-side error), so a caller can branch before reading a byte. A wire message is a close()d shape, so a surplus or typo’d key is a conflict, not a silently ignored extra:

CreateUserRequest: close({
  email: $.types.Email
  name: $.types.DisplayName
  role: $.types.Role
  # Invitations send an email unless explicitly suppressed.
  send_invite?: boolean
})

vet --at '$.msg.CreateUserRequest' anchors on it directly. The contract, the candidates, and the repair loop: use-cases/03-api-contract/.

04. Schema evolution

A shared customer-profile schema across three released versions, with a queue of proposed changes. aontu breaking --against is the governance gate a schema registry needs: additive changes pass, a narrowed constraint or an added required key exits 1 with a witness naming both files, and a must() on the new side answers undecided (exit 3) rather than guessing. The centrepiece is the two-release rename: deprecate in v2, remove in v3. The v2 mark, from profile-v2.aon:

phone?: deprecate(string, {
  msg: "free-form phone is unvalidated; write E.164 to contact.phone"
  use: "$.profile.contact.phone"
  since: "2.0.0"
})

The mark rides the value: vet warns with the data site when an instance still uses the field, and --allow-deprecated-removal admits the v3 deletion while keeping the finding visible at severity warning. All three versions and the proposal queue: use-cases/04-schema-evolution/.

05. RBAC policy

An authorization model for a multi-tenant SaaS, written as data the engine checks: a permission catalog, an exhaustive role registry, tenant plans, and agent-emitted candidates vetted against all of it. Every grant is a refer()-checked address, so a hallucinated permission is a located refusal, and the registry is close()d, so an invented role dies at review. The security rule “no role holds the wildcard unless flagged privileged” is structural: a role is a disjunction of two closed shapes:

Role: type(
  close({
    desc: string
    rank: integer & min(0) & max(100)
    tenantOwner: boolean
    privileged: true
    grants: unique() & [&: refer() & string]
  })
  | close({
    desc: string
    rank: integer & min(0) & max(100)
    tenantOwner: false
    privileged: false
    grants: unique() & [&: refer() & string & neq("$.permissions.admin_all")]
  })
)

The unprivileged branch excludes the wildcard from every grant with one neq(), so a proposal granting it to a collaborator role is refused. A grant is a tree address, which is what makes refer() a checked foreign key against the permission catalog rather than a string comparison. The registry and its attack proposals: use-cases/05-rbac-policy/.

06. Kubernetes golden path

A platform team’s golden path: product teams edit a 40-line service model, and evaluating main.aon renders three Deployments and three Services, around 340 lines of manifests, none written by hand. An override composes like plain data (pin replicas: 6 at the generated path and the sibling defaults survive), close() seals the service set against drift, and vet runs the org guardrails over the rendered JSON. pack() makes a manifest per service, and a second pack merges one authored column into every generated child:

deploy: close(pack($.svc.names, {
  apiVersion: "apps/v1"
  kind: Deployment
  metadata: name: key(2) # depth-counted by hand
  # ... the rest of the Deployment skeleton ...
}))

deploy: pack($.svc.version, {
  spec: template: spec: containers: [
    image: $.platform.registry + "/" + key(6) + ":" + _
  ]
})

The version column becomes the image tag through _, and drift in either direction refuses: an entry with no service hits the sealed set, a service with no entry leaves image: string ungenerable. The whole generator: use-cases/06-k8s-golden-path/.

07. Event contracts

An order service’s events, held the way a Kafka schema registry holds them: one shared CloudEvents-flavoured envelope, one closed payload shape per event type, a discriminated union, and a compatibility gate between contract versions. Producers vet before publishing, consumers vet a whole stream sample with one command, and CI refuses a revision that breaks subscribers. Each event type is the envelope, narrowed by conjunction and sealed:

OrderPaid: close($.Envelope & {
  type: "order.paid"
  payload: close({
    order_id: re("^ord-[0-9a-f]{8}$")
    payment_ref: re("^psp-[a-z0-9-]{4,40}$")
    amount_cents: integer & min(1)
    method: "card"|"sepa"|"paypal"|"invoice"
  })
})

Pinning type to one string is what makes the union discriminated, and close() keeps surplus keys off the wire. The contract versions and the stream samples: use-cases/07-event-contracts/.

08. Feature flags

The write-path case: a flag catalog with environment and tenant overrides, mutated by aontu model set into an overlay file the reviewed base files never absorb. Ten sets of the same path collapse to a single overlay line under --in-place, why attributes the served value to the overlay with rank annotations, and a hostile overlay is confined by --trust. The kill switch is a concrete pin in the catalog, so no overlay of any rank can flip it: set vets before writing and refuses with the pinning site named:

$ aontu model set '$.flags.payments_legacy_gateway.enabled=true' --entry base.aon --overlay overlay.aon
verdict: invalid

$.flags.payments_legacy_gateway.enabled: scalar_value [conflict]
  [aontu/scalar_value]: Cannot unify values at path $.flags.payments_legacy_gateway.enabled
  data: overlay.aon:2:48 (true)
  schema: base.aon:53:14 (false)

Exit 1, and the overlay is untouched (the case asserts both). The full write loop: use-cases/08-feature-flags/.

09. Agent tools

An agent platform’s tool registry: six tools with closed argument schemas, rate limits, and side-effect classes, plus a runtime guardrail that vets each {tool, arguments} call at $.guard.<tool>. The four vet verdicts are a dispatcher’s decision table (valid: dispatch; invalid: refuse, feed the findings back; incomplete: ask for the missing argument; error: unknown tool), and the case drives the real aontu mcp server over JSON-RPC too. The wire schema is generated from the registry, so the two can never drift:

@"./registry.aon"

guard: pack($.argschemas, close({ tool:key() arguments:_ }))

close() survives the _ clone, so a hallucinated argument on any tool is a located [aontu/closed]. The registry, the calls, and the MCP session: use-cases/09-agent-tools/.

10. Data model

An order-to-cash domain: customers with 64-bit upstream ledger ids, orders, invoices, money. One document is at once the vet schema, the referential-integrity checker, and the seed-data generator; money is the stress test the 0d exact-decimal literals exist for. The price book pins its own sums, so an engine that computed them inexactly could not produce output at all.

The smallest such theorem, lifted from the case’s seed.aon: write it as reconcile.aon:

reconcile: centsPath: (10 + 20) & 30 # integer cents: exact
reconcile: exactPath: (0d0.1 + 0d0.2) & 0d0.3 # exact decimals: also exact

Evaluate it:

$ aontu reconcile.aon
{
  "reconcile": {
    "centsPath": 30,
    "exactPath": 0.3
  }
}

Binary64 arithmetic answers 0.30000000000000004 here; the pinned & 0d0.3 holds because 0d values are exact, and the case’s money-wire.aon shows how that exactness crosses JSON. The same schema is a code source: the case’s xf-domain.aon walks its record types into a component tree, one exported interface per record, with the TypeScript spelled by the transform itself; xf-order.aon writes the same walk as TypeScript and as Go, where nom and an acronym list the document carries spell ledgerId as LedgerID, and an optional field is a pointer with omitempty. Both are held against their goldens by the byte gate. The domain, the transforms and the failed attempts, kept executable: use-cases/10-data-model/.

11. Shared modules

The distribution story: a platform team’s deployment contract, published into a repository with aontu publish, acquired by a consumer with aontu sync, and held by a one-line lockfile whose three pins cover the bytes, the signed manifest and the meaning. The canon pin survives a byte-different, meaning-identical module refactor (a byte-hash lockfile breaks on exactly this), and a flipped default in the vendored tree fails evaluation with both hashes named. A single file can freeze the hash in the import string, with no pkg.aon and no lockfile: the agent-sandbox mode:

svc: @"corp.example/schemas/service#aon1-zFHnyVa1fA--g8hTx8lUUhaKzzRUNI--2nDheIMsSFs"
svc: spec: { name:"audit-log" owner:"sec-ops@corp.example" }

A tampered store is refused, with both hashes named:

module integrity: corp.example/schemas/service expected aon1-zFHnyVa1fA--g8hTx8lUUhaKzzRUNI--2nDheIMsSFs got aon1-NHmNT6r-Lhy8di9BgGNRfgwNFT3r5PgCZxCYnJ4F0Ws

The #aon1- pin resolves, verifies, and refuses a mangled hash with the same integrity error. The publish gate and the whole vendoring flow: use-cases/11-shared-modules/.

12. Relations

An ETL pipeline DAG: four jobs, one relation (feeds, with its written-out inverse fedBy). The whole thing is declared once, at the field, and data documents stay plain JSON-shaped string lists, with no per-link boilerplate. A cycle or a missing inverse refuses at generation with a located finding naming the loop or the exact absent entry, and aontu reaches --relation feeds answers the closure question directionally over the same edges. The declaration, from the case’s spec.aon:

feeds?: rel($.spec.JobShape) & acyclic() & inverse(fedBy)
fedBy?: rel($.spec.JobShape)

rel(t) makes the field’s strings checked entity addresses and flows the shape into every target, re() rides onto every address, and the graph atoms are decided at generation, where every edge is known. The DAG, its refusals, and the append proposal: use-cases/12-relations/.

13. Recursive schema

An approval chain: a Step is an approver, a decision, and optionally the step that follows it. Writing then?: $.spec.Step inside Step means the fixpoint, with no marker and no unrolled copies: the schema applies at every depth, expanding one level per meet with concrete data, so vet descends exactly as far as the data does. Canon and the aon1- hash stay symbolic: one finite string pins an infinitely deep type. The vocabulary, one reference deep, from schema.aon:

Step: approver: string & re("^[a-z]+@acme[.]example$")
Step: decision: *pending|pending|approved|rejected
Step: then?: $.spec.Step

A required recursive tail refuses at generation with recursion_unexpanded at the exact position no finite document can fill; guardedness is emergent, because the data decides. The chain, vetted as plain JSON: use-cases/13-recursive-schema/.

14. JSON Schema export

JSON Schema is the bridge out: an MCP tool’s inputSchema must be one, structured-output APIs constrain generation to one, OpenAPI embeds one. aontu jsonschema exports the unified value as draft 2020-12 on stdout and names every loss on stderr; --strict turns a lossy export into exit 1, and a dangling reference exports nothing rather than a partial schema.

Write a tool’s argument schema as registry.aon (trimmed from the case’s registry):

argschemas: read_file: close({
  path: string & re("^[A-Za-z0-9._/\\-]+$") & re("^[a-z]") & length(max(512))
  max_bytes?: integer & min(1) & max(1048576)
})

Export it as the tool’s inputSchema:

$ aontu jsonschema --at '$.argschemas.read_file' registry.aon
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "additionalProperties": false,
  "properties": {
    "max_bytes": {
      "maximum": 1048576,
      "minimum": 1,
      "type": "integer"
    },
    "path": {
      "allOf": [
        {
          "pattern": "^[A-Za-z0-9._/\\-]+$"
        },
        {
          "pattern": "^[a-z]"
        }
      ],
      "maxLength": 512,
      "minLength": 0,
      "type": "string"
    }
  },
  "required": [
    "path"
  ],
  "type": "object"
}

The two re() calls cross as an allOf of patterns, the closed map becomes additionalProperties: false, and the optional key stays out of required, with stderr empty, nothing was lost. The three moods of the bridge (exact, lossy, refused): use-cases/14-jsonschema-export/.

15. Code generation

The model is the source of the code. One catalogue of record types feeds a Go generator, a TypeScript generator and a SQL generator, each reading a different slice of it, and one component tree holds the three files. A generator is a rule set: emit walks the records in source order and each node contributes children (a blank line, a head, one line per field at depth 1, a tail) which splice flat into the file that holds them, so the transform owns every indent and every terminator. Names like Email and credit_cents are written in the model rather than derived, because what a type is called in a target is a fact about the model, not a rule in a template.

Write the model and one generator as types.aon:

records: [
  {
    name: "Customer"
    fields: [{ n:"id" t:"string" go:"ID" } { n:"email" t:"string" go:"Email" }]
  }
]

%field = emit(_, {
  match: n: string
  body: [
    line(
      "\t" + .go + " " + match(.t, "string", "string", "integer", "int64")
      + ` \`json:"` + .n + `"\``
    )
  ]
})

%record = emit(_, {
  match: name: string
  body: ["type " + .name + " struct {" emit(.fields, %field) "}"]
})

out: file("types.go", emit($.records, %record))

The generator answers a component tree: a File holding the Line nodes the model produced, which aontu render writes to disk.

$ aontu model get $.out types.aon
{
  "children": [
    {
      "children": [],
      "cmp": "Line",
      "props": {
        "src": "type Customer struct {"
      }
    },
    {
      "children": [],
      "cmp": "Line",
      "props": {
        "src": "\tID string `json:\"id\"`"
      }
    },
    {
      "children": [],
      "cmp": "Line",
      "props": {
        "src": "\tEmail string `json:\"email\"`"
      }
    },
    {
      "children": [],
      "cmp": "Line",
      "props": {
        "src": "}"
      }
    }
  ],
  "cmp": "File",
  "props": {
    "name": "types.go"
  }
}

A rule set rather than pack, because list order is source order and map keys sort by code point; a nested emit splices its children, so the file’s lines arrive flat. A bare string child is a Line, which is why the two rules above read as text. The three generators, their goldens, and a check that both ports answer byte-identical files: use-cases/15-code-generation/.

16. Module deps

A codebase’s own module graph: twelve modules across four layers, where the architecture rule is that nothing may depend on a layer above it. The rule is a shape rather than a checking pass. rel(t) flows its target shape into every module an edge names, so a core module’s dependsOn carries layer: "core" | "util" to the far end, and a module that says layer: "feature" cannot meet it. Each layer is one line of schema and one disjunction, from spec.aon:

Core: $.spec.Mod & { layer:"core" dependsOn?:rel($.spec.CoreDep) }
CoreDep: { kind:mod layer:"core"|"util" }

An upward edge then refuses at generation as an ordinary conflict naming both sides, and a loop between two modules of the same layer (which the layering allows) refuses under acyclic(). The case pins the refusal in both declaration orders. The same edges are drawn two ways and pinned as goldens: a dependency tree, drawn by aontu view tree with derived roots and every repeated subtree elided the way cargo tree elides one, and a dependency-structure matrix. The layered codebase, its refusals, and its views: use-cases/16-module-deps/.

17. Lambda handlers

Twelve services on one message wire, each deployed as its own Lambda handler: the same forty lines with three things that vary per service. The generator is the handler file itself, as one rule set in the canonical form a template file expands into: the body is the file, line for line, and a value reaches a line through replace rather than a hole:

%handler = emit(_, {
  match: name: string
  esc: sq
  replace: SERVICE: .name
  body: [
    "function complete(seneca: any) {"
    emit(.listen, {
      match: pin: string
      esc: sq
      replace: PIN: .pin
      body: ["  seneca.listen({type:'sqs',pin:'PIN'})"]
    })
    "}"
    "  let seneca = await getSeneca('SERVICE', complete)"
  ]
})

SERVICE and PIN are ordinary TypeScript in the body; the map says which strings stand for a value, and esc: sq escapes each for the single-quoted literal it lands in, so a service named o'brien compiles. A service with no S3 events gets no gateway hook, because a dispatch over an empty selection emits nothing, and the two lines of two spaces in every handler survive because a line is verbatim. An index names the services in the model’s order through each, with a constant spelled by join(each(split(_, "-"), upper(_)), "_"). All thirteen files are held against their goldens by the byte gate in both ports: use-cases/17-lambda-handlers/. The same generator is there twice: gen.aon is the canonical aontu above, and handler.ts is that generator written as a Lambda handler, with its aontu on marked lines. Both answer the same thirteen files.

18. Role permissions

Which role may change which subtree, asked before the change. roles.aon is a close()d role vocabulary (desc, allow, deny?) and a closed registry of four roles, admin, dev, product and qa, over the service model in model.aon: services with an owner, a tier, replicas and a description, a deploy layer with per-region replicas, feature flags and a tests block. The vocabulary and the dev role, from roles.aon:

Role: type(close({ desc:string allow: [&: string] deny?: [&: string] }))

roles: close({
  &: $.Role
  dev: {
    desc: "Runs the services: scaling and deployment, never ownership or tier"
    allow: ["$.services" "$.deploy.*.replicas"]
    deny: ["$.services.*.tier" "$.services.*.owner"]
  }
})

The agent skill asks aontu allow with the very assignment it is about to hand set, and writes on exit 0 alone. A proposal at $.services.auth.replicas lands in the overlay; one at $.services.auth.tier is refused with the rule named, and so is one at $.services.auth, because a change there could rewrite the tier:

$ aontu allow --role dev roles.aon '$.services.auth.tier="standard"'
verdict: refused
role: dev
$.services.auth.tier: refused by $.roles.dev.deny.0 ($.services.*.tier)

Exit 1, and aontu model why '$.roles.dev.deny.0' roles.aon names the line that wrote the rule. A role-model edit that puts a string where the allow list goes, or adds a key the closed vocabulary does not declare, is exit 4 with the engine’s own finding, so the rules are held to the same shape as everything else. The roles, the proposals and the skill: use-cases/18-role-permissions/.

Where a page in these docs and a use case disagree, the case wins (its checks run; the page does not). File the docs bug.