aontu

API reference

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

The programming interfaces of both implementations, plus the command-line tool. For the language itself see the Language reference.

Contents


Command-line interface

Both implementations ship the same aontu command. It evaluates a source file (or stdin) and prints the result, or starts a REPL when run interactively with no file. The synopsis, from the command itself:

$ aontu --help
Usage: aontu [options] [file]
       aontu vet [options] <schema> <data> [more-data...]
       aontu subsume [options] <general> <specific>
       aontu breaking --against <file|git#rev> [options] <file>
       aontu trim --check [options] <file>
       aontu relations [options] <file>
       aontu reaches <from> <to> [--relation <name>] [options] <file>
       aontu view <kind> [options] <file>...
       aontu view --views <path> [--check] [options] <file>
       aontu jsonschema [--at <path>] [--strict] [options] <file>
       aontu template [--resugar] [--check] [--marker <token>]
                      [--profile <file>] <file>
       aontu trace [--at <path>] [--format json] [--marker <token>]
                   [--profile <file>] <file>
       aontu render [--check] [--at <path>] [--format json]
                    [--marker <token>] [--profile <file>]
                    <file|folder> <path>
       aontu hash [options] <file>
       aontu sync [--frozen] [options] [dir]
       aontu add <pkg>[@<version>] [options] [dir]
       aontu get <pkg>[@<version>] [options] [dir]
       aontu remove <pkg> [options] [dir]
       aontu why <pkg> [options] [dir]
       aontu publish [--yes] [--to <dir>] [options] [dir]
       aontu pkg tidy|verify|vendor|manifest|refreeze|tree|outdated|serve
                 [options] [dir]
       aontu pkg keygen <file>
       aontu model get <path> [options] <file>
       aontu model why <path> [options] <file>
       aontu model set <path>=<value>... --entry <file> --overlay <file>
       aontu allow --role <role> [--at <path>] <roles-file> <path>...
       aontu agentsmd [--write <AGENTS.md>] [--depth <n>] <file>
       aontu fmt [-w|-l|--check|-d|--lint] [--marker <token>]
                 [--profile <file>] <file>...
       aontu help [topic] [--format text|json]
       aontu explain <code> | --list [--format text|json]
       aontu init [dir]
       aontu lsp
       aontu mcp [--root <dir>]

Evaluate an aontu source file and print the result as JSON.
With no file on an interactive terminal, start a REPL.
With no file and piped input, read the source from stdin.
...

The elided remainder lists every option; the per-verb sections below carry the same lists. Three options apply to every verb that reads a document: --trust <t> (the include capability: system, none, or root[:dir]; see AontuOptions for what each admits), its shorthand --include-root <dir>, and --text-ext <ext>[,<ext>] (extensions an include additionally reads as text; repeatable, and a leading dot is optional). The last rides WITH the capability rather than beside it: both answer what an include may read. Five verbs take none of the three: help, explain and init, which read no document; lsp, which takes no arguments at all; and the MCP server, which confines with --root <dir> instead. fmt takes all three, and they govern the document it evaluates, which is the one --profile names rather than the one it formats. sync, add, get and remove take them and then refuse a root capability, because a confined run cannot reach the package cache they write.

Behaviour

  • File: aontu config.aontu reads, unifies and prints the file. Relative @"file" loads inside it resolve against the file’s own directory, so it works from any working directory.
  • Exactly one file. The bare form is aontu [options] [file], singular, and a second filename is a usage error (exit 2) rather than a silent discard. This is what makes a MISTYPED VERB fail loudly: vet2 matches no subcommand, so it falls through as a file name, and aontu vet2 schema.aontu good.json is a usage error rather than a plausible pass: in the one place a tool loop reads the exit code to decide whether the data is good. A file genuinely named like a verb is still reachable as ./vet.
  • One mistyped verb is diagnosed too. A single argument that cannot be read and is shaped like a bare word (no separator, no extension) is reported as a verb rather than as a missing file, with the nearest verb named: aontu vett answers aontu: `vett` is not a file, and not a verb this port knows and suggests aontu vet, at exit 2. A path-shaped argument (./help, help.aontu, /tmp/help) keeps the file diagnosis and its exit 1, which is the same escape hatch the subcommand dispatch uses.
  • Stdin: echo 'a:1 b:$.a' | aontu reads source from the pipe.
  • REPL: aontu with no file on a terminal starts an interactive loop; each line is evaluated and printed.
  • Output is pretty-printed JSON by default, or canonical form with --canon.
  • Exact numbers keep their digits. A document using the 0d exact literals prints them in full, at any magnitude: x:0d9007199254740993 prints 9007199254740993, not a rounded …992. The TypeScript CLI gets this from the library’s exactJSON export, the Go CLI from a json.Encoder over the marshalling types, with HTML escaping off in both, so <, > and & stay literal and the two CLIs print the same bytes.
  • Results go to stdout; errors go to stderr with a non-zero exit status (1 for an evaluation error, 2 for a bad option).
  • --format json makes the answer an object, so the default entry point reports like every other verb rather than like a stream of prose: {aontu, findings, ok, out} on stdout, whether the document evaluated or not. out is the text the default form prints (the generated JSON, or the canonical form under --canon), empty when it did not evaluate; findings then carries one finding with the code to hand to aontu explain, its class from the registry, and the headline as message. The finding names no site and carries no hint: the frames under the headline are drawn for a person, and hint prose is deliberately outside cross-port parity. Exit codes are unchanged.

aontu vet

Validate data documents against a schema document. This is the emit → validate → repair loop’s entry point: an agent writes a document, vet says what does not hold and where, and the exit code says which kind of “no” it was.

aontu vet [options] <schema> <data> [more-data...]

  --at <path>       Validate against this path of the schema ($.a.b)
  --closed          Refuse keys the anchor does not declare
  --partial         Residue is reported but does not fail the run
  --max-errors <n>  Cap the finding list (default 20)
  --coverage        Report what the check examined
  --strict-coverage --coverage, and exit 1 when the run was vacuous
  --coverage-at <p> Measure coverage under this path of the data only
  --format <f>      text (default), json or sarif
  --watch           Re-run whenever a watched file changes

Exit codes are verdict classes, not a pass/fail bit, because the three ways to fail call for three different responses:

ExitVerdictMeaning
0validthe data unifies and is concrete (or --partial)
1invalidthe data does not hold: a contradiction it can never satisfy, or a document that would not parse
1valida VACUOUS run under --strict-coverage: the unification held and measured nothing, so the verdict stands and only the status says so (below)
2:usage: a bad option, or a file that cannot be read
3incompleteno contradiction, but the truth is not yet satisfied
4errorthe run could not be set up from the schema side: an unusable schema, or an --at that names nothing: never the data’s fault

Each data file is vetted separately, and the worst verdict wins: two data files are two candidates for the same truth, not one merged candidate. --max-errors caps the whole report, not each file, and says so with truncated.

A data file that will not parse is the data’s fault, and is reported as one parse-class finding with a site in that file: not as a broken schema. The distinction matters to the loop the verb exists for: exit 1 says “repair what you emitted”, exit 4 says “the truth you were given is unusable, stop”.

What the check examined

A check that examined nothing and a check that passed answer the same. That is not a bug in the unifier, which answered correctly about the document it was given, but it is a hole in a gate: the caller reads exit 0 and reports success.

The usual cause is one construct. A schema written with the wildcard other tools use is not a wildcard here:

entity: { "*": { table: string } }

"*" is a key named *. It declares an entity called *, meets no data key, and constrains nothing, so data with table: 42 vets valid. The template is &:, which meets every key of the map it sits in.

--coverage adds a coverage object to the report, and the report is the answer:

fieldis
checkeddata leaves a schema declaration constrained
leavesdata leaves in all, under --coverage-at when given
declareddeclarations the schema makes under the anchor: a map key, a list index, or a template, at every depth
uncheckedthe shallowest data paths no declaration constrained
unusedthe shallowest declarations no data path met
vacuousno data leaf was constrained, over a document that has leaves

Leaves, not paths. A leaf is where a value lives, and matching a container constrains no value: a schema saying only “there is a key called entity” has checked nothing, and checked is the number that says so. A document with no leaves is not vacuous either, because there was nothing to examine.

The lists name the shallowest paths: a subtree nothing constrained is named once rather than once per leaf. The text form prints the first ten of each and counts the rest; the JSON form carries every one.

--strict-coverage makes a vacuous run exit 1. The verdict word is unchanged (the unification really did hold), so nothing that passes today starts failing, and the reason goes to stderr while stdout stays a report contract. It is the flag a CI gate and an agent loop both want, and it implies --coverage, because a gate cannot fire on what was never measured.

Across several data files the schema side is counted once and the data side adds up: a declaration one file exercised is not unused, and vacuous means no file constrained anything.

The accounting is structural: what the schema declares about the data, rather than a reading of the meet. A meet-based reading would count a value the data supplied to itself as covered, which is the opposite of the question. It costs one extra evaluation of the data document, and only when asked for.

A parse failure is located. Its single site carries the parser’s own row and column, 1-based: the same position the human renderer draws its caret under. A document whose second line is b: ] reports row: 2, col: 4, in both ports.

A site has an extent, so a finding can be repaired. Beside row and col a site carries len (the span in UTF-16 code units, the units col is already counted in) and src, the source text that span covers. Both are -1 and "" when unknown, and a consumer must not edit a site that says so.

The extent is not optional detail, because value is the canon and not the source text. Vetting port: 0x1F reports value: "31" at column 7, so replacing (col, value.length) writes port: 90001F. With the span the edit is (col, len) ((7, 4)) and lands exactly on 0x1F.

src is what makes the span verifiable: read the document at (row, col, len), compare it to src, and refuse if they differ. That check matters most where the span is correct but partial. A site names the token it points at, exactly as row and col always have, so a scalar reports its whole literal while a compound reports its opening token: min(1) reports src: "min", a map reports src: "{". Seeing min where it expected min(1), a consumer refuses rather than replacing the name and orphaning the arguments.

To see the site shape, pin port in a one-line schema.aontu:

port: 8080

and vet a data.aontu that spells a different port in hex:

port: 0x1F
$ aontu vet --format json schema.aontu data.aontu
{
  "aontu": {
    "verb": "vet",
...
      "sites": [
        {
          "col": 7,
          "file": "data.aontu",
          "len": 4,
          "role": "data",
          "row": 1,
          "src": "0x1F",
          "value": "31"
        },
...
$ echo $?
1

value says 31, src says 0x1F, and len says the span is four code units: the three facts an editing consumer needs, together.

aontu model why carries the same pair: each conjunct in the record has the len on its site and the contribution’s own src beside its canon.

A site names the file whose text it excerpts, which for a modular document is not the entry file. A constraint written in lib/types.aontu and reached through @"./lib/types.aontu" is reported at lib/types.aontu with that file’s row and column: never at the entry with the included file’s coordinates, which is a real filename against a line it may not have.

The name is the one the CALLER’S OWN spelling reaches: vet contract.aontu names types.aontu, vet a/b/contract.aontu names a/b/types.aontu, and an absolute entry keeps absolute includes. So a site can be opened from wherever the command was run, and a report stays repo-relative, which is what a SARIF upload needs. Identity is still the resolved path underneath: two documents loading one library by different relative spellings are one file, not two.

Every verdict carries its finding, error included. A schema that does not stand up (a contradiction inside it, a document that will not parse, a merge marker) reports what failed and where, while the verdict stays error: whose fault it is and what the fault is are two separate answers, and the report gives both. Every site is in the schema (role schema), and a contradiction names both of its operands, exactly as one in the data would. A report that said only error was the one a repair loop could do nothing with.

--at takes a structural path: map keys and list indices, the same thing a reference means by $.a.b, with an index spelled as a plain decimal integer. A path that names nothing is verdict error, carrying the same no_path finding get and why give, including the “did you mean” note when a near key exists.

Relative @"file" loads inside either document resolve from that document’s own directory, exactly as they do for aontu <file>.

A finding names both sides. Sites are labelled by provenance (data first, because that is the one to edit) rather than by the source-order heuristic a single-document error uses. Write a closed schema as service.aontu:

service: close({ name:string port: *8080|integer replicas:integer })

and a deploy.json with one mistyped key and one string where an integer belongs:

{"service": {"name": "checkout", "prot": 8080,
  "replicas": "3"}}
$ aontu vet service.aontu deploy.json
verdict: invalid

$.service.prot: closed [conflict]
  [aontu/closed]: Cannot resolve value at path $.service.prot
  data: deploy.json:1:42 (8080)
$.service.replicas: no_scalar_unify [conflict]
  [aontu/no_scalar_unify]: Cannot unify values at path $.service.replicas
  data: deploy.json:2:15 ("3")
  schema: service.aontu:1:59 (integer)
$ echo $?
1

The closed finding has no schema site (there is no line that refuses prot, only a close() that never declared it) and the no_scalar_unify finding names both.

--format json emits the same report as an object, with an aontu stanza naming the producer, so a report read from a pipe says which version and verb made it. Where the constraint algebra knows what would have unified, the finding carries it as expected/actual, and a must() check’s author message rides along as note.

A finding carries the repair beside the diagnosis. message is the headline and stays one line (that is what makes it comparable and greppable) so a finding also carries hint: the engine’s own explanation of the failure class, with the offending values filled in. It is the text a human sees under the error frame, and for several codes it is the only place the FIX is written down. A lossy integer literal is the clearest case (abridged):

{ "code": "lossy_integer_literal",
  "message": "[aontu/lossy_integer_literal]: Cannot resolve value at path $.port",
  "hint": "This integer literal, 9007199254740993, is not exactly representable in\nbinary64 ... write it as a `0d`\nliteral to get the exact integer." }

The field is absent, not empty, for a code that has no hint text. Hint prose, like message, is deliberately outside cross-port parity.

Colour is a decision about the destination. Error frames are coloured for a terminal and plain everywhere else: NO_COLOR (set, to anything) turns colour off for every caller of the library, the command additionally turns it off when its own stderr is not a terminal, and --jsonl turns it off unconditionally: a JSONL answer is machine-read by definition. A piped report therefore never carries terminal control codes into a log, a CI annotation or a parser.

--format sarif emits the report as SARIF 2.1.0, the interchange form CI systems ingest (GitHub code scanning upload, PR annotation): a minimal profile: one run, one result per finding, the data site as the primary location, the schema site under relatedLocations, and the whole native finding embedded in properties, so a SARIF consumer still holds the native contract. Severities map to SARIF levels (info → note). The renderer is library API in both ports (sarifReport(report, version) from aontu; aontu.SarifReport in Go), and its bytes are held to cross-port parity by the golden in test/spec/files/vet-sarif/. A ready-made GitHub Action wrapping the verb ships in this repository: vet-action/.

--watch re-runs the whole vet whenever a watched file (the schema or any data file) changes, streaming one report per run: non-incremental by design: parsed trees are single-use, so every run is a full re-parse and re-unify, bounded by the fixpoint’s pass budget. A file that is briefly unreadable mid-save reports and keeps watching.

Vetting a recursive schema

A recursive schema needs nothing extra from vet: the definition expands one level per meet with concrete data, so the checks descend exactly as far as the data does, and a finding at depth is located there. The vocabulary below is a trimmed version of use-cases/13-recursive-schema. Write it as chain.aontu:

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

--at anchors the run at the definition (hide() keeps spec out of generated output but not out of the path) so a data document is a candidate Step, not a candidate whole file. A two-level chain in request.json holds:

{"approver": "lead@acme.example", "decision": "approved",
 "then": {"approver": "cfo@acme.example"}}
$ aontu vet --at $.spec.Step chain.aontu request.json
verdict: valid

A chain whose third level breaks the approver pattern, as request-deep.json does, is refused at that depth:

{"approver": "lead@acme.example", "decision": "approved",
 "then": {"approver": "cfo@acme.example",
          "then": {"approver": "EXTERNAL@other.example"}}}
$ aontu vet --at $.spec.Step chain.aontu request-deep.json
verdict: invalid

$.spec.Step.then.then.approver: constraint [conflict]
  [aontu/constraint]: Cannot unify values at path $.spec.Step.then.then.approver
  expected: re("^[a-z]+@acme[.]example$")
  actual:   "EXTERNAL@other.example"
  data: request-deep.json:3:32 ("EXTERNAL@other.example")
  schema: chain.aontu:3:24 (re("^[a-z]+@acme[.]example$"))
$ echo $?
1

The finding’s path is the unrolled position ($.spec.Step.then.then.approver), while its schema site is the one re() the author wrote: the definition is written once and applies at every depth, and the report says both. For the recipe form see Define a recursive schema.

aontu subsume

The subsumption query as a command (docs/reference-language.md, “Subsumption”): does the general document admit every instance the specific one admits?

aontu subsume [--profile values|defaults|gen] [--at <path>]
              [--format text|json] <general.aontu> <specific.aontu>

The exit code is the verdict class: 0 subsumes, 1 does not subsume (the findings carry the witness: path, codes, both sites), 3 undecided (always with a sub_* reason), 4 a document that does not stand up on its own, 2 usage. The report reuses vet’s finding object and renderers, class compat.

An unexpanded recursive position is undecided, never guessed. A recursive reference expands only against concrete data, and subsumption compares two documents with no data on either side, so at the recursive position there is no rule to apply, and the query says so rather than answering from hope. Write a general.aontu:

spec: hide({ Step: { label:string then?:$.spec.Step } })
doc: $.spec.Step

and a specific.aontu whose step recurses into a DIFFERENT definition:

spec: hide({ Step: { label:"start" then?:$.spec.Other } Other:label:string })
doc: $.spec.Step
$ aontu subsume general.aontu specific.aontu
verdict: undecided

$.spec.Step.then: sub_unresolved [compat]
  no subsumption rule covers this pair of value formers
  expected: $.spec.Step
  actual:   {"label":string}
  general: general.aontu:1:41 ($.spec.Step)
  specific: specific.aontu:1:63 ({"label":string})
$.doc: sub_unresolved [compat]
  no subsumption rule covers this pair of value formers
  expected: $.spec.Step
  actual:   {"label":"start","then"?:{"label":string}}
  general: general.aontu:2:6 ($.spec.Step)
  specific: specific.aontu:1:20 ({"label":"start","then"?:{"label":string}})
$ echo $?
3

The same recursion on both sides is decided, and decided by identity: a document that recurses, declares a relation or shares a template by reference admits itself, because two values with the same hash form are the same value. The rule runs only where the answer would otherwise be undecided, so it narrows nothing else, and without it a contract could not be gated against its own earlier version at all.

This is the verdict breaking fails on by default: a gate that cannot decide a recursive contract reports undecided and stops, and --allow-undecided is the deliberate downgrade.

aontu breaking

The evolution gate built on the same query: compare a document against its own earlier versions.

aontu breaking --against <file|git#rev> [--at <path>]
               [--mode backward|forward|full]
               [--allow-undecided] [--format text|json] <file.aontu>
  • --against takes a file path or git#<rev>, and is repeatable. A git#<rev> spelling is the old version of the whole tree, not of the entry file alone: the revision’s includable sources (.aontu, .aontu, .jsonic, .json) are materialised into a temporary directory by shelling out to git (no embedded git) and the old document is evaluated from there, so a change inside an @"…"-included file is part of the comparison. The temporary tree is removed when the run ends. Sources outside the revision (package includes under node_modules, the bundled aontu:system) resolve as they always do; their versions travel with the lockfile rather than with this comparison. A file the revision does not carry is a usage failure naming it, not a comparison against nothing.
  • --at <path> compares that path of both versions, the same anchor subsume takes, and findings are reported from it. A module’s top level carries the things that are supposed to change between releases (the version string, the aontu_policy block) so the whole-document comparison answers about those rather than about the contract, and a release that bumps only its version self-breaks the gate. Anchoring at the contract is the fix; splitting the file was the workaround.
  • Modes: backward (the default) checks the new document subsumes the old: documents valid under v1 stay valid; forward checks the old subsumes the new; full checks both.
  • The document can declare its own promise: aontu_policy: hide({compat: *backward | forward | full | none}): breaking reads $.aontu_policy.compat from the new document, and --mode overrides it. none declares no promise: nothing is checked.
  • Exit codes mirror subsume’s: 0 compatible, 1 breaking, 3 undecided, 4 error, 2 usage. Undecided fails the gate by default (a gate that shrugs is not a gate) downgradable with --allow-undecided.
  • --allow-deprecated-removal downgrades a finding about a value the old version already deprecate()d to a warning (still reported, no longer failing): deprecate-then-remove is the supported rename path.

aontu trim

Report redundant map entries (entries whose removal leaves the evaluated result unchanged, the spread-implied case included) as paths.

aontu trim --check [--format text|json] <file.aontu>
  • The test is evaluate-and-compare: for each candidate entry the source is re-parsed, the entry deleted from the parsed tree, and the canon compared to the baseline. This covers everything the fixpoint can see (spread templates, references, duplicate-key merges), and a removal that errors is not redundant: the document does not stand up without that entry.
  • Candidates are map entries at every depth; list elements are not candidates (removing one shifts every later index: a different document, not the same one minus a redundancy). A child of a redundant parent is skipped: removing the parent already covers it.
  • --check is required: trim only reports, and aontu trim f.aontu doing something other than trimming silently would be worse than saying so. It is not blocked on the machinery set --in-place now has (a splice needs no format-preserving editor) but deleting an entry is a different edit from replacing one: a statement’s span does not say which surrounding blank line or trailing comment went with it, and guessing wrong silently rewrites the file’s shape.
  • Exit codes: 0 clean, 1 redundant entries found, 4 the document itself does not evaluate, 2 usage.
  • A verdict of error says why. A document that does not evaluate has no redundancy to report, but it does have a reason: the report carries errors, the engine’s own first failure in the same finding shape vet reports in: code, class, path, sites with file, row, column and extent, and the repair hint. The field is present only on that verdict, and the text renderer prints the finding under the verdict line.

aontu relations

Run the declared-relation checks (acyclicity and inverse consistency) over one finished model.

aontu relations [--format text|json] <file.aontu>

The vocabulary is declared once, at the field; the model lists plain names. The files below are a trimmed version of use-cases/12-relations. Write the vocabulary as spec.aontu:

spec: hide({
  Service: {
    kind: service
    dependsOn?: rel($.spec.ServiceShape) & acyclic() & inverse(usedBy)
    usedBy?: rel($.spec.ServiceShape)
  }
  ServiceShape: kind: service
})

A model whose edges hold, system.aontu, passes:

@"./spec.aontu"

services: { &: $.spec.Service }
services: web: dependsOn: [path($.services.billing)]
services: billing: {
  dependsOn: [path($.services.ledger)]
  usedBy: [path($.services.web)]
}
services: ledger: usedBy: [path($.services.billing)]
$ aontu relations system.aontu
verdict: pass

A bad-system.aontu whose two services depend on each other, with neither inverse written out, fails on every count at once:

@"./spec.aontu"

services: { &: $.spec.Service }
services: auth: dependsOn: [path($.services.billing)]
services: billing: dependsOn: [path($.services.auth)]
$ aontu relations bad-system.aontu
verdict: fail

$.services.auth.dependsOn.0  dependsOn: cycle $.services.auth -> $.services.billing -> $.services.auth
$.services.auth.dependsOn.0  dependsOn: $.services.billing does not list $.services.auth under usedBy
$.services.billing.dependsOn.0  dependsOn: $.services.auth does not list $.services.billing under usedBy
$ echo $?
1
  • Relations are declared at the field, by rel(t) and the graph atoms: acyclic() and inverse(name) register the declaration during unification, and the verb reports the verdict over the finished model’s edge set. There is no reserved relations: key: a document that writes one has written ordinary data.
  • These are not lattice constraints, deliberately. Both properties are global and non-monotone (one more edge makes an acyclic graph cyclic) so they are facts about a finished model rather than something unification may hold. Generation enforces the same verdict (a located relation_cycle / relation_inverse_missing at the offending edge); the verb reports it without generating. The language reference states the rule; the explanation argues it.
  • A finding carries at (the position of the offending edge), code (relation_cycle or relation_inverse_missing), relation, and detail: for a cycle, the node paths it runs through in order; for a missing inverse, [from, to, inverseName]. Findings are sorted by at, so the report diffs cleanly.
  • The endpoint type is rel(t)’s flow: declared once on the field, it flows into each far end at the site, so a conflict or a hole is an ordinary located evaluation error rather than a report row, and a document with a wrong-typed far end answers verdict: error here, because it does not stand up at all. (The old target: declaration and its relation_target_unmet finding are retired with the relations: key.)
  • --format json wraps the same findings with the aontu producer block (verb, version) that every machine-readable report carries.
  • Exit codes: 0 pass, 1 fail, 4 error (the document does not evaluate), 2 usage. Note these are the verb’s own three verdicts, not vet’s five classes: there is no schema on the other side of this question, so incomplete has nothing to mean.
  • A verdict of error says why. A document that does not stand up has no graph, so it has no relation findings, but the report carries errors, the engine’s own first failure in the same finding shape vet reports in. findings stays the graph’s own vocabulary; the two lists answer two different questions, and the errors field is present only on the error verdict.
  • The library form is relationCheck(src) in TypeScript and Aontu.RelationCheck(src) in Go, returning the identical {verdict, findings} record (plus errors on a failed run); the derived graph the checks run over is result.graph / Aontu.Graph, described under the TypeScript API.

aontu reaches

Ask whether one node reaches another over the link graph, at any remove. Endpoints are $.dotted node paths.

aontu reaches <from> <to> [--relation <name>] [--format text|json] <file>

relations asks about the edge set as a whole. This asks the question that needs the closure: does anything from links to, at any remove, end up at to? That is the shape of every blast-radius question an operator asks (“if the billing database goes, what falls over?”) and every containment question a policy asks (“nothing in the public tier may reach the ledger”), and neither can be put one edge at a time. Ask it of the system.aontu model above:

$ aontu reaches $.services.web $.services.ledger system.aontu
verdict: reaches

$.services.web -> $.services.billing -> $.services.ledger
$ echo $?
0
  • The path is the answer, not decoration: “yes” is worth little to an operator asking what a failure would take out, and the chain is what they act on. It is a shortest path, and among shortest ones the first in code-point order, so it is the same path in both ports. A no carries none: there is no evidence for a negative answer.
  • Transitive, not reflexive-transitive. reaches a a is true only when a path of one or more edges returns to a, which says the graph has a cycle through a rather than saying nothing.
  • --relation <name> follows only edges under that relation: the difference between “can this reach that at all” and “can it reach it this way”.
  • A link into part of a node ($.services.auth.ports.http) reaches that node, not the one above it: reachability is between tree positions, and there is no declared boundary to widen it to. Same rule relations uses, and it has to be, or the two verbs would disagree about what an edge connects.
  • Exit codes: 0 reaches, 1 unreachable, 4 error, 2 usage. An unreachable pair is a failed check, not an error: the question was answered, and the answer was no.
  • An endpoint that names no node is a refusal, reported as refer_unresolved with the linked nodes listed: answering no would report a typo as a fact about the model. An endpoint that exists but has no edges is a perfectly good question with the answer unreachable.
  • Like acyclicity, this is a verb and not a lattice constraint: reachability is global and non-monotone, so a citizen asserting non-reachability could be true and then false as one more edge arrives.
  • The library form is reachCheck(src, from, to, options?) in TypeScript and Aontu.Reach(src, from, to, options) in Go, returning the identical {verdict, path?} record (plus errors on a failed run).

In system.aontu the usedBy inverses run the other way, so the ledger reaches the web service in general but not along dependsOn:

$ aontu reaches $.services.ledger $.services.web system.aontu
verdict: reaches

$.services.ledger -> $.services.billing -> $.services.web
$ aontu reaches $.services.ledger $.services.web --relation dependsOn system.aontu
verdict: unreachable

$.services.ledger does not reach $.services.web
$ echo $?
1

And an endpoint that names no node refuses rather than answering:

$ aontu reaches $.services.web $.services.ledgr system.aontu
verdict: error

$: refer_unresolved [reference]
  $.services.ledgr names no node in this document.
  note: nodes with links: $.services.billing, $.services.ledger, $.services.web
$ echo $?
4

aontu view

Draw a figure of a finished model, as deterministic text a golden diff can check: no coordinate is computed, nodes and edges are sorted by code point, and both ports emit the same bytes.

aontu view <kind> [--as <profile>] [--at <path>] [--out <file> [--check]]
           [--strict] [--max-rows <n>] [--depth <n>] [--style <s>]
           [--format text|json] [options] <file>...

Ten kinds. Eight read a report the engine already produces; doc and lattice read the document itself, which is the one thing no report holds:

kinddrawsreadsprofiles
docthe document’s own key tree, to a depththe anchor walk, as get --keys --types reads ittext, svg
latticethe language’s value lattice, with a count at every node the document reachesthe anchor walk, and each value’s own kindtext, svg
treethe dependency tree of a relation: roots derived, repeats elided, cycles markedthe edge settext, svg
matrixthe dependency-structure matrix over one relation, in canon or partition order, with --closurethe edge set and the relation declarationstext, svg
graphthe node-link drawing, grouped and labelled by fields of the nodesthe edge set, the declarations, the node valuesmermaid, dot, er
layerthe architecture layers: one band per value of --group-by, upward edges namedthe edge set and the node valuestext, mermaid, svg
setsthe set-intersection panel over a family of sets (UpSet)the generated valuetext, svg
layerswhich document contributed which paththe provenance recordtext, svg
ladderthe meet ladder at one path: every contribution as a rung, in rank orderthe why recordmermaid, dot
posetthe subsumption order over several documentssubsume, pairwisemermaid, dot

The first profile listed is the kind’s default; asking for another is a refusal (view_profile_unknown), because there is no text form of a node-link drawing and no Mermaid form of a matrix.

--as svg draws the cell-based kinds (every kind whose text form is a grid of character cells) as a standalone SVG with the same geometry: 8 units per character and 20 per line, every coordinate a whole number, so no font is measured and both ports emit the same bytes. The figure carries its own style block (dropped by --style none, below); a host page sets the colours through CSS variables (--av-ink, --av-muted, --av-bg, --av-rule, --av-rule-faint, --av-closure, --av-warn, --av-alert, --av-bar), and the defaults stand where it sets none. The matrix fills a direct cell, tints a closure cell, marks an unmirrored edge, and rules the diagonal; the layers draw each upward edge as a dashed arrow; the panel draws its bars and dots. The node-link kinds stay Mermaid and DOT, whose renderers lay them out.

Draw the system.aontu model above over its dependsOn relation, first as the tree, then as the matrix in partition order:

$ aontu view tree --relation dependsOn system.aontu
web
└── billing
    └── ledger
$ aontu view matrix --relation dependsOn --order partition --closure system.aontu
          1 2 3
ledger  1 \ . .
billing 2 X \ .
web     3 + X \
# above-diagonal direct cells: 0

A cell at (row, column) is set when the row depends on the column: X a direct edge, ! a direct edge whose declared inverse is not written back, + reachable only transitively, . absent, \ the diagonal. In partition order an acyclic relation is a lower triangle, and the footer’s count of cells above the diagonal is the acyclicity proof in the picture’s own shape; a cycle survives every ordering as an above-diagonal cell, and is reported as cycle_block.

The same edges as a node-link drawing, in Mermaid:

$ aontu view graph --relation dependsOn system.aontu
flowchart LR
  n_billing["billing"]
  n_ledger["ledger"]
  n_web["web"]
  n_billing -->|"dependsOn"| n_ledger
  n_web -->|"dependsOn"| n_billing

The lattice kind draws the LANGUAGE rather than the model. The shape is always the same (the value lattice, whatever the document holds) and what the document adds is a count at each node its own values landed on. Write ports.aontu:

host: "0.0.0.0"
port: 8080
tls: true
timeout: 1.5
retries: integer
backoff: integer & min(1)
$ aontu view lattice ports.aontu
                                           top
                                            │
      ┌────────────────────────────────┬────┴───────────────────────────┬─────────┐
 string (1)                         number                         boolean (1)  null
      │                                │                                │         │
      ├─────────────┬────────────┬─────┴─────┬────────────┐             │         │
   path()      integer (2)   float (1)  biginteger   bigdecimal         │         │
      │             │            │           │            │             │         │
      └─────────────┴────────────┴──────────┬┴────────────┴─────────────┴─────────┘
                                           nil

A concrete scalar sits at its kind (8080 counts at integer) and a kind written as a schema sits at that node, which is why retries: integer is the second of the two. backoff is at neither: integer & min(1) is a region of the lattice rather than a point, so the figure declines to draw it anywhere and names it instead:

lattice_unplaced  1  $.backoff

An unresolved disjunction is unplaced for the same reason: *8080 | 9090 is two places at once, and a node that claimed either would be claiming something the document does not say.

  • The loss report. Every run prints, on stderr, what the figure could not draw or drew differently from the model, one line per code with a count: hidden_contribution (an edge inside a hide() subtree, not drawn, because a committed figure discloses what it draws), edges_in_disjunct (a link under an unresolved disjunction, which is not a fact, so the figure reports it rather than picking an arm), unresolved_field (a node without a value for --group-by or --label), lattice_unplaced (a value at no single point of the lattice, named rather than drawn), cycle_block, cols_elided, and for the poset order_undecided, order_maybe_equal and order_intransitive. Any of these makes the verdict lossy, which --strict turns into exit 1. Three codes are informational and leave the verdict rendered: edges_deduped (a model declaring each entity at two positions writes each edge twice), inverse_suppressed (a declared mirror, implied by the edge drawn) and crossings (a property of the emitted order).

  • --style <s> says how a figure carries the MEANING of its marks. Every mark has a reason the extractor established (a direct cell, a closure cell, an unmirrored edge, an upward edge, a repeated subtree) and each profile has one way to show it: SGR escapes for text, CSS classes for svg. auto, the default, picks that mechanism where the destination can carry it: escapes only when stdout is a terminal and NO_COLOR is unset (stdout, because that is where the figure goes: the error frames’ own colour decision is about stderr), and an SVG keeps the stylesheet that makes it standalone. none drops both: on svg the classes stay (they are structure, not style) and only the embedded stylesheet goes, which is what a host page wants once it has bound --av-ink and its kin and is embedding several figures. ansi and css name a mechanism outright, and asking for one on a profile that cannot carry it is a usage error (view_style_profile) rather than a silent no-op. Escapes are never written to a file: --out with --style ansi is refused, and auto resolves to no escapes there.

    Neither mechanism states a colour, and a figure still cannot name one: SGR 31 means the colour the reader’s terminal calls red, and a CSS class states nothing at all. A hex triple in a figure stays refused, and so does style in a view document: a declaration says which projection, never how it looks.

  • --out <file> and --check. The figure is written to the file instead of stdout; with --check nothing is written and the exit is 1 when the file differs from what would be drawn, which is the CI gate for a committed figure.

  • --max-rows <n> (default 60) is a refusal, exit 2, not a truncation; the message names the narrowing options.

  • doc: the shape of the model, before any of its values mean anything. Every other kind here needs the document to HAVE something (links, contributions, peers) and draws nothing from one that does not; this draws what is in the document and how it is arranged, which is what a reader meeting a model wants first. --at names the subtree (default $) and --depth <n> how many levels of key below it (default 3). Map keys are in code-point order and list indices in order, exactly as get --keys lists them. A leaf carries its canon, cut at 32 characters: the kind of thing it is, not its value. A container the depth bound stops at carries the number of keys not drawn, and they are counted into the loss report as depth_elided: a tree that stopped without saying so would be the one thing a structural drawing must not be.

  • --at <path> restricts the edge-derived kinds to nodes under the path, names the subtree doc draws, the provenance panel to paths under it, and names the path the ladder draws (required) and where the poset compares.

  • tree: --relation draws one relation; without it every relation is drawn, each branch naming its own. --root <path> (repeatable) draws one subtree; a root that is not a node of the drawn graph is refused (refer_unresolved). Roots are derived as the nodes nothing depends on; a shared subtree is expanded once and marked (*) after; a closing edge is (cycle); labels are the shortest path suffix unique in the drawing.

  • matrix: --relation is required unless exactly one relation has edges (view_relation_ambiguous otherwise); --order canon (label order, the default) or partition; --closure marks the transitively reachable cells. Ten or more rows stack the index digits in the header.

  • graph: --relation (repeatable) keeps only those predicates; a declared inverse’s mirror is suppressed and counted. --group-by <field> puts each node in a subgraph named by that field’s value (ids g0, g1 and so on, in label order); --label <field> labels the node with it, a number or boolean as its canon. Node ids encode the label injectively: n_ + the name when it is an ASCII identifier, else nq_ + the name with every other code point as _ and its hex (cust-1 is nq_cust_2d1, so a name such as end or graph can never collide with a keyword). Text is escaped per code point: Mermaid as numeric entities (#34; for ", #124; for |), DOT as \" and \\. A label holding a line terminator is refused (view_line_break). --as er draws Mermaid’s erDiagram, every relationship many-to-many because the model states no cardinality.

  • layer: --group-by <field> (required, view_group_required) names each node’s layer; bands are stacked in the partition order of the layer-level graph, reversed, so the layer nothing depends on is on top. --layers a,b,c fixes the order (top first) for a model whose upward edge makes the layer graph cyclic. The footer counts the relation’s downward, sideways and upward edges, and names each edge --edges shows. --edges upward|all|none chooses which of them the figure draws over the bands: upward is the violations, and the default for the fixed grids (text, svg), because the bands already say which way the rest go; all draws the relation itself, which is what a reader tracing one module’s dependencies wants, and is mermaid’s default since it lays edges out itself; none leaves the bands alone. In SVG an upward edge is dashed and alert-coloured, a downward one runs from the bottom of its box to the top of the one it names, and a sideways one dips below the boxes of its band.

  • sets: --sets <path> names a map whose keys are the sets, --member <key> the field holding each set’s members (a list of strings), --universe <path> a map or list of every element, so the covered-by-nothing column exists. A member written as an address (path($.perms.read)) meets a universe map’s key on that address and is shown by its shortest unique suffix. Columns are the exact membership signatures, by degree, then cardinality, then name; --min-degree <n> drops the low ones and --max-cols <n> elides the rest (cols_elided). Both need the document to generate.

  • layers: the same panel over the provenance record, sets being the documents and elements the paths each document wrote into. Files are shown relative to the entry document; --min-size <n> drops the small intersections.

  • ladder: the why record at --at, one rung per contribution, sorted by rank descending (weakest first, so the winner is the last rung before the value), then by site.

  • poset: several files; each pair is compared with subsume at --at under --profile values|defaults|gen (default defaults). Documents that subsume each other are one node, labelled a = b; an edge is a cover of the transitive closure, upward toward the more general document; an undecided pair with no proven order is a dashed edge labelled with the sub_* reason. Labels are the filenames without .aontu.

  • Exit codes: 0 rendered or lossy, 1 a --check mismatch or lossy under --strict, 2 usage (an unknown kind or profile, a missing required option, --max-rows exceeded), 4 error (a document that does not stand up, a relation, root or path that names nothing). A document with nothing to draw renders an empty figure and exits 0. In text form the figure is all that stdout carries, so a redirect is a golden file; --format json wraps the whole report as {kind, verdict, text, loss} under the usual aontu envelope, and a refusal carries errors in place of text.

  • The library form is view(src, options) in TypeScript (viewTree remains for the tree) and Aontu.View(src, options) in Go, returning the identical {verdict, kind, text?, loss, errors?} record; the poset’s further documents ride options.docs as {src, path?, name?}.

The view document. A projection that runs in CI belongs in a file. --views <path> names a map, in an ordinary document that includes the model, whose values declare figures: one evaluation, N figures, one exit code. The keys of a declaration are the view options (the flags without the dashes) and every declaration names its kind and the out file it draws into. views is the author’s key; nothing in the engine knows the name, which is why the path is given. Write a views.aontu beside the system.aontu above:

@"./system.aontu"

views: arch: {
  kind: matrix
  relation: dependsOn
  order: partition
  closure: true
  out: "arch.dsm.txt"
}
views: map: {
  kind: graph
  relation: dependsOn
  groupBy: owner
  as: mermaid
  out: "arch.mmd"
}
$ aontu view --views '$.views' views.aontu
$ aontu view --views '$.views' --check views.aontu
$ echo $?
0

Each out is resolved against the view document’s own directory, so the gate passes from any working directory. Nothing is written unless every figure rendered: N figures of one model are only meaningful together, so a set whose third figure refuses leaves the first two off disk, and its exit code is the worst of the figures’. --check compares the whole set and names every difference; --strict turns any figure’s loss into exit 1. A declaration that names an option that is not one, gives a value of the wrong shape, or leaves out kind or out is view_document_shape, reported for every faulty declaration at once and before anything is drawn. The poset is refused there: it compares several documents and a view document declares figures of the one it includes. The library form is viewSet(src, options) in TypeScript and Aontu.ViewSet(src, options) in Go, returning {verdict, views, errors?} where each view is {name, kind, out, verdict, text?, loss, errors?}: the caller writes the files.

@"aontu:view" is the bundled schema for a declaration, so the same mistakes are refused when the document is EVALUATED rather than when the verb reads it. Write a views-typed.aontu:

@"aontu:view"
@"./system.aontu"

views: { &: $.aontu.View.Figure } & {
  arch: {
    kind: matrix
    relation: dependsOn
    order: partition
    closure: true
    out: "arch.dsm.txt"
  }
}
$ aontu view --views '$.views' --check views-typed.aontu
$ echo $?
0

$.aontu.View.Figure types every option, and a kind that is not a kind, an order that is not an order or a count below zero is an ordinary unification failure naming aontu:view as the other operand. It is optional: a view document that does not include it is read exactly the same way, and refused by view_document_shape instead. The source is served from the engine, as aontu:system is, so it needs no filesystem and resolves under every include capability but 'none'.

The use cases pin one figure of each kind as a golden: 01-service-catalog the graph and the matrix, 04-schema-evolution the poset, 08-feature-flags the ladder, 12-relations the graph and the ER diagram, and 16-module-deps the tree, the matrix and the layers; 16 also declares all seven of its figures in a views.aontu that its check.sh gates in one run.

aontu jsonschema

Export a document as a JSON Schema (draft 2020-12), and say what could not be carried.

aontu jsonschema [--at <path>] [--strict] [--format text|json] <file>

This is the interop bridge. Every major LLM provider’s structured-output API constrains generation to JSON Schema and to nothing else, so the shape an enterprise actually deploys is: export the model, let the provider generate under it, then vet the result against the model itself: the schema narrows what is produced, the model decides what is true. An MCP tool’s inputSchema, which the protocol requires to be JSON Schema, is the same export.

The schema goes to stdout and the losses to stderr, so aontu jsonschema x.aontu > schema.json writes a usable schema and still tells the reader what it left behind. Write a contract.aontu:

spec: name: string & re("^[a-z][a-z0-9-]{2,39}$")
spec: tier: *internal|standard|critical
$ aontu jsonschema --at spec contract.aontu
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "properties": {
    "name": {
      "pattern": "^[a-z][a-z0-9-]{2,39}$",
      "type": "string"
    },
    "tier": {
      "default": "internal",
      "enum": [
        "internal",
        "standard",
        "critical"
      ]
    }
  },
  "required": [
    "name",
    "tier"
  ],
  "type": "object"
}
  • It exports the unified value, not the parse: what a document MEANS is what a consumer should be constrained to.
  • --at <path> names the subtree to export: the same anchor vet --at takes, so --at spec means the same thing in both.
  • --format json prints the whole report (schema, lossy, verdict) under the usual aontu: {version, verb} envelope.
  • Exit codes: 0 exported, 1 lossy under --strict, 2 usage, 4 the document does not stand up on its own. Without --strict a lossy export is still an export and exits 0.

What crosses exactly. Kinds become type; a concrete scalar becomes const; a disjunction of scalars becomes enum, and its preference becomes default; bounds become minimum/maximum, with the open endpoints as 2020-12’s exclusiveMinimum/exclusiveMaximum; re becomes pattern (aontu’s portable subset is a subset of ECMA-262, which is what JSON Schema reads, so no translation happens); neq becomes not: {enum: …}; length becomes minLength/maxLength on a string and minItems/maxItems otherwise; unique() becomes uniqueItems; an optional key is simply absent from required. A spread is additionalProperties: <template>, which is what a spread means. A written list is a tuple, so prefixItems plus items: false.

And close() is additionalProperties: false: the one thing the two languages say identically, and the reason the export is worth having at all: the closedness an agent’s output must respect crosses without loss.

What does not cross is REPORTED, never dropped in silence. A converter that silently lost a constraint would hand its caller a schema that admits more than the model does, which is the failure this language exists to refuse. So each loss carries its path, the aontu construct’s own name, and one sentence saying what the schema says instead:

lossy: $.spec.total must: an evaluate-only check is opaque by
  construction … so it is DROPPED and the schema admits values `vet`
  refuses

The losses, and why each is one:

ConstructWhy JSON Schema cannot say it
must(c, m)Band B is opaque by construction: it carries the author’s own message and the algebra never reasons about it
unique(k)there is no uniqueness-by-property keyword; uniqueItems compares whole items
biginteger, bigdecimal, and exact literalsJSON has one number type and it is binary64, so the exactness these leaves exist for has no receiver
hide(x)a hidden entry is not generated, so it is not part of the value a consumer produces
&: on a closed mapthe template constrains keys that cannot exist
a length with no domainno keyword counts a string or a container, so it is exported as minItems/maxItems
residue: an unresolved reference, a waiting callnot a property constraint at all; guessing one would be inventing a promise

The exact-leaf loss is the one with a way around it. Money carried as a decimal string with a conversion mark exports without loss (the pattern and the mark both cross) and stays exact on the aontu side: see Carry exact money over JSON.

A recursive position is residue, and exports as residue. JSON Schema can spell recursion ($defs plus $ref), but this exporter does not mint it: a recursive reference that has met no data is unresolved, so it crosses as the empty schema {} (a position that admits anything) and is reported under lossy as unresolved, like any other residue. Two consequences follow. Anchor the export at a definition kept un-hidden, because a hide() mark propagates and a hidden entry is omitted from the export entirely; and treat the exported schema as wider than the model at the recursive position: vet the produced value against the model, which does check every depth. --strict turns the loss into exit 1. Write a recursive steps.aontu:

Step: { approver:string & re("^[a-z]+@acme[.]example$") then?:$.Step }
$ aontu jsonschema --strict --at Step steps.aontu
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "properties": {
    "approver": {
      "pattern": "^[a-z]+@acme[.]example$",
      "type": "string"
    },
    "then": {}
  },
  "required": [
    "approver"
  ],
  "type": "object"
}
lossy: $.Step.then unresolved: this is not a value yet, so there is nothing to constrain a consumer to; the schema admits anything here
$ echo $?
1

Everything above then crosses intact; the tail is the gap it reports. Without --strict the same export exits 0.

  • The library form is jsonSchema(src, options?) in TypeScript and Aontu.JSONSchema(src, at) in Go, returning the identical {verdict, schema, lossy} record (plus errors on a failed run).

aontu model get

Select one node of an evaluated document by path and render it: the task-sized slice, instead of the whole file as one JSON blob.

aontu model get <path> [-c|--canon] [--keys] [--types] [--depth <n>]
          [--format text|json] <file.aontu>
  • Evaluation is global. Unification has no partial mode: the whole document is evaluated and then one node is selected. What get buys is the size of the answer, not the cost of producing it.
  • The path is what a reference means by $.a.b: map keys and canonical-decimal list indices, and nothing else, so $.a.01 names nothing here exactly as it names nothing there. A key that contains a dot is likewise unreachable, as it is to a reference.
  • Default output is the fragment’s generated JSON; --canon is its canonical form, and for the root path that is byte-identical to aontu --canon.
  • Exit codes: 0 rendered, 1 the path names nothing (the finding carries a nearest-key suggestion), 2 usage, 4 the document does not stand up on its own, including a node that is not concrete, for which there is no JSON to print.

Write an app.aontu whose spread template supplies defaults:

services: { &: { replicas: *1|integer port: *8080|integer } }
services: auth: replicas: 3
services: billing: {}
$ aontu model get $.services.auth app.aontu
{
  "port": 8080,
  "replicas": 3
}
$ aontu model get --keys $.services app.aontu
auth
billing
$ aontu model get $.services.auht app.aontu
$.services.auht: no_path [reference]
  The path $.services.auht names nothing in this document.
  note: did you mean auth?
$ echo $?
1

The mistyped path is exit 1 with a suggestion, not an empty render: a missing key and an empty value are different answers.

The projections are lattice abstractions. Each view is a valid aontu document that subsumes the truth: generalisation, never distortion:

flagview
--typesevery concrete leaf lifted to its own kind: {"replicas":3} becomes {"replicas":integer}
--depth nstructure to depth n; every elided subtree renders as top: “no further information at this tier”
--keysthe node’s own key names (or list indices), one per line

On app.aontu the shape view erases the concrete leaves:

$ aontu model get --types $.services.auth app.aontu
{"port":*integer|integer,"replicas":integer}

That claim is checked rather than asserted: every projection row of test/spec/query.tsv runs subsume(view, truth) in both implementations and requires subsumes. It runs under the values profile, deliberately: a shape view erases defaults (*8080|integer becomes *integer|integer, as above), which the defaults profile would rightly call a compatibility break. The claim projections make is about the values admitted, not about which one is generated.

Kinds are lifted through the lattice’s own superior(), so the view follows the type system rather than a table of the renderer’s opinions; a value that is already an abstraction (a kind marker, a constraint, an unresolved reference) is left alone rather than generalised twice. Projections are not canonical form and are never fed to aontu hash: the flags are distinct from --canon to keep that unambiguous.

aontu model why

Provenance: what contributed to the value at a path, in order, with the site each contribution was written at. The positive twin of vet’s report: errors explain what failed to unify, why explains what did.

aontu model why <path> [--format text|json] <file.aontu>

Ask it about the app.aontu above:

$ aontu model why $.services.auth.replicas app.aontu
$.services.auth.replicas = 3
  1. *1|integer  app.aontu:1:28  (spread)
  2. 3  app.aontu:2:27
  • A contribution is a value the author wrote that met something at this path. Values the engine mints on the way (a kind lifted from a leaf while a disjunction trials its members, a fold’s intermediate) are not contributions, and neither are the members inside one written value, which meet at the same path as that value resolves. A conjunct is the exception in the other direction: a & b, or the merge of two duplicate keys, is the statement that several separately written values must all hold, so it expands into one contribution each.
  • Roles: literal, spread (a template applied to this key), ref (the reference itself, whose canon names its target) and pref. A preference inside a spread template reports as spread, which is the thing the author needs to be told.
  • Contributions are listed in source order (file, then row, then column) not in the order the fixpoint happened to meet them, which is an engine detail.
  • Provenance travels with a clone. A value that reached this path by being copied from somewhere else (a spread template applied per key, a pack() generator’s child, a $ref, a refer(t) flow) is reported as the value the author wrote, at the line they wrote it on. That is the whole audit question: which file set this? A clone of a written value is that written value somewhere else, so it is named; a value the engine mints on the way is not.
  • The value that stands at a path is a contribution when nothing met there. A generator places a value without meeting anything, and a path with no meets still has a source.
  • A value the author never wrote and no template supplied has no contributions, and says so. That is a fact about the document, not a failure.
  • --format json emits the record: {path, value, conjuncts: [{canon, role, site}]}, with sites in the same shape the vet report uses. Exit codes mirror get’s: 0 explained, 1 the path names nothing, 2 usage, 4 the document does not stand up.
  • Cost: the recorder rides the context and is off by default: uninstrumented evaluation pays one property load per meet. An instrumented run pays site materialisation, one map entry per path met, and the spread walk that marks a template’s application.

aontu model set

Change a document by appending to an overlay: or, with --in-place, by rewriting the literal inside that same overlay. --overlay is required either way, and the entry document is never written.

aontu model set <path>=<value>... --entry <file> --overlay <file>
         [--in-place] [--dry-run] [--format text|json]

Write an entry.aontu that constrains owner and pins replicas:

services: auth: { owner:string replicas:3 }
$ aontu model set '$.services.auth.owner="identity-2"' --entry entry.aontu --overlay changes.aontu
verdict: valid
wrote: changes.aontu

A pinned value refuses the append and the overlay is left unchanged:

$ aontu model set '$.services.auth.replicas=5' --entry entry.aontu --overlay changes.aontu
verdict: invalid

$.services.auth.replicas: scalar_value [conflict]
  [aontu/scalar_value]: Cannot unify values at path $.services.auth.replicas
  data: changes.aontu:2:33 (5)
  schema: entry.aontu:1:41 (3)
$ echo $?
1

A literal the overlay itself pinned is --in-place’s case: the span is rewritten where it was written, and the report says so as source text:

$ aontu model set '$.services.auth.owner="identity-3"' --entry entry.aontu --overlay changes.aontu --in-place
verdict: valid
replaced: changes.aontu:1:30 "identity-2" -> "identity-3"
wrote: changes.aontu
  • The assignment becomes a path-flattened conjunct: $.a.b=1 is appended as "a": "b": 1, keys quoted so a segment may be a word the grammar spells otherwise, a number, or hold a space. The text is split at the first =; everything after it is aontu source, so a value may contain one.
  • This needs no rewriter, and damages nothing: an overlay entry is just another conjunct, and unification is order-independent, so appending to a second file is the same value as writing into the first. The shared suite asserts that equivalence for every row rather than claiming it.
  • Appending cannot change a pinned value. The lattice refuses 5 against 3, the verdict is invalid, and the finding names the pinning site, which aontu model why then explains. --in-place closes that loop.
  • A path reached through a reference is refused (patch_not_editable). n: $.base against base: 7 is pinned by base’s line, not by n’s: splicing there would rewrite the referent for every reader of it and leave the named path where it was. The assignment is appended instead, exactly as it would be without the flag.
  • --in-place rewrites the literal where the author wrote it. The span at (row, col, len) is replaced and nothing else is touched, so comments and layout survive, including a comment on the edited line. Nothing is re-serialised, which is why no CST is needed: a targeted splice never reads the bytes it does not replace.
  • The span is verified before a byte is written. A site carries src, the source text it claims to cover, and the text at the span must equal it. That is what makes port: 0x1F safe to rewrite even though its value is 31: the span is four code units and says so.
  • An overlay that loads another document is refused outright. A literal reached through @"..." cannot be told apart from the overlay’s own by position (an include holding a: 42 at 1:4 and an overlay holding x: 42 at 1:4 give the same site and the same text) so the evaluation that decides what to edit denies loads, and what resolves is what the overlay says by itself.
  • It rewrites only a single editable literal, and appends otherwise. The contribution must be one literal-role conjunct in this overlay whose src, parsed alone, means the contribution’s own canon, which refuses a compound, because a site names a compound’s opening token (min for min(1), 1 for 1+2, { for a map). It must also be concrete: a: integer is a constraint, not a pin, and appending narrows it without discarding what it says. Anything else appends exactly as plain set would, plus one warning naming the case: patch_not_editable, patch_ambiguous or patch_span_mismatch. Warnings never move a verdict, so --in-place cannot turn a run that would have held into one that does not.
  • A default (a: *1) is left alone with no warning: appending already overrides a default correctly.
  • The text form says would replace: when nothing was written, and replaced: only when the file changed: a run can have one replaceable assignment and another that refuses it as a whole. A run that HOLDS writes its status to stdout whether or not it carries warnings; the warnings go to stderr beside it.
  • The report gains replaced, one entry per rewrite, carrying the path, the site, and from/to as source text: replacing 0x1F with 31 is a different edit from replacing it with 0x1F, and only the spelling says which.
  • The overlay is written only when the change holds. An invalid or error verdict leaves the file exactly as it was: a change the author still has to think about should not sit in their configuration while they do. --dry-run writes nothing either way and prints what would have been written.
  • A missing overlay file is the empty overlay, and is created.
  • Exit codes are vet’s verdict classes: 0 valid, 1 invalid, 2 usage, 3 incomplete, 4 the entry does not stand up on its own.

aontu allow

Ask a role model whether a role may modify every one of the given subtrees, and answer before the change is made: the gate an agent runs in front of set, from a document that is itself aontu.

aontu allow --role <role> [--at <path>] [--format text|json]
            <roles-file> <path>...
  • The role model is an aontu document: one entry per role, each carrying allow (the subtrees it may modify) and optionally deny (the ones it may not), as path strings. The map lives at $.roles unless --at names another path, and a role is whatever identifier its author writes.
  • A path is allowed when an allow entry is at or above it. Being allowed $.services allows $.services.auth and $.services.auth.replicas; it does not allow $, because a change at the root reaches every sibling too.
  • A path is refused when a deny entry is at, above or below it, whatever the order. Denied $.services.*.tier refuses $.services.auth.tier, and refuses $.services.auth and $.services as well, because a change at either could rewrite the tier. Deny wins over allow however the entries were written.
  • * in a path matches any one key. It is the only pattern character; every other segment is a map key or a list index compared for equality, as a reference compares them.
  • A role with no allow list allows nothing, and so does a role the model does not declare: the verdict is refused, and a no_path finding at the role’s path names the nearest declared role when one is close.
  • Every asked path is answered, and the verdict is allowed only when every one of them is. Each answer names the entry that decided it as a path into the role model, so aontu model why locates the rule and the line that wrote it.
  • Every path starts with $, and may be spelled as set’s assignment, <path>=<value>, so a skill can hand the gate the very arguments the write will get. The value must be one value: set appends it as source after the flattened path, so a value carrying a second pair (3 secrets: key: "x") would write a sibling of the overlay root, a subtree the gate was never asked about. Such an argument, an empty one, or a second filename in a path’s place is a usage error, exit 2.
  • --format json emits {aontu, findings, paths, role, verdict}. Each entry of paths carries path, allowed and reason (allow, deny, uncovered or no_role), plus by and pattern when an entry decided it.
  • Exit codes: 0 allowed (every path), 1 refused (at least one path, or a role the model does not declare), 2 usage, 4 the role model does not stand up on its own, in which case the engine’s own finding follows the verdict after a blank line.

The shape of a role is aontu too. The model meets it at evaluation, as data meets a schema under vet: a spread template over the roles map, roles: { &: { allow: [&: Entry] deny?: [&: Entry] } } where Entry is string & re("^[$]") & re("[^.]$"), so a malformed role (allow: "$.a", deny: [1], an entry that is empty or does not start at $ or ends in a dot, a roles map that is a number) is refused with the engine’s own code and site, exit 4. A role model that close()s its role vocabulary must declare deny? in it, or the template’s optional key is refused as [aontu/closed] and no role can be asked about. The lists are read from the written tree rather than the generated document, so a hide()d deny still denies; each entry must be one concrete string, and a kind (string) in an entry’s place is refused as no_gen.

Three limits follow from what the gate compares:

  • A key that contains a dot is unreachable, as it is for get: an asked path and an entry are both split at every dot, quotes included, so neither side can name such a key. A role is one key, looked up as written and never split, and the command refuses a dotted role name, because the entry paths it reports could not then be followed by why.
  • The gate answers about the path, and says nothing about where the value is written. A path reached through a reference is set’s business: the append lands at the named path, and --in-place is refused there.
  • The verb is TypeScript-only for now: the Go CLI does not have it.

Write a roles.aontu that declares three roles:

roles: admin: allow: ["$"]
roles: dev: {
  allow: ["$.services" "$.deploy.*.replicas"]
  deny: ["$.services.*.tier"]
}
roles: qa: allow: ["$.tests"]

dev may change any service, and the replica count of any region:

$ aontu allow --role dev roles.aontu $.services.auth.replicas $.deploy.eu1.replicas
verdict: allowed
role: dev
$.services.auth.replicas: allowed by $.roles.dev.allow.0 ($.services)
$.deploy.eu1.replicas: allowed by $.roles.dev.allow.1 ($.deploy.*.replicas)

A service’s tier is denied, and so is the service above it, because a change there could rewrite the tier:

$ aontu allow --role dev roles.aontu $.services.auth
verdict: refused
role: dev
$.services.auth: refused by $.roles.dev.deny.0 ($.services.*.tier)
$ echo $?
1

A role the model does not declare may change nothing, and the finding says which path was looked for:

$ aontu allow --role ops roles.aontu $.services.auth
verdict: refused
role: ops
$.services.auth: refused (role ops is not declared)

$.roles.ops: no_path [reference]
  The role ops is not declared at $.roles in this document.
$ echo $?
1

The deciding entry is a path into the role model, and why names the line that wrote it:

$ aontu model why $.roles.dev.deny.0 roles.aontu
$.roles.dev.deny.0 = "$.services.*.tier"
  1. "$.services.*.tier"  roles.aontu:4:10

aontu agentsmd

Generate the AGENTS.md stanza for a definition: the prose entrypoint, derived from the formal source so it cannot drift from it.

aontu agentsmd [--write <AGENTS.md>] [--depth <n>] <file.aontu>

The stanza names the document, its canon-hash pin, its root keys and its shape, spells the get / why / vet / set commands with a path that actually exists in it, and points at aontu help language for the language the document is written in. --write splices it into a file between <!-- aontu:begin --> and <!-- aontu:end -->, appending the markers when they are absent: everything outside them is left exactly as it was, so the verb is safe to re-run and safe to point at a file someone else writes prose in.

--depth <n> is how deep the shape line projects, default 2. Two levels name the root keys and say top under them, which says what the document is about rather than what is in it; --depth 4 on an entity map reaches the fields. The default is unchanged because the stanza is spliced into a file people read.

Exit codes: 0 generated, 2 usage, 4 the document does not stand up on its own.

aontu fmt

The source formatter, in the tradition of gofmt: one agreed form for aontu source, so that layout is never argued about and a diff shows only what changed.

aontu fmt [-w|-l|--check|-d|--lint] [--marker <token>]
          [--profile <file>] <file>...
aontu fmt < in.aontu > out.aontu
optiondoes
(none)print the formatted text; one file, or standard input
-w, --writerewrite each file in place, when its form would change
-l, --listprint the name of each file whose form would change
--checklike --list, and exit 1 when any would: the CI gate
-d, --diffprint a unified diff for each file whose form would change
--lintreport the style findings, key case and repeated shapes, on standard error, and print nothing else
--strict--lint, and exit 1 when there is a finding
--marker <t>the file is a generator, and this is its marker (default //-, and #- --- /*- <!--- by extension)
--profile <f>a profile file, whose template.ext names the extensions it marks and template.marker the marker
  • The form. Two-space indentation. key: value, the colon tight to the key. No commas between entries (a call’s argument list keeps them). Braces only where the language needs them: a one-pair map is a chain, a: b: c: 1, and a one-key map in a list is a pair element, [a:1 b:2]. A container on one line when it fits 80 columns, padded inside braces and with the colons tight, { a:1 b:2 }, and as a block when it does not. Keys bare where they can be; a single-quoted string double-quoted unless it holds a double quote; numbers as written. Every comment and every blank line between groups stays, and a comment that ends a line of code sits two spaces behind it, port: 8080 # the admin port, normalised from whatever the author left. The formatter never breaks a line, and keeps the author’s line breaks inside an expression, at their operators.
  • The prefix is repeated. A map that does not fit on one line is written as one statement per entry, each carrying its key again: server: host: "0.0.0.0" / server: port: 8080. A key written twice is a meet, so the two spellings are one document. The prefix reaches through nested maps and stops at a RECORD (a map of several entries, all of them values) which is written as a braced block under the prefix instead, field: id: { and its facts one level in. Adjacent statements naming one key are the same map to the formatter, and are written as one line when that fits. Only a plain map in statement position: a map that is an argument, an operand or a list element keeps its braces, because splitting it would change the document.
  • It reads the file it is given and no other. An @"..." include is a token like any other, so --trust has nothing to confine in it. The verb does take the flag, and it governs the --profile document, which is evaluated: --trust none beside a profile that includes a file refuses with include_denied.
  • A new tree at the top level stands apart. One blank line above a top-level statement that takes more than one line to write, and one below it, above its own comments so that a note travels with what it describes. What counts as a tree is measured rather than guessed: the statement did not fit on one line. a: 1 beside b: 2 is left where it is, and the rule is the root’s alone; below it a blank line is the author’s.
  • A generator is formatted as the document it carries. A file whose extension is not .aontu is a generator written in the target’s own syntax (aontu template), as it is for trace: it is desugared, formatted and resugared, so what comes back is a generator. The marker stands at the left margin with the aontu indented after it, so the tree the marker lines carry has a shape on the page, and every line of output is held on a line of its own, which the packing budget would otherwise fold three strings into. --marker names the marker for a language the table does not know. A file with no marker line in it is another language’s, a .json, .yaml or .toml include, and is refused by name, exit 2.
  • It checks its own work. Before a byte is returned the formatted text is parsed again and compared with the input, tree to tree; a disagreement is refused as the formatter’s own defect (format_check), and nothing is written. Each repeated or merged statement is also evaluated both ways in isolation, and a rewrite the engine evaluates differently is not made: that statement keeps its braces.
  • It points at style, and touches nothing. --lint reports on standard error, one line per finding as file:line:col: rule: message, and changes neither the document nor the exit code. style/key-case is a bare key holding an underscore or beginning with two capitals, credit_cents, HTTP_PORT, with the spelling that would follow the form, creditCents, httpPort; a quoted key that must stay quoted is a deliberate spelling. style/repeat is a map or list whose shape is written two or more times in the file and is 40 columns or wider, reported once, at its first site, with the other sites, because a value written twice can drift and an alias names it once. A chain and the braces it stands for are one shape, the order of a map’s entries is not part of it, and a repeat inside a repeat is the outer one’s. --strict is --lint with exit 1 when there is a finding.
  • With no file it reads standard input; with several files it needs --write, --list, --check, --diff or --lint, rather than print them as one stream.
  • Exit codes: 0 formatted or clean, 1 a --check file would change or a --strict finding, 2 usage, 4 a document does not parse.

A configuration written as it came from JSON, config.aontu:

{
  "server": { "host": "0.0.0.0", "port": 8080, "tls": { "enabled": true, "cert": "/etc/tls/cert.pem" } },
  "features": ["auth", "metrics"],
  "limits": { "rps": 100, "burst": 200 }
}
$ aontu fmt config.aontu
server: host: "0.0.0.0"
server: port: 8080
server: tls: { enabled:true cert:"/etc/tls/cert.pem" }

features: ["auth" "metrics"]
limits: { rps:100 burst:200 }

The formatted text is the same document: aontu hash of the two agrees, and formatting the formatted text changes nothing.

aontu template

Read a generator written in the target’s own syntax, and print the aontu it means.

aontu template [--resugar] [--check] [--marker <token>]
               [--profile <file>] <file>

One rule: a marked line is aontu source, and every other line is a line of output. The marker is the target’s comment token plus a dash (//-, #-, ---, or a block form such as /*- … */ and markdown’s <!--- … --> where the language has no line comment) so the file stays valid in its own language, an editor highlights it, and the target’s compiler parses it.

A language the table does not know says so once. --marker names the marker for one call; a profile names it for every call, because a profile is a language declared as data and aontu template and aontu fmt read the same file. A marker carries its own closer after a space when the opener does not imply one:

@"aontu:profile"

aontu: Lang: lang: "ocaml"
aontu: Lang: indent: { unit:" " width:2 }
aontu: Lang: template: { marker:"(*-" close:"*)" ext: ["ml" "mli"] }
aontu template --profile ocaml.aontu unit.ml

Write a greet.ts:

//- who: { world: {}, moon: {} }
//- svc: $.who & pack($.who, { name: key() })
//- out: emit($.svc, {
//- match: { name: string }
//- body: [file("greet-" + .name + ".ts", emit([_], {
//- match: { name: string }, replace: { NAME: .name }, body: [
export function greet() {
  console.log(`hello, NAME`)
}
//- ]}))]
//- })

The three unmarked lines are the output. They are ordinary TypeScript, indented where they belong, and a value reaches them through replace rather than through a hole: NAME is a string the body already holds, and there is no delimiter to collide with the target’s own syntax: the backtick template literal above survives untouched. The inner dispatch is what makes replace reach those lines: a rule substitutes into the body it wrote, so the lines of a file and the map that names the file are two rules, not one.

The verb prints the canonical form, which is what the marker lines mean once the output lines are quoted:

$ aontu template greet.ts
who: { world: {}, moon: {} }
svc: $.who & pack($.who, { name: key() })
out: emit($.svc, {
match: { name: string }
body: [file("greet-" + .name + ".ts", emit([_], {
match: { name: string }, replace: { NAME: .name }, body: [
`export function greet() {`
"  console.log(`hello, NAME`)"
`}`
]}))]
})

Each output line is one string, and the quote is chosen per line: a backtick, which carries " and ' without escaping, unless the line holds a backtick itself, in which case the double quote is used and " is escaped. A backslash is escaped in either.

Every verb that reads a generator reads the template directly, so the canonical form is something to look at rather than something to keep. aontu trace answers over the file the author edits:

$ aontu trace greet.ts
greet-moon.ts	$.0	$.who.moon	#0
greet-moon.ts	$.0.children.0	$.who.moon	#0
greet-moon.ts	$.0.children.1	$.who.moon	#0
greet-moon.ts	$.0.children.2	$.who.moon	#0
greet-world.ts	$.1	$.who.world	#0
greet-world.ts	$.1.children.0	$.who.world	#0
greet-world.ts	$.1.children.1	$.who.world	#0
greet-world.ts	$.1.children.2	$.who.world	#0

--resugar is the other direction, and --check is the round trip between them: it holds the file to the spelling the two transforms answer, and names the first line that is not it. What that catches is a marker line the transform would not have written: one without its space, or one indented to match the code around it, since the marker stands at the left margin with the aontu indented after it. A template’s whitespace is output, so its bytes are the artifact: a body line’s trailing space shows up as a changed byte in the generated tree, which is what a drift check against the committed files catches.

aontu fmt writes that spelling, and formats the aontu the marker lines carry while it is there.

$ aontu template --check greet.ts

The round trip is a fixpoint, not a table of escapes: a canonical line becomes an output line only when desugaring the rebuilt line answers the canonical line back. A line that could not survive (one beginning with the marker itself) fails that test and stays aontu, which is how a generator emits its own marker with no new syntax.

Exit codes: 0 written, 1 a --check file that is not what the round trip answers, 2 usage or I/O.

aontu trace

Ask what wrote this line. Every piece a rule stamped under the component tree, with the file it reached, the model node the dispatch matched, and the rule set that wrote it.

aontu trace [--at <path>] [--format json] [--marker <token>]
            [--profile <file>] <file>

A generator answers a tree of files, and a reader looking at one line of the output has no way back to the rule that produced it. This is that way back. The anchor is $.out unless --at names another.

$ aontu trace gen.aontu
handlers/chat.ts	$.0.children.0	$.services.chat	$.%handler#0

Four columns: the file, the address of the piece in the document, the model node the dispatch matched, and the rule. --format json answers the same entries as one object.

A rule read through a name is addressed by it. $.%handler#0 is the first template of the %handler rule set. A table written inline at the call has no address of its own and is #0, #1 and so on, so the two cases are told apart by whether an address precedes the hash.

A piece that reached no file is not traced. The tree is what the entries attribute to, so a rule whose output never landed in a file has nothing to name.

aontu render

Write the files a generator answers.

aontu render [--check] [--at <path>] [--format json] [--marker <token>]
             [--profile <file>] <file|folder> <path>

A generator answers a component tree (file, folder, line and the rest) as an ordinary value at $.out. render hands that tree to jostraca, the generator runtime both implementations depend on, and jostraca writes the files. aontu decides which files exist and what each line of them is; the runtime decides how the bytes reach a disk. Write a gen.aontu:

foo: "BAR"

out: file("zed.txt", ["foo = " + $.foo])

A tree that is one file is written to <path> itself, under that name, unless <path> is a directory that already exists:

$ aontu render gen.aontu zed.txt

Nothing is printed when the write succeeds, unless a file was skipped (below). --format json reports the files written, in the seven groups the runtime sorts them into, and an eighth aontu reads from the run’s own record:

$ aontu render --format json gen.aontu zed.txt
{
  "aontu": {
...
  "files": {
...
    "unchanged": [
      "zed.txt"
    ],
    "written": []
  },
  "verdict": "ok"
}

A file the runtime declines to touch is named as skipped. A copy on disk carrying the JOSTRACA_PROTECT marker is left as it is, so a hand edit survives the next run. Those seven groups say what was done to a file, and nothing was done to this one, so aontu reads the run’s own record under .jostraca/ and reports skipped: <path> in text, or the skipped group in JSON. --check still counts it as content drift: the bytes differ from what the generator answers, whoever chose that.

Any other tree is written below <path>: a project, a folder, or a list of files, with the paths the tree spells. A one-file tree that should land in a directory names the file: aontu render gen.aontu build/zed.txt.

A folder is a set of generators. Every regular file directly in it is one (a name beginning with a dot is not), taken in name order: a .aontu as it is, any other file by its marker, as below. Their trees are written below <path> as one run, so one --check holds the whole set, and two generators claiming one path are refused by the runtime. A file with no marker line in it is refused by name rather than skipped. Write a gen/a.aontu:

out: file("a.txt", ["a"])

and a gen/b.rb, a generator in the target’s own syntax:

#- out: folder("lib", [file("b.rb", [
puts "b"
#- ])])
$ aontu render gen out
$ aontu render --check gen out

out/a.txt and out/lib/b.rb are on disk, a.txt under its own name: in a set the one-file rule above does not apply.

--check is the CI form. Nothing is written; <path> is compared with what the generator writes, one kind: file line per difference (content, missing, or mode where the tree declared one), and the exit code is 1 on drift:

$ aontu render --check gen.aontu zed.txt

Edit the file by hand, as zed.txt:

foo = edited
$ aontu render --check gen.aontu zed.txt
content: zed.txt
$ echo $?
1

A template entry is read directly. As aontu trace does, render reads a <file> whose extension is not .aontu as a generator in the target’s own syntax (see aontu template), desugared by the marker its extension names, --marker, or a profile’s. --at names another anchor than $.out.

Beside the files it writes, jostraca keeps a record of its own in a .jostraca/ folder under the output directory (for a one-file render, the directory that holds the file), with a .gitignore in it. That folder, and what the runtime does with a file that already exists, is jostraca’s contract; Trust and determinism records what stays aontu’s.

Exit codes: 0 written or clean, 1 --check drift, 2 usage or I/O (a path that cannot be made, a fragment whose source is not there), 4 the document does not stand up, or --at names nothing.

aontu hash

The canon-hash: one string that pins what a document means, so a lockfile, a registry or an agent can say “this module, this meaning” and have the claim survive reformatting.

aontu hash [--form] [--format text|json] <file.aontu>
  • The hash is "aon1-" + base64url(SHA-256(UTF-8(hcanon(unify(file))))), where hcanon is the hash form: see below. aon1- is a scheme id, so a future semantically stronger normal form is an upgrade rather than a breakage.
  • The document is evaluated standalone: its own @"file" closure resolved and unified at its own root, before any consumer context. That is what makes the pin transitive: an edit two includes deep changes the unified root, hence the hash.
  • The pin survives comments, whitespace, formatting, key reordering, and splitting one file into several includes: any refactor that leaves the unified value identical. It breaks on any semantic change in the transitive closure: a default flipped, a field added, a map closed, a constraint tightened.
  • --form prints the hashed TEXT instead of the digest, which is what to diff when a pin moves. --format json prints both under hash and form.
  • Exit codes: 0 hashed, 2 usage, 4 the document does not evaluate on its own: a broken document has no meaning to pin, and a hash of the wreck would agree with every other wreck.

To see the pin hold still, write svc.aontu:

service: { name:"checkout" port: *8080|integer }

and svc-reformat.aontu, the same meaning re-ordered under a comment:

# the same meaning, reordered and commented
service: port: *8080 | integer
service: name: "checkout"
$ aontu hash svc.aontu
aon1-nSY9noXFhWc_dtcRrErhCS9bZVtNfTJb0vVoCE9W1CM
$ aontu hash svc-reformat.aontu
aon1-nSY9noXFhWc_dtcRrErhCS9bZVtNfTJb0vVoCE9W1CM
$ aontu hash --form svc.aontu
{"service":{"name":"checkout","port":*8080|integer}}

Two spellings, one meaning, one pin, and --form prints the exact text the digest is taken over.

The hash form (hcanon)

Exactly the unify-level canon with the two additions that close its semantic gaps:

canonhash form
a closed map or list{"a":1}close({"a":1})
a type- or hide-marked value1type(1), hide(1)

Both reuse existing parseable syntax, so the hash form is itself valid aontu source and round-trips: hcanon(unify(parse(hcanon(v)))) == hcanon(v) is asserted for every row of test/spec/hcanon.tsv, in both implementations. Marks propagate to every descendant at unification, so a wrapper is emitted only where a mark starts. User-facing canon is unchanged.

This is a canonical-text hash, not a hash of semantic equivalence classes: canon is deterministic syntax, not a unique normal form, so number|integer and number denote the same value set and hash differently. The failure direction is the safe one: a false “changed” forces a needless re-review, while a false “unchanged” is impossible provided the hash form is semantically complete, which is exactly why the close/mark additions are part of the definition rather than an optimisation.

aontu sync

Make the project correct: resolve its declared dependencies by minimum version selection, fetch what no local store holds, write the lockfile, materialise the vendor tree, and verify every pin. It is idempotent, and it is the one package verb a reader needs to know.

aontu sync [--frozen] [--base <url>]... [--format text|json] [dir]

dir is the project root, the directory holding pkg.aontu, and defaults to the working directory.

What a project declares. pkg.aontu names the package and what it depends on. A dependency is a minimum version; an alias:<name> entry holds a second version of one package under a name the imports use:

pkg: { path:"corp.example/checkout" main:"main.aontu" }

dep: "corp.example/schemas/service": v: "1.4.2"
dep: "alias:legacy": { pkg:"corp.example/schemas/service" v:"1.2.0" }

Resolution is minimum version selection. Every package is taken at the highest of the minima anyone in the closure asked for, and never higher, so a resolve is reproducible and adding one dependency cannot move another. The closure is walked from the project’s own file through each dependency’s pkg.aontu, fetching a package the stores lack as it is reached.

What a fetch checks, in order. The repository is trusted for availability and for nothing else. For each package the client reads the version list, then the manifest, then the proof, then the archive, and refuses at the first check that does not hold:

  1. the manifest is well formed and names the package and version asked for;
  2. the proof signs the manifest’s digest, by a signer the project’s trust configuration accepts for that name;
  3. the archive’s digest is the one the manifest declares, before a byte of it is parsed;
  4. every file in the archive has the digest and size the manifest lists, no file is missing and none is extra, every path obeys the element rules (at most 32 elements, none a name a platform reserves such as con or nul), and nothing outside the allowlist is present;
  5. the module, evaluated standalone with its own dependencies at the minima it declares, means what the manifest’s canon-hash pins.

A refusal names its code and the package, and nothing is written:

verdict: refused
fetched: corp.example/schemas/common 1.0.0
refused: archive_digest_mismatch: the archive for corp.example/schemas/service 1.4.2 is sha256:45e3…, not sha256:9127…

The lockfile pins three things. aontu_meta/pkg-lock.aontu is one canonical line, and each entry carries the version, the canon-hash of the module’s meaning, the digest of its canonical archive and, for a package acquired from a repository, the digest of its signed manifest:

# pkg-lock.aontu (generated by `aontu sync`; do not edit)
{"lock":{"corp.example/schemas/service":{"archive":"sha256:9127…","canon":"aon1-zFHn…","manifest":"sha256:f72c…","v":"1.4.2"}}}

The vendor tree is the build. aontu_meta/vendor/<package-path>/ holds every package of the closure as a real copy, one directory per /-element, uppercase escaped as !x, with the served manifest and proof kept beside it under the package’s own aontu_meta/. A synced project evaluates with no cache and no network. Commit the lockfile and the vendor tree together.

--frozen is the CI mode. It resolves, fetches what a locked version needs, and refuses to change the lockfile: a dependency the lock does not name, a version that would move, a pin that would be recomputed, all exit 1 and name themselves as lockfile would change.

Where packages come from. repo in pkg.aontu is the project’s trust configuration, and it travels with the project:

repo: base: ["https://pkg.aontu.dev"]
repo: private: ["corp.example/*"]
repo: private_base: ["https://pkg.corp.example"]
repo: trust: "corp.example/*": { signer:"ed25519:q520…" inclusion:none }

base lists the repositories to read from, in order; --base <url> on the command line replaces the list. A base is https, or http on a loopback host, which is how a local aontu pkg serve is reached. A name matching private is never sent to a public base: it is read from private_base, or refused when none is configured. trust maps a pattern (a package path, a path followed by /*, or *) to the signer accepted for it: a named Ed25519 key, or forge for a signing identity proved through Sigstore. The default entry, *, names the forge signer and requires log inclusion. This build verifies key proofs; a name whose entry names the forge is refused rather than accepted unverified.

Cooldown. A version is selectable by get and outdated only once the repository has held it for 72 hours, timed from the repository’s own first-seen time, and a version that a later version retracted is never selected. A version named explicitly, in pkg.aontu or on the command line, is taken as it is; a private name skips the cooldown.

Exit codes. 0 the project is correct; 1 a refusal, a frozen lockfile that would change, a missing package or a pin that does not hold; 2 usage; 4 a module in the store that does not evaluate on its own.

--format json prints the report: verdict, fetched, lock, vendored, missing, unevaluable, forbidden, mismatched, unlocked, changes, events and, on a refusal, refusal with its code, message and pkg.

aontu add, aontu get, aontu remove

Change what the project depends on, then sync.

aontu add <pkg> [dir]
aontu get <pkg>[@<version>] [dir]
aontu remove <pkg> [dir]
  • add takes on a dependency the project does not have. It refuses one already declared and names get.
  • get adds a dependency or raises its minimum: the superset of add. Without a version it takes the newest selectable version.
  • remove drops a dependency and the vendor tree it held.

Each edits pkg.aontu by the smallest text change that keeps the file the author’s: a line appended, a version literal rewritten where it stands, a one-line entry removed. An entry the verb cannot edit that way is left to the author, and the verb says so. Each then runs a full sync, and a change the sync cannot carry is taken back, so neither leaves the project half-changed:

$ aontu get corp.example/schemas/service@1.4.3
verdict: ok
change: raised corp.example/schemas/service 1.4.2 -> 1.4.3
fetched: corp.example/schemas/service 1.4.3
corp.example/schemas/service 1.4.3 aon1-JstX…

Exit codes are sync’s, plus 2 for a spelling the verb cannot act on.

aontu why

Why is this package in the closure? Every chain of dependencies from the project to it, read from the lockfile and the package files in the store. No network.

aontu why <pkg> [--format text|json] [dir]
$ aontu why corp.example/schemas/common
verdict: ok
corp.example/checkout -> corp.example/schemas/service -> corp.example/schemas/common

Exit 0 when a chain exists, 1 when the package is not in the closure.

aontu publish

Publish this package. A dry run without --yes.

aontu publish [--yes] [--key <file>] [--token <file>] [--to <dir>]
              [--write <url>] [--against <dir>] [--format text|json] [dir]

A package declares its path, its version and its entry in pkg.aontu, and whether it may leave the machine:

pkg: path: "corp.example/schemas/service"
pkg: version: "1.4.2"
pkg: main: "service.aontu"

publish: public

publish defaults to private, and a private package is refused by a public write path; a project-level publish: public is what allows it out.

What a publish sends is the manifest, its proof, and the canonical archive of the source tree. The archive holds exactly what the allowlist admits: aontu source, the data formats the include table reads, .md and .txt, LICENSE and NOTICE, and nothing under aontu_meta/. It is a zip whose entries are sorted, stored uncompressed and undated, so one tree has one digest in both implementations. It is held to the caps every consumer applies at acquisition (16 MiB compressed, 64 MiB of files, 4096 files, 8 MiB a file) before it is minted, so nothing leaves the machine that every consumer would refuse; the package path and version in pkg.aontu must be a package path and a version, and the entry must be a path inside the tree.

The gate runs first. The highest version the repository already holds is fetched, checked as any acquisition is, and the candidate is compared against it. Compatibility has three components, and all three must hold: acceptance, every document the old version admitted the new one admits, which is the subsumption query’s subsume with its findings; determination, every position the old version resolved to a value with nothing supplied the new one still resolves (compat_undetermined, as when port: 8080 loosens to port: integer and a consumer who supplied nothing had a value and now has an error); and agreement, where both resolve a position they resolve it to the same value (compat_outcome_changed). A change that fails any of them is refused, and there is no major version to bump past the gate; a breaking change ships as a new package path, and the old path’s last version declares moved: <new path>. --against <dir> names a prior tree to gate against instead.

$ aontu publish --key key.pem
verdict: dry-run
corp.example/schemas/service 1.4.3 public
archive: sha256:b4b8… (2 files, 1006 bytes)
module: corp.example/schemas/service service.aontu aon1-JstX…
file: pkg.aontu sha256:bc26… 133
file: service.aontu sha256:0188… 663
digest: sha256:acb6…
signer: ed25519:q520…
against: corp.example/schemas/service 1.4.2
write: https://publish.aontu.dev/v1/publish
dry run: nothing sent (add --yes)

The proof. --key <file> names a PKCS#8 PEM Ed25519 private key; any other kind of key, or a file that is not a PEM private key, is refused key_invalid before anything is signed. The proof signs the manifest’s digest under it, and the signer’s id, ed25519: followed by the public key, is what a consumer names in its trust entry. --token <file> carries the forge’s OIDC token to the write path, which decides admission from it; the token’s claims are copied into the manifest as its publisher block.

--to <dir> writes the read-path layout straight into a directory instead of sending anything: the archive, manifest and proof under pkg/<path>/@v/, the version list and @latest, and the advisory derived from retract declarations. That directory is a repository, which aontu pkg serve serves and a consumer reads over a loopback base. --write <url> names another write path.

Exit codes. 0 sent or dry run; 1 refused, or breaking; 2 usage; 3 the gate is undecided; 4 the package cannot be minted.

aontu pkg

The remaining package operations, each the right verb when you want exactly that step and no other.

aontu pkg tidy     [--format text|json] [dir]
aontu pkg verify   [--format text|json] [dir]
aontu pkg vendor   [--format text|json] [dir]
aontu pkg manifest [--against <dir>] [--format text|json] [dir]
aontu pkg refreeze [--format text|json] [dir]
aontu pkg tree     [--format text|json] [dir]
aontu pkg outdated [--base <url>]... [--format text|json] [dir]
aontu pkg serve    [--listen <addr>] [--upstream <url>]... [dir]
aontu pkg keygen   <file>

tidy resolves the closure against the local stores and rewrites the lockfile, fetching nothing. A package the stores do not hold is reported and the lockfile is not written at all: a partial lock claims a closure that was never resolved. A package that is present but does not evaluate on its own is refused the same way (verdict: error, exit 4), because a pin computed from a module with no meaning is the same string for every broken module.

verify asks whether every locked package still is, and still means, what the lockfile pins, and changes nothing. It is the CI gate. Bytes are checked before meaning: the archive digest and each file against the kept manifest first, then the one evaluation and the canon-hash. A pinned manifest the tree no longer keeps is a mismatch, not a pass.

$ aontu pkg verify
verdict: mismatch
corp.example/schemas/service: pinned archive sha256:9127… but the store holds sha256:45e3…

Verdicts: ok; mismatch, a store no longer is or means what is pinned; unlocked, the lockfile does not name a dependency the project declares; missing, a locked package is in no store. Exit 0, and 1 for each of the three refusals: nothing to check is not a pass.

vendor copies every locked package from the stores into aontu_meta/vendor/, as whole source trees, replacing whatever stood at each destination. The cache is keyed by canon-hash, and a consumer’s pin is computed against its own closure, so vendor looks under that pin and under the canon the repository’s manifest for the locked version pins.

manifest prints the manifest a publish would send, and gates it with --against <dir> exactly as publish does: acceptance, determination and agreement.

refreeze recomputes every canon pin and nothing else, which is what a canonical-form change in the engine needs.

tree draws the locked closure as a graph, each node’s edges read from its own package file in the store.

outdated asks the repository, for every locked package, what the newest selectable version is and what a resolution taking it would move with it: minimum version selection walked through the repository’s manifests to the closure, so a dependency’s own raises are listed too. A pinned version that a later one retracted is named too; the lock keeps working.

$ aontu pkg outdated
verdict: outdated
corp.example/schemas/common 1.0.0 -> 1.1.0
corp.example/schemas/service 1.0.0 -> 1.1.0
  corp.example/schemas/common 1.0.0 -> 1.1.0
corp.example/schemas/service 1.0.0: retracted by 1.1.0

keygen writes a new Ed25519 signing key to a file, as PKCS#8 PEM and once only, and prints the signer id, ed25519: followed by the public key, which is what a consumer names in a trust entry and what publish --key signs under.

serve serves a repository directory over HTTP: what publish --to wrote, byte for byte. With --upstream <url> it is a caching proxy in front of another repository, fetching an object on a miss and keeping it, and it says when it served a copy no upstream would refresh. The default address is 127.0.0.1:8017. A proxy relaxes nothing: every consumer verifies what it serves as it verifies anything else.

The user cache is $XDG_CACHE_HOME/aontu/pkg where that is set, otherwise ~/.cache/aontu/pkg (%LOCALAPPDATA%\aontu\pkg on Windows), and holds three trees: download/<package>/@v/ keyed by package and version, store/<canon-hash>/<package>/ keyed by canon-hash and package path, and seen/<package>/, the client’s own first-seen records, from which a repository that drops a version this client saw is detected. Under a root trust capability the cache is not consulted at all: a confined run reads the vendor tree only, and the verbs that write the cache refuse to run.

aontu_meta/ is where a project’s generated state lives: the lockfile, the vendored closure, and whatever later tooling writes. pkg.aontu and the documents stay at the root, since they are authored; nothing under aontu_meta/ is. The verbs name an older layout, a mod.aon or a root mod-lock.aon, once when they find it, and read nothing from it.

  • --format json prints every report as an object with the usual aontu: {version, verb} envelope and a verdict.

REPL commands

CommandEffect
:helpshow help
:load <file>evaluate a document and hold it for the commands below
:get [path]what the held document says at a path
:keys [path]the keys at a path of the held document
:why <path>every contribution to the value at a path
:canonswitch to canonical-form output
:jsonswitch to JSON output
:quit, :exitleave (or press Ctrl-D)

:load holds the document’s source, not its evaluated tree (parsed trees are single-use) so every later question re-evaluates from the text. :get and :keys are the query surface and :why is the provenance surface, answering about the held document.

--jsonl makes the session machine-drivable: no banner, no prompt, and every command answers as one JSON line ({"ok":true,"out":"…"}), so a harness can drive the REPL the way it drives the CLI. Human-readable output stays the default.

$ aontu
aontu v0.56.0 REPL — :help for commands, :quit to exit
aontu> port: *8080 | integer
{
  "port": 8080
}
aontu> :canon
canon output
aontu> a:1|2|3
{"a":1|2|3}
aontu> :quit

aontu help

Print the embedded teaching pack: the language, where --help documents the tool.

aontu help [topic] [--format text|json]

With no topic it lists them. The corpus travels inside the binary, so it answers with no network, no checkout and no documentation site, which is the condition it exists for.

topicis
taskswhich verb does the job you have, indexed by the word you arrived with
languagethe grammar card: everything the language spells, on one page
examplesthe ladder, from plain JSON upward
codeswhat a refusal means, and what to do about it
grammarthe published ABNF

The corpus is generated from docs/skill/ and grammar/aontu.abnf by ts/scripts/helpdoc.cjs (make helpdoc, which make build-ts runs), into ts/src/helpdoc.ts and go/cmd/aontu/helpdoc/. Both suites assert the embedded copy is byte-identical with its source, so the pack cannot drift from the published files: a Go binary needs a copy inside its own package directory, because //go:embed cannot read above one, and a generated copy that nothing compares is a second source of truth.

--format json answers {aontu, topics} for the index and {aontu, source, summary, text, topic} for a topic.

Exit codes: 0 printed, 2 an unknown topic (the topics are listed) or a bad option.

aontu explain

Explain one error code, from the same table the engine attaches to a finding.

aontu explain <code> [--format text|json]
aontu explain --list [--format text|json]

Every code in the registry resolves, so a code read out of a report always answers:

$ aontu explain mapval_no_gen
code:  mapval_no_gen
class: incomplete

This value was present after unification, and cannot be generated
because it is not a literal value.

The list is the registry, not the hint table. The registry is in cross-port parity (test/spec/errcodes.tsv, asserted set-equal with the engine’s class map in both ports), and listing from the hint table would make the verb answer about something other than what either port can report. Every registered code carries explanation text, and a gate in both suites holds it so. A registered code carrying none would be marked (no text) in the listing and say so when asked, rather than printing an empty block.

The code may be written bare or with the aontu/ prefix a report prints in its brackets: aontu explain constraint and aontu explain aontu/constraint answer identically, under the registered spelling.

A dynamic code is registered through the prefix it extends, and carries that prefix’s text: func:upper answers with the func: explanation, because the suffix names the operator and the explanation is the prefix’s.

An unknown code exits 2 and names the nearest registered match.

Exit codes: 0 explained, 2 an unknown code or a bad option.

aontu init

Write a working model, an instance of it, and a check script into a directory.

aontu init [dir]

The reason this verb exists is that writing a first document is the most expensive thing to get wrong. A model that reaches for "*", where the language spells the template &:, constrains nothing, and vet over it reports valid. A known-good document makes the next step an edit rather than an invention.

fileis
model.aontuthe truth: an entity map, constrained with &:
data.aontuan instance of it that holds
check.shthe four checks to run after every edit, vet --strict-coverage first
$ aontu init
model.aontu
data.aontu
check.sh

A model, an instance of it, and the four questions to ask.
Run the checks:  sh check.sh
Learn the language:  aontu help language

With no directory it writes into the working one, and it creates the directory if it is not there. check.sh is written executable and takes the binary from $AONTU, so a checkout with no installed aontu runs it with AONTU=./aontu sh check.sh.

It never overwrites. If any member of the trio already stands in the directory, it refuses before writing any of them, so a refused run never leaves the directory half written.

The trio is generated into both ports from docs/skill/init/ by the generator that stages the teaching pack (make helpdoc), on the aontu help precedent. Both suites assert the staged bytes are identical with their sources, and that the emitted documents pass their own check.sh.

Exit codes: 0 written, 2 a standing file, a directory it cannot write, more than one directory, or a bad option.

aontu lsp

aontu lsp

The language server, over standard input and output: LSP, which is JSON-RPC with Content-Length framing, until the client says exit. Both builds serve it, byte-for-byte alike from a client’s point of view; editors launch it with no arguments beyond the verb, and --help is the only option. The standalone aontu-lsp binary runs the same server. What it reports, and the library beneath it, are on the LSP page; wiring a client is in wire your editor.

The MCP server

aontu mcp [--root <dir>]

A Model Context Protocol server over stdio (newline-delimited JSON-RPC 2.0), the mcp verb of the npm build’s CLI; the standalone aontu-mcp binary of the package runs the same server. It follows the language server’s three-layer split (docs/lsp.md): the tools and the protocol are a transport-free library (ts/src/mcp.ts), the verb is stdio and nothing else.

ToolAnswers
vetthe vet report for a schema and a data document
getthe query surface: a path, and a view of it
whythe provenance record for a path
diffwhat changed at which paths between two documents
canona document’s canonical form
summarythe pin, the root keys and the top-tier shape: the first tier of progressive disclosure, expanded by calling get
subsumethe subsume report: does the general document admit every instance the specific one admits?
breakingthe breaking verdict (compatible | breaking | undecided | error) plus the mode checked: the mode argument, else the document’s own $.aontu_policy.compat, else backward
setthe set report plus the new overlay text: assignments arrive as {path, value} pairs (with optional inPlace), and the server never writes files: the caller owns the write
relationsthe relations report: acyclicity and inverse consistency over the entity edge set
hashthe canon-hash pin {hash} (plus the hash-form text when form: true)
trimthe trim --check report: redundant entries as paths
reachesthe reachability check: the verdict and, when it reaches, a shortest path: the closure question relations cannot ask one edge at a time
viewa figure of the document as text: the tree, matrix, graph (mermaid, dot, er), layer, sets, layers, ladder, doc or lattice kind, with the loss report; the poset takes several files and is CLI only
jsonschemathe JSON Schema export: the schema, and the lossy list naming what it could not say: the bridge to a structured-output API, and to an MCP tool’s own inputSchema

Every tool returns the same JSON contract the CLI prints, so a report read from one is the report read from the other. A tool that refuses (an invalid document, a path that names nothing, a document the served trust profile cannot read) answers with its own report and isError: false, because the report is the answer; isError is reserved for a call that could not be made at all (an unknown tool, a malformed argument, a file argument the server cannot serve).

Served evaluation is confined (the trust contract): the source arrives from a caller, and @"..." is exactly what a server must not run unconfined. By default every include is denied ({ include: 'none' }). Started with --root <dir>, the server takes the CLI’s --trust root:<dir> posture instead: includes resolve confined below the (realpath’d) root, and every tool’s document arguments gain <name>Path alternatives (schemaPath, srcPath, sourcePath, generalPath, …) naming files below that root, checked by the same realpath-then-prefix rule the include resolver applies, so a symlink escape is an escape. Without --root, path arguments are refused with isError: true; the initialize handshake’s instructions field says which mode the server is in, and tools/list advertises the <name>Path properties only when they are served. The package-resolver leg is enabled by neither posture.

The Go port ships no MCP server: its aontu mcp says so and exits 2. Its role is embedding the same library calls, and Get, Why, Diff and AgentsMd are in the Go API for that.

The published grammar

Three files, one grammar. grammar/aontu.gbnf and grammar/aontu.lark publish the emission surface for constrained decoding; grammar/aontu.abnf says the same thing in RFC 5234 notation, for a reader rather than a decoder, and is the one the language reference draws its railroad diagrams from.

All three are conservative by construction (they accept less than the parser does, never more) and all three deliberately exclude @"..." includes, because generated documents should describe values rather than reach for files.

The grammar is not a document that drifts: ts/test/grammar.test.ts reads aontu.gbnf and aontu.abnf, interprets both, and requires each to accept every canonical-form output in the shared spec suite. The function-name list in all three, and in the TextMate grammar the editors ship, is checked against the engine’s own registry in both directions.

The skill

docs/skill/ holds the agent-facing sources: a trigger stub, a task-to-verb index, a one-page grammar card, a JSON-first example ladder, and the error-code index for repair loops. Every example document in the ladder is evaluated by ts/test/skill.test.ts, so a skill that teaches something the engine no longer does fails the build.

They reach a caller by three routes, and the third is the one that works when nothing was configured. The npm tarball stages them as skill/ (ts/scripts/prepack.js, which rewrites the links that would otherwise escape the package). A harness can mount the directory as a skill. And aontu help serves them from inside the binary, which is what an agent holding the command and no network has.

LSP hover provenance

The language server can append a value’s contributions to its hover (what met at that path, in source order, with each site) the same record aontu model why prints. It is off unless an editor asks for it:

{ "initializationOptions": { "aontu": { "provenance": true } } }

Hover already re-unifies the document per request, so an editor that asks for this pays a second, instrumented evaluation knowingly, and one that does not pays nothing. Diagnostics are unchanged either way.

Getting the command

  • TypeScript: the npm package declares a bin named aontu (bin/aontu.js), so npm install -g aontu (or npx aontu) provides it. From a clone: make install-ts, an npm link of the checkout, or node ts/bin/aontu.js … without installing.
  • Go: go install github.com/aontu-lang/aontu/go/cmd/aontu@latest, or from a clone: make install-go, a go install of aontu and aontu-lsp, or go run ./cmd/aontu … inside go/.

make install at the root of a clone does both. Both commands accept the same options and produce the same results.


Evaluation consumes the tree

A parsed Val tree is single-use, in both implementations. unify/generate refine the tree in place (children are written back, junction and reference nodes advance their own state), which is safe only because a tree is unified once and never shared. Do not cache, reuse, or unify the same parsed Val (or any node reachable from it) in two different evaluations: the second run starts from mutated state and the result is nondeterministic. Parse again (or clone first) for every independent evaluation. The string entry points (generate(src), unify(src)) parse per call and are always safe.

This is a named rule of the trust contract’s determinism clause, not a performance note: violating it produces wrong answers, not slow ones.

TypeScript API

Package aontu (canonical). Entry point dist/aontu.js, types dist/aontu.d.ts. Requires Node ≥ 22.

import { Aontu } from 'aontu'          // named
import Aontu from 'aontu'              // default (same class)

class Aontu

new Aontu(opts?: AontuOptions)

Constructs an instance and its parser (Lang). One instance can process many sources.

MethodSignatureReturnsNotes
parseparse(src: string, opts?, ctx?)Val | undefinedParses to an unresolved AST. Does not unify.
unifyunify(src: string | Val, opts?, ctx?)ValParses (if given a string) and runs the fixpoint to a fully unified Val.
generategenerate(src: string, opts?, ctx?)anyParse → unify → emit a native JS value. Throws AontuError on conflict or an unresolved result. Serialise the result with exactJSON, not JSON.stringify.
ctxctx(cfg?: AontuContextConfig)AontuContextCreates a context (for variables, error collection, a custom fs, etc.).
const aontu = new Aontu()
aontu.parse('a:number')                  // Val (AST)
aontu.unify('a:1 a:number').canon        // '{"a":1}'
aontu.generate('a:1 b:$.a')              // { a: 1, b: 1 }
aontu.generate('a:1 a:2')                // throws AontuError: Cannot unify value: 2 with value: 1

unify accepts a previously parsed Val, so a caller that wants the AST first need not re-parse: const p = aontu.parse(src); aontu.unify(p). But note that the parsed tree is single-use (see Evaluation consumes the tree) so each parse feeds at most one unify/generate.

AontuOptions

Passed to the constructor, to any method’s opts argument, or merged into a context.

OptionTypePurpose
srcstringSource text (usually passed positionally instead).
pathstringPath of the entry file (for @"…" relative resolution and error sites).
basestringBase path for the resolver.
resolverResolverCustom source resolver for @"…" loading.
fstypeof fsFilesystem implementation: for example a memfs volume for tests.
collectbooleanCollect errors onto result.err instead of throwing.
errany[]Pre-existing array to accumulate errors into (implies collect).
explainany[]Capture a structured trace of the unification.
debug / tracebooleanEnable parser debug / parse tracing.
depsobjectDependency record populated by @"…" loads.
lognumberParser log verbosity.

@"…" resolution tries an in-memory resolver, then the filesystem, then package resolution, in that order. The chain is unconfined by default (a relative include follows any path the process can read) so treat opening an untrusted source as running it. Confinement is the trust option (the trust contract), in both implementations:

const aontu = new Aontu({
  trust: {
    // include capability, one of:
    //   'none'              — @"…" always denied
    //   { mem: {...} }      — a virtual file set only
    //   { root: '/models' } — real files, realpath-confined below root;
    //                         no package resolution
    //   'system'            — the full chain (today's default)
    include: { root: '/models' },
    budget: { passes: 9, depth: 1000 },  // integer engine-event counts
  },
})

Go mirrors it as Aontu.Trust (TrustOptions: IncludeNone, IncludeMem, IncludeRoot, Budget). A denied resolution is the parse-stage include_denied error, pinned by test/spec/include-trust.tsv in both runners. Confinement is realpath-then-prefix-check on the resolved file, so a symlink inside the root pointing outside it is denied. An empty root denies every include rather than resolving to the process directory; Go’s flat IncludeRoot has no spelling for it, its zero value being an absent root. Note fs is not a sandbox: it supplies source text for parsing and error context, and the file and package legs read through their own channels; the trust profile is the confinement surface.

The include manifest. After a parse, the resolved include closure is observable as sorted, deduplicated { path, capability } entries (result.deps in TypeScript, Aontu.IncludeDeps in Go) hermeticity’s “file set” as data (capability is mem, file or pkg). Content hashing and pinning belong to aontu hash and the package tooling, aontu pkg.

The bundled vocabularies. @"aontu:system" (the system vocabulary) and @"aontu:view" (the schema for a view document’s declarations) are served from the engine rather than from disk, so they need neither the filesystem nor package resolution and resolve under every include capability except 'none'. They appear in the manifest with capability std. A host that wants a different vocabulary supplies its own source under its own name; the bundled one is engine-owned.

Relation checks. relationCheck(src) in TypeScript and Aontu.RelationCheck(src) in Go run the declared-relation checks over the derived edge set, returning the {verdict, findings} record that aontu relations prints: that section has the verdicts, the finding fields, and the exit codes.

The derived graph. After a unification, an evaluated document’s link structure is observable too: result.graph in TypeScript (also available as the pure function graphOf(val)) and Aontu.Graph in Go. It is the edge set:

{
  edges: [ { from: '$.services.billing', key: 'dependsOn',
             to: '$.services.auth',
             at: '$.services.billing.dependsOn.0' }, … ],
}

There is no entity index, because there is no second namespace to index: a node’s address is its path. One entry per checked link:

  • from is the node the link starts at: the link’s own position with the relation key and any list indices stripped, so a link inside a list is an edge from the node that holds the list. $ when the link sits at the top of the document.
  • key is the relation. A rel()-minted link carries its predicate declared; a bare refer() has it inferred as the first real key above the link.
  • to is the resolved address, always absolute. A relative address means a different node from each position it is written at, so an edge set whose far ends were spellings could not be traversed: the link’s own value is still what the author wrote.
  • at is where the link is written.

It is deterministic: edges are sorted by construction, and both runners re-derive the graph on a fresh engine and require the same bytes (test/spec/graph.tsv). Impact analysis, reachability and node slices are traversals over it; their exposure as verbs and projections is the machine-access layer’s.

AontuContext

A context threads variables, error state, and resolver configuration through a run. Create one with aontu.ctx().

  • ctx.vars: Record<string, Val>: values for $name variables.
  • ctx.err: any[]: collected errors (when collect).
  • ctx.find(path: string[]): Val | undefined: look a value up by path.

Pass the context as the third argument: aontu.generate(src, undefined, ctx).

Val (TypeScript)

The unified value. Useful members:

MemberDescription
canon: stringReparseable canonical form (see language reference).
gen(ctx): anyEmit the native value (used by generate).
err: any[]Errors attached to this value (NilVals).
isVal: boolean and isMap/isList/isScalar/isNil/…Type discriminators.
path: string[]Path from the root.

Val is an abstract base; concrete subclasses (MapVal, ListVal, IntegerVal, NumberVal, BigIntegerVal, BigDecimalVal, StringVal, BooleanVal, NullVal, ScalarKindVal, ConjunctVal, DisjunctVal, PrefVal, RefVal, VarVal, the *FuncVals, …) are exported from their modules under dist/val/.

The four numeric subclasses are the four numeric leaves: IntegerVal is integer, NumberVal is float (the class name is historical: number is the pure supertype, not a leaf), and BigIntegerVal / BigDecimalVal are the exact leaves biginteger / bigdecimal.

Exact numbers and exactJSON

generate() returns native values, and a document that opts into the 0d exact literals returns two of them that JSON.stringify cannot write:

aontu kindSourcegenerate() returns
integerx:5number, or bigint past Number.MAX_SAFE_INTEGER (see below)
floatx:1.5number
bigintegerx:0d5bigint
bigdecimalx:0d0.1Decimal

Why an integer can be a bigint. The integer leaf is an int64 window, and JavaScript stores it in a double. Below Number.MAX_SAFE_INTEGER that is faithful: the integers are contiguous there, so the number renders its own exact digits. Above it they are not (JSON.stringify(2**60) is 1152921504606847000, a different integer that merely rounds to the same double) so generate() returns a bigint, which exactJSON writes exactly. A float stays a number at any magnitude, because a double’s shortest form already names it exactly (1e21 serialises as 1e+21, in this port and in Go).

typeof gen('x:9007199254740991').x     // 'number'  (2^53-1)
typeof gen('x:9007199254740992').x     // 'bigint'  (2^53)
typeof gen('x:1e21').x                 // 'number'  (float kind)
exactJSON(gen('x:1152921504606846976'))  // '{"x":1152921504606846976}'

An integer-kind bigint is still not a biginteger: the leaves stay disjoint and only canon tells them apart (1152921504606846976 versus 0d1152921504606846976). Go needs none of this: its integer leaf is an int64, exact across the whole window, so Generate returns an int64 at every magnitude. The serialised JSON is identical in both ports.

A 0d-free document generates exactly what it always did: the exact leaves are reached only by writing 0d (see the language reference). Both leaves survive nesting: generate('x:{y:0d7} z:[0d1,0d0.5]') puts a bigint at x.y and a Decimal at z[1]. Note that an integral bigdecimal is still a Decimal and never a bigint: 0d1e3 is a bigdecimal by source form, and the leaves are disjoint.

JSON.stringify throws on a bigint (TypeError: Do not know how to serialize a BigInt), and a replacer cannot rescue it: a replacer may only return another value, and anything it returns that is not already a JSON primitive gets quoted, so the exact digits could come back only as a JSON string, which is a different document. JSON itself was never the obstacle: a JSON number is arbitrary-precision decimal text, and {"x":9007199254740993} is a legal document. Only JavaScript’s serialiser stands in the way, so the package ships its own.

exactJSON(value: any, indent?: number | string): string

Serialises a generate() result as JSON text, preserving exact numbers. Use it instead of JSON.stringify on generated output.

import { Aontu, exactJSON } from 'aontu'

const out = new Aontu().generate('x:0d9007199254740993')
typeof out.x        // 'bigint'
exactJSON(out)      // '{"x":9007199254740993}'
exactJSON(out, 2)   // '{\n  "x": 9007199254740993\n}'
JSON.stringify(out) // TypeError: Do not know how to serialize a BigInt
  • indent has JSON.stringify’s space semantics: a number of spaces (clamped to 0–10) or a literal string (truncated to 10 characters). Omitted or 0 gives compact output: no spaces, no newlines.
  • A bigint writes its digits. A Decimal writes its plain digit form (1000.0, 0.1, -1.5): no 0d marker, since that belongs to canon and is not JSON, but an integral bigdecimal keeps its .0 so the JSON still shows a decimal.
  • Object keys are emitted in lexicographic order (by UTF-16 code unit), matching Go’s encoding/json, which sorts map keys. This is done at emit time and not by generate(), because a JavaScript object cannot hold the required order: ECMAScript lists canonical array-index keys first, ascending numerically, so an object can never present "10" before "9". It applies to any object passed in, not only generate() output, since this is a general emitter, and it is the one place the result deliberately differs from JSON.stringify.
  • Ordinary values are otherwise written exactly as JSON.stringify writes them: the same string escaping, null for NaN and Infinity, and undefined/function/symbol dropped from an object but written as null inside an array. An object with a toJSON method is asked for its replacement (Decimal is handled as a number before that check).
  • U+2028 and U+2029 are escaped, which JSON.stringify does not do: that is the one place JavaScript and Go disagree by default, and escaping is both legal JSON and safe to embed in JavaScript source.
  • It always returns a string: a top-level undefined becomes null.
  • It throws AontuError if the value contains a reference cycle. A shared subtree (which unification produces routinely) is fine; only a true cycle is refused, as in JSON.stringify.

The output is byte-identical to the Go port’s encoding/json with SetEscapeHTML(false) for the same document; that equivalence is pinned by the test suite both implementations run. The aontu CLI calls this same export with indent of 2, so there is exactly one implementation for the pretty and compact forms to stay in step with.

Decimal is exported from aontu alongside it: the type a bigdecimal generates as. It is an immutable exact base-10 value (unscaled: bigint plus scale: number) in normal form, so numerically equal decimals have equal fields:

MemberDescription
new Decimal(unscaled: bigint, scale: number)Construct and normalise.
Decimal.fromString(src: string)Parse [+-]?digits[.digits][e[+-]digits], with or without a 0d marker.
toString(): stringPlain digit form: what exactJSON writes.
canon(): stringCanonical form, with the 0d marker.
equals / compare / add / negate / ceil / floor / isZeroExact operations: no rounding anywhere.

Decimal.fromString refuses input beyond the exactness budget (at most 4096 coefficient digits and an absolute scale of at most 4096: see the language reference), the same refusal a literal gets.

Variables

$name references are filled from ctx.vars. Build value objects with the exported Val constructors:

import { Aontu } from 'aontu'
import { IntegerVal } from 'aontu/dist/val/IntegerVal'
import { StringVal }  from 'aontu/dist/val/StringVal'
import { MapVal }     from 'aontu/dist/val/MapVal'

const aontu = new Aontu()
const ctx = aontu.ctx()
ctx.vars.foo = new IntegerVal({ peg: 11 })
ctx.vars.bar = new StringVal({ peg: 'hello' })
ctx.vars.obj = new MapVal({ peg: { x: new IntegerVal({ peg: 1 }) } })

aontu.generate('a:$foo b:$bar c:$obj', undefined, ctx)
// { a: 11, b: 'hello', c: { x: 1 } }

Exact-input constructors. The two exact leaves take a bigint, a Decimal, or the digits as text: never a JS number, which binary64 has already rounded before this library could inspect it, so an exact value above 2^53 could not arrive that way intact:

import { Decimal }       from 'aontu'
import { BigIntegerVal } from 'aontu/dist/val/BigIntegerVal'
import { BigDecimalVal } from 'aontu/dist/val/BigDecimalVal'

new BigIntegerVal({ peg: 5n })                     // 0d5
new BigIntegerVal({ peg: '9007199254740993' })     // 0d9007199254740993
new BigIntegerVal({ peg: 5 })                      // throws: not-biginteger

new BigDecimalVal({ peg: new Decimal(15n, 1) })    // 0d1.5
new BigDecimalVal({ peg: '0.10' })                 // 0d0.1  (normalised)
new BigDecimalVal({ peg: 1.5 })                    // throws: not-bigdecimal

Both reject malformed text ('5.5' is not a biginteger). BigDecimalVal additionally refuses input over the exactness budget, exactly as a 0d literal does; a biginteger has no bound and is as wide as its digits. Because the constructor picks the leaf where a literal’s source text would, new BigDecimalVal({ peg: '5' }) is a bigdecimal and canons 0d5.0.

Exports

From aontu:

Aontu          // class (also default export)
AontuOptions   // type
AontuContext   // class
AontuError     // error class (thrown by generate)
Val            // base value type
Lang           // the parser
VERSION        // the package version string
runparse, util // parsing helpers
formatExplain  // pretty-print an `explain` trace
exactJSON      // exact JSON emitter — use instead of JSON.stringify
Decimal        // the type a bigdecimal generates as
vet            // the validation verb (see `aontu vet` above)
sarifReport    // a vet report as SARIF 2.1.0
subsume        // the subsumption query (docs/reference-language.md,
               // "Subsumption"): subsume(general, specific, {profile?, at?})
               // -> {verdict, findings}; Go: aontu.Subsume
trimCheck      // the redundancy reporter (see `aontu trim` above):
               // trimCheck(src, {path?}) -> {verdict, redundant};
               // Go: aontu.New().TrimCheck(src)
hcanon         // the HASH FORM of an evaluated Val (see `aontu hash`
               // above): canon plus the close()/type()/hide()
               // wrappers; Go: aontu.Hcanon
canonHash      // the canon-hash pin over that form,
               // "aon1-"+base64url(SHA-256(...)); Go: aontu.CanonHash
get            // the query surface (see `aontu model get` above):
               // get(src, path, {view?, depth?, path?, trust?}) ->
               // {ok, out, findings}; Go: aontu.New().Get(src, path, opts)
why            // provenance (see `aontu model why` above):
               // why(src, path, {path?, trust?}) -> {ok, record, findings},
               // record = {path, value, conjuncts}; Go: (*Aontu).Why
patch          // the overlay patch (see `aontu model set` above):
               // patch(entry, overlay, ["$.a.b=1"], opts?) ->
               // {overlay, appended, verdict, findings}; Go: aontu.Patch
diff           // what changed at which paths between two documents:
               // diff(left, right, {at?, trust?}) -> {changes, same, findings};
               // Go: aontu.Diff
agentsMd       // the generated AGENTS.md stanza (see `aontu agentsmd`
               // above): agentsMd(src, {name?}) -> {stanza, ok};
               // Go: (*Aontu).AgentsMd
format         // the source formatter (see `aontu fmt` above):
               // format(src, {path?, lint?, template?}) -> {verdict,
               // text, changed, findings} or {verdict, errors};
               // `template` is the marker of a generator, formatted as
               // the document it carries; Go: aontu.New().Format(src),
               // and FormatWith(src, FormatOptions{Lint, Template})
unifiedDiff    // unifiedDiff(name, before, after): the diff `aontu fmt
               // --diff` prints; Go: aontu.UnifiedDiff
loadProfile    // a profile document -> {profile} or {errors}:
               // evaluated under the caller's include options, vetted
               // against aontu:profile as a settled value and met with
               // it, so the defaults are filled; what --profile <file>
               // hands to `template`, `fmt` and `trace`
desugarTemplate // a generator in the target's own syntax -> the
               // canonical aontu it means (see `aontu template`):
               // desugarTemplate(src, marker?) -> string;
               // Go: aontu.DesugarTemplate
resugarTemplate // the other direction, a fixpoint against the first:
               // resugarTemplate(src, marker?) -> string;
               // Go: aontu.ResugarTemplate
markerFor      // the marker a file's extension names, `//-` by
               // default: markerFor(path) -> string;
               // Go: aontu.MarkerFor

Evaluating a document you did not write

vet, get, why and diff each take a trust option, the same profile AontuOptions.trust takes, and it means the same thing: what the document being evaluated may reach.

These four verbs exist to be pointed at source from somewhere else (a candidate an agent emitted, a live system dump, the other side of a diff) and without a profile they resolve @"…" through the default chain, which reaches anything on the filesystem the process can read. Opening an untrusted source is reading your disk, so pass a profile whenever the source is not yours. Reading, never running: an include’s extension decides what the file is (.aontu as aontu source, .json, .jsonld, .jsonc, .json5, .jsonic, .jsc, .toml, .yaml, .yml and .ini as configuration data, and .txt as text (the bytes, as one string)) and every other extension is refused. textExt widens the text set; it cannot re-read a named format, and cannot reach .js.

vet(schemaSrc, candidateSrc, { trust: { include: 'none' } })

The MCP server supplies its profile ({ include: 'none' }, or { include: { root } } when started with --root <dir>) to every tool from a single place, rather than each tool applying it for itself: a tool that must remember to confine itself is one that eventually forgets, and the forgetting is silent. The engines that take no trust option (subsume, trimCheck, relationCheck, patch) are confined there by a pre-parse under the same profile: includes resolve at parse, so a document whose confined parse is clean gives the engine nothing it could reach further with.


Go API

Module github.com/aontu-lang/aontu/go, package aontu.

import aontu "github.com/aontu-lang/aontu/go"

type Aontu

func New() *Aontu                 // relative @"file" loads resolve from the cwd
func NewWithBase(base string) *Aontu  // …resolve from base (a directory)

Use NewWithBase when a source’s relative @"file" loads should resolve from somewhere other than the process working directory: typically the directory of an entry file:

abs, _ := filepath.Abs(file)
a := aontu.NewWithBase(filepath.Dir(abs))

Absolute @"file" paths are unaffected by the base. (The aontu CLI does exactly this for a file argument.)

MethodSignatureNotes
ParseParse(src string) (Val, error)AST, not unified.
UnifyUnify(src string) (Val, error)Parse + fixpoint unify.
UnifyVarsUnifyVars(src string, vars map[string]Val) (Val, error)Unify with $name variables.
GenerateGenerate(src string) (any, error)Parse → unify → native Go value.
GenerateVarsGenerateVars(src string, vars map[string]Val) (any, error)Generate with variables.
TraceTrace(src string, opts *TraceOptions) TraceReportWhat wrote each line of a component tree (see aontu trace): Trace is one TraceEntry per stamped piece (At, File, Node and Rule), or Errors. aontu.TraceTree(root Val) []TraceEntry is the walk alone, over a value already unified.
LoadProfileLoadProfile(src string) (map[string]any, []VetFinding)A profile document, evaluated under this instance’s include options, vetted against aontu:profile as a settled value and met with it so the defaults are filled: the profile --profile <file> hands to template, fmt and trace, or the findings that refused it.
FormatFormat(src string) FormatReportThe source formatter (see aontu fmt): the agreed form, or the findings that say why there is none. FormatWith(src string, opts FormatOptions) FormatReport is the same with the options: Lint fills the report’s Findings, the style findings of --lint, and Template is the marker of a generator, formatted as the document it carries. aontu.UnifiedDiff(name, before, after string) string is the diff --diff prints.
a := aontu.New()
v, err := a.Unify("a:1 a:number")   // v.Canon() == `{"a":1}`
out, err := a.Generate("a:1 b:$.a") // out == map[string]any{"a":1,"b":1}

All methods return an error (never panic for ordinary conflicts); Generate returns (nil, err) on any unresolved or conflicting value. Generated output uses Go’s natural types (map[string]any, []any, int64/float64, string, bool, nil), plus *big.Int and *Decimal for the exact leaves: see Exact numbers in Go.

Val (Go)

The lattice element interface:

type Val interface {
    Canon() string              // canonical source-like form
    Gen(ctx *Ctx) (any, error)  // native value (error if not generable)
    Unify(peer Val, ctx *Ctx) Val
    Dc() int                    // done-counter; DONE (-1) == fully resolved
    Nil() bool                  // true for a unification failure (bottom)
    // …plus unexported lattice-ordering methods
}

Concrete exported types: TopVal, NilVal, ScalarVal, ScalarKindVal, MapVal, ListVal, ConjunctVal, DisjunctVal, PrefVal, RefVal, VarVal, FuncVal, PlusOpVal. Every scalar leaf (including the two exact ones) is a ScalarVal; it holds its kind internally, so from outside the package a leaf is told apart by the concrete type Gen returns, or by Canon.

Exact numbers in Go

Generate returns Go’s natural types, and the two exact leaves come out as the two types that can hold them exactly:

aontu kindSourceGenerate returns
integerx:5int64
floatx:1.5float64
bigintegerx:0d5*big.Int
bigdecimalx:0d0.1*Decimal

A 0d-free document generates exactly what it always did. An integral bigdecimal is still a *Decimal and never a *big.Int: 0d1e3 is a bigdecimal by source form, and the leaves are disjoint.

Both types implement json.Marshaler and emit exact digits as a raw JSON number, so encoding/json needs no help:

out, _ := aontu.New().Generate("a:0d9007199254740993 b:0d1e3 c:0d0.1")
b, _ := json.Marshal(out)
// {"a":9007199254740993,"b":1000.0,"c":0.1}

The pointer is part of the contract. A non-pointer big.Int inside an any has no MarshalJSON in its method set, so encoding/json falls back to the struct encoder and writes {}: an exact number silently replaced by an empty object, which is the class of failure the exact leaves exist to eliminate.

A generated *big.Int is a copy, so a caller may mutate it without disturbing the value it came from.

Decimal is an exact base-10 value (coefficient plus scale), immutable and always in normal form. Its exported surface is what a consumer of generated output needs:

MethodDescription
String() stringPlain digit form (1000.0, 0.1, -1.5).
MarshalJSON() ([]byte, error)The same digits, as a raw JSON number.
Canon() stringCanonical form, with the 0d marker.

json.Marshal output matches the TypeScript port’s exactJSON byte for byte once HTML escaping is off (json.Encoder + SetEscapeHTML(false)); that equivalence is pinned by the test suite both implementations run.

Ctx and errors

  • Ctx carries the root, variables, and collected errors through a run; you normally let Unify/Generate create it.
  • AontuError{ Msg string } implements error and is returned (wrapped) for conflicts; its message matches the TypeScript phrasing (for example Cannot unify value: 2 with value: 1).

Variables in Go

UnifyVars/GenerateVars accept a map[string]Val. Build the values with the exported constructors:

ConstructorReturns
NewString(s string) Valstring scalar
NewInteger(i int64) Valinteger scalar: refuses an int64 binary64 cannot carry exactly (see below)
NewNumber(f float64) Valfloat scalar (the name is kept for API compatibility; the kind it builds is KindFloat)
NewBigInteger(n *big.Int) Valbiginteger scalar: the exact unbounded integer leaf
NewBigDecimal(s string) (Val, error)bigdecimal scalar: the exact base-10 leaf
NewBoolean(b bool) Valboolean scalar
NewNull() Valnull scalar
NewScalarKind(k Kind) Valtype constraint (KindString, KindBoolean, KindNull, and the numeric lattice KindNumber with its leaves KindInteger, KindFloat, KindBigInteger, KindBigDecimal)
NewMap(map[string]Val) Valmap (keys inserted in sorted order)
NewList([]Val) Vallist
vars := map[string]aontu.Val{
    "port": aontu.NewInteger(8080),
    "host": aontu.NewString("localhost"),
    "obj":  aontu.NewMap(map[string]aontu.Val{"x": aontu.NewInteger(1)}),
}
out, err := aontu.New().GenerateVars(
    "server: { host: $host, port: $port }", vars)
// out == map[string]any{"server": map[string]any{"host":"localhost","port":8080}}

Pass nil vars when a model uses no $name variables. An undefined $name is a Cannot resolve error.

NewInteger obeys the same storage contract as a literal. An int64 that binary64 cannot carry exactly is refused rather than stored, exactly as the equivalent literal is refused: otherwise the API would be a hole straight through that rule, since Go’s integer leaf is an int64 and the canonical TypeScript port’s is a double. The refusal is a nil value, not a panic and not a second return: aontu errors are values, so it flows through unification and surfaces at Generate with the same “not exactly representable” message and the same 0d escape a lossy literal gets.

The rule is exactness, not magnitude: every power of two in the window is fine however large, math.MinInt64 included.

aontu.NewInteger(1152921504606846976)   // 2^60 — fine
aontu.NewInteger(math.MinInt64)         // -2^63, a power of two — fine
aontu.NewInteger(9007199254740993)      // 2^53+1 — nil value
aontu.NewInteger(math.MaxInt64)         // 2^63-1, rounds up — nil value
aontu.NewBigInteger(big.NewInt(9007199254740993))  // the exact escape

Exact-input constructors. NewBigInteger copies its argument and never mutates the copy, so a caller may keep using (and mutating) the *big.Int it passed in; a nil argument is zero. NewBigDecimal takes a string: an optional sign, an optional 0d marker, digits, an optional fraction and an optional exponent, and no _ separators (those are literal syntax, not part of a number’s text). A float64 is deliberately not accepted: it has already rounded before the library can inspect it.

n, _ := new(big.Int).SetString("123456789012345678901234567890", 10)
aontu.NewBigInteger(n)          // 0d123456789012345678901234567890
aontu.NewBigDecimal("0.10")     // 0d0.1   (normalised)
aontu.NewBigDecimal("0d1e3")    // 0d1000.0
aontu.NewBigDecimal("1_000")    // error: Not an exact decimal

NewBigDecimal returns an error for malformed text and for input over the exactness budget (at most 4096 coefficient digits and an absolute scale of at most 4096: see the language reference), the same refusal a literal gets. A biginteger has no bound and is as wide as its digits. Because the constructor picks the leaf where a literal’s source text would, NewBigDecimal("5") is a bigdecimal and canons 0d5.0.


Behavioural parity

Both implementations are validated against the same test/spec/*.tsv cases and agree on: scalars and scalar kinds (including the numeric tower’s four leaves (integer, float, biginteger, bigdecimal) under the pure supertype number, their 0d exact literals, and exact arithmetic) maps (nesting, merge, spreads &:, optional keys, close/open), lists (incl. spreads), conjunction &, disjunction |, preference *, references ($.a.b, .x.a), $name variables, the + operator, all twelve functions, type/hide marks, and @"…" source loading: plus parse / unify / generate and the canonical form.

Generated bytes are in parity too: exactJSON in TypeScript and encoding/json in Go produce the same JSON text for the same document, which the shared suite’s byte-exact gens rows pin. What byte equality cannot see (a bigint where a number was due, since both serialise as 5) is pinned by per-port API tests instead.

Validation reports are in parity as well: aontu vet produces the same report from both commands, text and JSON, with the same exit code: pinned by the shared suite’s vet.tsv rows for everything but each finding’s message, which is prose. One thing still differs by construction: the wording of a “cannot read ” failure, which is the host’s. The aontu.version field agreed from 0.70.0, when the two version series merged into one.

The shared parser stack is identical: TypeScript uses @tabnas/jsonic + @tabnas/{expr,path,multisource,directive,debug}; Go uses the ports github.com/tabnas/{jsonic,expr,path,multisource,directive}/go. See the Explanation for how parity is maintained.