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.
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 jsonschema [--at <path>] [--strict] [options] <file>
aontu hash [options] <file>
aontu mod tidy|verify|vendor|manifest [options] [dir]
aontu get <path> [options] <file>
aontu why <path> [options] <file>
aontu set <path>=<value>... --entry <file> --overlay <file> [--in-place]
aontu agentsmd [--write <AGENTS.md>] <file>
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.
Options:
-c, --canon Print the canonical form instead of generated JSON
-h, --help Show this help and exit
-v, --version Print the version and exit
Behaviour
- File:
aontu config.aontureads, 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 file name is a usage error (exit 2) rather than a silent discard. This is what makes a MISTYPED VERB fail loudly:vet2matches no subcommand, so it falls through as a file name, andaontu vet2 schema.aon good.jsonused to printgood.jsonand exit 0 — 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. - Stdin:
echo 'a:1 b:$.a' | aontureads source from the pipe. - REPL:
aontuwith 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
0dexact literals prints them in full, at any magnitude:x:0d9007199254740993prints9007199254740993, not a rounded…992. The TypeScript CLI gets this from the library’sexactJSONexport, the Go CLI from ajson.Encoderover 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 (
1for an evaluation error,2for a bad option).
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)
--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:
| Exit | Verdict | Meaning |
|---|---|---|
| 0 | valid | the data unifies and is concrete (or --partial) |
| 1 | invalid | the data does not hold: a contradiction it can never satisfy, or a document that would not parse |
| 2 | — | usage: a bad option, or a file that cannot be read |
| 3 | incomplete | no contradiction, but the truth is not yet satisfied |
| 4 | error | the 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”.
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 honest 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.
{ "col": 7, "file": "data.aon", "len": 4, "role": "data",
"src": "0x1F", "row": 1, "value": "31" }
aontu 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.aon and reached through @"lib/types.aon" is reported at
lib/types.aon with that file’s row and column — never at the entry
with the included file’s coordinates, which is a real file name
against a line it may not have.
The name is the one the CALLER’S OWN spelling reaches: vet contract.aon names types.aon, vet a/b/contract.aon names
a/b/types.aon, 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:
$ aontu vet service.aon deploy.json
verdict: invalid
$.service.prot: closed [conflict]
[aontu/closed]: Cannot resolve value at path $.service.prot
data: deploy.json:1:40 (8080)
$.service.replicas: no_scalar_unify [conflict]
[aontu/no_scalar_unify]: Cannot unify values at path $.service.replicas
data: deploy.json:2:28 ("3")
schema: service.aon:4:13 (integer)
--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, not just 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:
{ "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 — honestly
non-incremental: 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.
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.aon> <specific.aon>
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.
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.aon>
--againsttakes a file path orgit#<rev>, and is repeatable. Agit#<rev>spelling is the old version of the whole tree, not of the entry file alone: the revision’s includable sources (.aon,.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 undernode_modules, the bundledstd/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 anchorsubsumetakes, and findings are reported from it. A module’s top level carries the things that are supposed to change between releases — the version string, theaontu_policyblock — 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})—breakingreads$.aontu_policy.compatfrom the new document, and--modeoverrides it.nonedeclares no promise: nothing is checked. - Exit codes mirror
subsume’s:0compatible,1breaking,3undecided,4error,2usage. Undecided fails the gate by default — a gate that shrugs is not a gate — downgradable with--allow-undecided. --allow-deprecated-removaldowngrades a finding about a value the old version alreadydeprecate()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.aon>
- 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 entry is load-bearing.
- 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.
--checkis required: trim only reports, andaontu trim f.aondoing something other than trimming silently would be worse than saying so. It is not blocked on the machineryset --in-placenow 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:
0clean,1redundant entries found,4the document itself does not evaluate,2usage. - A verdict of
errorsays why. A document that does not evaluate has no redundancy to report, but it does have a reason: the report carrieserrors, the engine’s own first failure in the same finding shapevetreports in — code, class, path, sites with file, row, column and extent, and the repairhint. 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.aon>
$ aontu relations system.aon
verdict: fail
$.auth.dependsOn.0 dependsOn: cycle auth -> billing -> auth
$.auth.dependsOn.0 dependsOn: billing does not list auth under usedBy
$.billing.dependsOn.0 dependsOn: auth does not list billing under usedBy
$ echo $?
1
- Relations are read from the
relationskey of the document root: every map under it is a declaration, and itsacyclicandinversefields are read directly. Unifying one with the bundled$.std.Relationdocuments what it is and is the vocabulary’s convention — not a requirement. A bare{ inverse: usedBy, acyclic: true }declares the same relation with no@"std/system"at all, so the checks are available under every include capability,'none'included. The key name is convention too: nothing in the language knows it, the checking pass does. - 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. The language reference states the rule; the explanation argues it.
- A finding carries
at(the position of the offending edge),code(relation_cycle,relation_inverse_missingorrelation_target_unmet),relation, anddetail— for a cycle, the entities it runs through in order; for a missing inverse,[from, to, inverseName]; for an unmet target,[from, to, reason]where the reason is the engine’s own code for the refusal. Findings are sorted byat, so the report diffs cleanly. target: <schema>says what the far end must be, and is checked here. The declaration used to be inert, on the reasoning thatrefer(t)already flows the type in at the site — which is exactly why it was worth nothing, because the site then has to repeat it. Satisfaction is the meet, and not merely the absence of a conflict: a target key the far end does not have unifies happily and leaves a hole, so the check asks the questionrefer(t)answers at the site — can the far end still generate once the target is met? — and compares it with the far end alone, so a node already incomplete for its own reasons is not blamed on the relation pointing at it. The check never writes: a relation reports on a finished model, and flowing the type in here would be generation, the same rule that keeps it from writing an author’sinversefor them.--format jsonwraps the same findings with theaontuproducer block (verb,version) that every machine-readable report carries.- Exit codes:
0pass,1fail,4error(the document does not evaluate),2usage. Note these are the verb’s own three verdicts, notvet’s five classes — there is no schema on the other side of this question, soincompletehas nothing to mean. - A verdict of
errorsays why. A document that does not stand up has no graph, so it has no relation findings — but the report carrieserrors, the engine’s own first failure in the same finding shapevetreports in.findingsstays the graph’s own vocabulary; the two lists answer two different questions, and theerrorsfield is present only on theerrorverdict. - The library form is
relationCheck(src)in TypeScript andAontu.RelationCheck(src)in Go, returning the identical{verdict, findings}record (pluserrorson a failed run); the derived graph the checks run over isresult.graph/Aontu.Graph, described under the TypeScript API.
aontu reaches
Ask whether one entity reaches another over the entity graph, at any remove.
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.
$ aontu reaches web ledger system.aon
verdict: reaches
web -> billing -> 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
nocarries none — there is no evidence for a negative answer. - Transitive, not reflexive-transitive.
reaches a ais true only when a path of one or more edges returns toa, which says the graph has a cycle througharather 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 an entity (
svc/auth.ports.http) reaches the entity: reachability is between entities, and the path inside one says which part of it the link arrives at. Same rulerelationsuses, and it has to be, or the two verbs would disagree about what an edge connects. - Exit codes:
0reaches,1unreachable,4error,2usage. An unreachable pair is a failed check, not an error: the question was answered, and the answer was no. - An endpoint that names no entity is a refusal, reported as
refer_unresolvedwith the known entities listed — answeringnowould report a typo as a fact about the model. - 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 andAontu.Reach(src, from, to, options)in Go, returning the identical{verdict, path?}record (pluserrorson a failed 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.aon > schema.json writes a usable schema and still
tells the reader what it left behind.
$ aontu jsonschema --at spec contract.aon
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "type": "string", "pattern": "^[a-z][a-z0-9-]{2,39}$" },
"tier": { "enum": ["internal","standard","critical"],
"default": "internal" }
},
"required": ["name","tier"]
}
- 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 anchorvet --attakes, so--at specmeans the same thing in both.--format jsonprints the whole report —schema,lossy,verdict— under the usualaontu: {version, verb}envelope.- Exit codes:
0exported,1lossy under--strict,2usage,4the document does not stand up on its own. Without--stricta 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 quietly 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:
| Construct | Why 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 literals | JSON 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 map | the template constrains keys that cannot exist |
a length with no domain | no keyword counts a string or a container, so it is exported as minItems/maxItems |
| residue — an unresolved reference, a waiting call | not 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 how-to, “Carry exact money over JSON”.
- The library form is
jsonSchema(src, options?)in TypeScript andAontu.JSONSchema(src, at)in Go, returning the identical{verdict, schema, lossy}record (pluserrorson a failed run).
aontu 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 get <path> [-c|--canon] [--keys] [--types] [--depth <n>]
[--format text|json] <file.aon>
- Evaluation is global. Unification has no partial mode: the whole
document is evaluated and then one node is selected. What
getbuys 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.01names nothing here exactly as it names nothing there. A key that contains a dot is likewise unreachable, as it is to a reference; the escape spelling is G4’s to settle for both at once. - Default output is the fragment’s generated JSON;
--canonis its canonical form, and for the root path that is byte-identical toaontu --canon. - Exit codes:
0rendered,1the path names nothing (the finding carries a nearest-key suggestion),2usage,4the document does not stand up on its own — including a node that is not concrete, for which there is no JSON to print.
The projections are lattice abstractions. Each view is a valid Aontu document that subsumes the truth — generalisation, never distortion:
| flag | view |
|---|---|
--types | every concrete leaf lifted to its own kind: {"replicas":3} becomes {"replicas":integer} |
--depth n | structure to depth n; every elided subtree renders as top — “no further information at this tier” |
--keys | the node’s own key names (or list indices), one per line |
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), 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 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 why <path> [--format text|json] <file.aon>
$ aontu why $.services.auth.replicas service.aon
$.services.auth.replicas = 3
1. *1|integer service.aon:2:18 (spread)
2. 3 service.aon:3:21
- 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) andpref. A preference inside a spread template reports asspread, 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, one side of anid()-merge — 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 jsonemits the record:{path, value, conjuncts: [{canon, role, site}]}, with sites in the same shape the vet report uses. Exit codes mirrorget’s:0explained,1the path names nothing,2usage,4the 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 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 set <path>=<value>... --entry <file> --overlay <file>
[--in-place] [--dry-run] [--format text|json]
$ aontu set '$.services.auth.owner="identity-2"' \
--entry system.aon --overlay changes.aon
verdict: valid
wrote: changes.aon
- The assignment becomes a path-flattened conjunct —
$.a.b=1is 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
5against3, the verdict isinvalid, and the finding names the pinning site — whichaontu whythen explains.--in-placecloses that loop. - A path reached through a reference is refused (
patch_not_editable).n: $.baseagainstbase: 7is pinned bybase’s line, not byn’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-placerewrites 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 makesport: 0x1Fsafe to rewrite even though its value is31— 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 holdinga: 42at 1:4 and an overlay holdingx: 42at 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 whosesrc, parsed alone, means the contribution’s own canon — which refuses a compound, because a site names a compound’s opening token (minformin(1),1for1+2,{for a map). It must also be concrete:a: integeris a constraint, not a pin, and appending narrows it without discarding what it says. Anything else appends exactly as plainsetwould, plus one warning naming the case —patch_not_editable,patch_ambiguousorpatch_span_mismatch. Warnings never move a verdict, so--in-placecannot 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, andreplaced: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, andfrom/toas source text — replacing0x1Fwith31is a different edit from replacing it with0x1F, and only the spelling says which. - The overlay is written only when the change holds. An
invalidorerrorverdict 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-runwrites 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:0valid,1invalid,2usage,3incomplete,4the entry does not stand up on its own.
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>] <file.aon>
The stanza names the document, its canon-hash pin, its
root keys and its shape, and spells the get / why / vet / set
commands with a path that actually exists in it. --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.
Exit codes: 0 generated, 2 usage, 4 the document does not stand
up on its own.
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.aon>
- The hash is
"aon1-" + base64url(SHA-256(UTF-8(hcanon(unify(file))))), wherehcanonis 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.
--formprints the hashed TEXT instead of the digest, which is what to diff when a pin moves.--format jsonprints both underhashandform.- Exit codes:
0hashed,2usage,4the 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.
The hash form (hcanon)
Exactly the unify-level canon with the two additions that close its semantic gaps:
| canon | hash form | |
|---|---|---|
| a closed map or list | {"a":1} | close({"a":1}) |
a type- or hide-marked value | 1 | type(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 mod
Module tooling: the commands that maintain a project’s dependency closure and describe what a publish would push. All are local — they read and write the project, the vendor directory and the user cache, and never reach the network.
aontu mod tidy [--format text|json] [dir]
aontu mod verify [--format text|json] [dir]
aontu mod vendor [--format text|json] [dir]
aontu mod manifest [--against <dir>] [--format text|json] [dir]
dir is the project root — the directory holding mod.aon — and
defaults to the working directory.
tidy walks the dependency closure and rewrites mod-lock.aon.
-
Dependencies are read from each module’s own
mod.aon, under adepmap keyed by module path, each entry declaring a versionv:dep: { "corp.example/schemas/service@1": { v: "1.4.2" } } -
Selection is minimum version selection: each module is taken at the highest of the minima anyone in the closure asked for, never higher. Nothing is upgraded by the act of resolving, so a tidy is reproducible and adding a dependency cannot silently move an unrelated one.
-
The closure is walked breadth-first and terminates without a cycle check, because a module’s selected version only ever rises.
-
Each entry’s
canonpin is recomputed from the module in the store, by unifying its entry file standalone and hashing it (seeaontu hash). It is never carried over from the old lockfile, which would pin what the module used to mean. Theocidigest is carried over: it is the registry’s word about the bytes it served, and nothing local can hear it. -
The lockfile is written in canonical form under a generated-file header, so it is one diffable line and every reader strips
#comments before parsing it:# mod-lock.aon (generated by `aontu mod tidy`; do not edit) {"lock":{"corp.example/schemas/service@1":{"canon":"aon1-oQs6…","oci":"","v":"1.4.2"}}} -
A module the stores do not hold is reported as missing and the lockfile is not written at all. A partial lock is worse than none: it would claim a closure that was never resolved.
-
A module the stores do hold but which does not evaluate on its own is refused the same way (
verdict: error, exit 4, no lockfile written), and named separately because the repair is different — a fetch cannot help it. This is the same refusalaontu hashgives for the same file, and for the same reason: a module that does not stand up has no meaning to pin, and every one of them hashes to the same string. A lockfile written from that hash looks exactly like a real pin and carries nothing.
verify asks whether every locked module still means what the
lockfile pins, and changes nothing. It is the CI gate.
tidy cannot be that gate. It recomputes and rewrites by design — a
pin is what a module means now — so a job that tidies before
evaluating makes the lockfile agree with whatever the store holds,
tampering included, and then passes. Verification is a question;
answering it must not be an edit.
$ aontu mod verify
verdict: mismatch
corp.example/schemas/service@1: pinned aon1-WXj9… but the store means aon1-pT2F…
- Verdicts:
okthe lockfile covers the project and every locked module still means what it pins;mismatchat least one store no longer means what is pinned;unlockedthe lockfile does not name a dependency the project declares;missingat least one locked module is in no store. Exit codes:0, and1for each of the three refusals,2for usage — a mismatch is a refused gate, the same class a breaking check uses. - Both hashes are reported, because the useful question is which way it
moved. A module that no longer stands up at all says so rather than
reporting the hash of
nilas though it were a meaning. - Nothing to check is not a pass. A project with no lockfile at
all, or one whose lockfile predates a dependency someone added, would
otherwise verify clean over an empty set — the same shape as the
defect the verb exists to close. The repair is a
tidy, not a fetch, and the line says so. Transitive dependencies need no separate check: a locked module’s own imports are resolved when its pin is recomputed, so one that is unreachable makes its dependant fail to evaluate and is reported as a mismatch.
vendor copies every locked module out of the stores into
aon_vendor/, as a whole source tree — that is what an OCI layer
holds, and a module is more than its entry file. A module already
resolving from aon_vendor/ is left alone rather than copied onto
itself. Anything the stores do not hold is reported as missing.
Because the user cache is keyed by canon-hash, vendor can only find
what the lockfile already pins: a cold start with no lockfile has
nothing to search the cache by. tidy first, then vendor.
The vendor layout is aon_vendor/<module-path>@<major>/, beside
the project’s mod.aon: each /-segment of the module path becomes a
directory, and the final segment carries the @<major> suffix — so
corp.example/schemas/service@1 lives at
aon_vendor/corp.example/schemas/service@1/ (moduleDir,
ts/src/mod.ts; the Go port mirrors it). The directory holds the
module’s whole source tree — its own mod.aon (declaring path,
version and main) and its entry file — exactly what an OCI layer
would carry:
myproject/
mod.aon # path, and the deps this project asks for
mod-lock.aon # generated by tidy; the resolved closure
main.aon
aon_vendor/
corp.example/
schemas/
service@1/ # one module
mod.aon # path, version, main
service.aon
common@1/ # its dependency, FLAT beside it
mod.aon
common.aon
The tree is FLAT, and a module’s own dependencies are resolved
against it. A vendored module carries its own mod.aon, so it is a
project inside a project — and its imports are resolved from its own
directory first and then from every project enclosing it, which is
where vendor put its dependencies. A module that ships its own
aon_vendor/ still wins for its own tree; one that does not falls
through to the consumer that vendored it. So the flat tree vendor
writes is the tree a nested import reads, and nesting a second
aon_vendor/ inside a dependency is unnecessary — which matters,
because manifest excludes aon_vendor/ from the published layer, so
a nested store could never have travelled through a publish.
With mod get absent, hand-creating this layout is the supported cold
start: vendor the tree by hand, run tidy to lock its canon-hash, and
every later evaluation verifies the vendored content against that pin
(see the hand-vendoring how-to).
--format jsonprints every report as an object with the usualaontu: {version, verb}envelope, averdict, andmissing.tidyaddslockandunevaluable;verifyaddsverifiedandmismatched(each{mod, want, got}) andunlocked;vendoraddsvendored.- Exit codes for
vendor:0resolved,1something was missing,2usage.
manifest prints the OCI artifact a publish would push, and gates
it on the breaking check.
-
A module publishes itself, so its own
mod.aondeclares a version as well as a path and an entry:mod: { path: "corp.example/schemas/service", version: "1.4.2", main: "service.aon" }The major an import spells lives inside that version —
1.4.2is published ascorp.example/schemas/service@1. A module declaring no version, or one whose entry file is absent, has nothing to mint: that is anerrorverdict, not a missing fetch. -
The artifact: config media type
application/vnd.aontu.module.v1+json, one layer holding the module source tree, and four annotations —org.opencontainers.image.titleand.versionfor the path and version, andcom.github.rjrodger.aontu.canonand.majorfor the two facts OCI has no predefined key for. -
The layer is the source tree, relative and forward-slashed so two implementations on two platforms describe the same layer.
aon_vendor/is excluded: a published module carries its own sources, not a copy of everyone else’s. -
--against <dir>is the publish-time breaking gate. It names a prior version’s module tree, and runsbreaking’s backward check between the two: every instance the old version admitted must still be admitted. The verdict, the findings and the exit class are that check’s, unchanged — this is wiring at the boundary where versions are minted, not a second definition of “breaking”. -
A major bump is where breaking is allowed. When the prior version’s major differs from this one’s, the gate does not apply: the major lives in the module path, so a consumer of
@1never sees@2unless it asks, and checking across majors would forbid the one change the version scheme exists to express. -
Exit codes:
0may be published,1breaking,2usage,3undecided,4nothing to mint —subsume’s classes, because the gate is a subsumption check.
“Has the truth changed?” is one annotation read and a string
compare — no download, no parse. The canon-hash in the annotation is
the same string tidy locks and aontu hash prints, so
a consumer holding aon1-oQs6… can ask a registry index whether the
module still means what it meant. A reformat, a comment or a file split
will not move it.
get and publish are not in this build. They are the network
half of the design (docs/capability-review/g6-distribution.md) and
need a registry client. The CLI names them anyway and says which half
is missing, because a reader of the design will type them and deserves
a better answer than “unknown subcommand”:
$ aontu mod get
aontu: mod get needs a registry client, which this build does not ship
(docs/capability-review/g6-distribution.md)
REPL commands
| Command | Effect |
|---|---|
:help | show 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 |
:canon | switch to canonical-form output |
:json | switch to JSON output |
:quit, :exit | leave (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.53.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
The MCP server
aontu-mcp [--root <dir>]
A Model Context Protocol server over stdio (newline-delimited
JSON-RPC 2.0), shipped as a second binary of the npm package. 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 binary is stdio and
nothing else.
| Tool | Answers |
|---|---|
vet | the vet report for a schema and a data document |
get | the query surface: a path, and a view of it |
why | the provenance record for a path |
diff | what changed at which paths between two documents |
canon | a document’s canonical form |
summary | the pin, the root keys and the top-tier shape — the first tier of progressive disclosure, expanded by calling get |
subsume | the subsume report: does the general document admit every instance the specific one admits? |
breaking | the breaking verdict (compatible | breaking | undecided | error) plus the mode checked — the mode argument, else the document’s own $.aontu_policy.compat, else backward |
set | the 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 |
relations | the relations report: acyclicity and inverse consistency over the entity edge set |
hash | the canon-hash pin {hash} (plus the hash-form text when form: true) |
trim | the trim —check report: redundant entries as paths |
reaches | the reachability check: the verdict and, when it reaches, a shortest path — the closure question relations cannot ask one edge at a time |
jsonschema | the 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 (G5, docs/trust.md): 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 separate MCP server — its role is embedding the
same library calls, and Get, Why, Diff and AgentsMd are in the
Go API for that.
The published grammar
grammar/aontu.gbnf and
grammar/aontu.lark publish the emission
surface for constrained decoding. They are conservative by
construction — they accept less than the parser does, never more — and
they 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, interprets it, and requires it to accept every
canonical-form output in the shared spec suite.
The skill
docs/skill/ holds the agent-facing sources: a trigger
stub, 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.
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 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
binnamedaontu(dist/cli.js), sonpm install -g aontu(ornpx aontu) provides it. From a clone:node ts/dist/cli.js …. - Go:
go install github.com/aontu-lang/aontu/go/cmd/aontu@latest, or from a clone:go run ./cmd/aontu …(insidego/).
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.
| Method | Signature | Returns | Notes |
|---|---|---|---|
parse | parse(src: string, opts?, ctx?) | Val | undefined | Parses to an unresolved AST. Does not unify. |
unify | unify(src: string | Val, opts?, ctx?) | Val | Parses (if given a string) and runs the fixpoint to a fully unified Val. |
generate | generate(src: string, opts?, ctx?) | any | Parse → unify → emit a native JS value. Throws AontuError on conflict or an unresolved result. Serialise the result with exactJSON, not JSON.stringify. |
ctx | ctx(cfg?: AontuContextConfig) | AontuContext | Creates 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.
| Option | Type | Purpose |
|---|---|---|
src | string | Source text (usually passed positionally instead). |
path | string | Path of the entry file (for @"…" relative resolution and error sites). |
base | string | Base path for the resolver. |
resolver | Resolver | Custom source resolver for @"…" loading. |
fs | typeof fs | Filesystem implementation — e.g. a memfs volume for tests. |
collect | boolean | Collect errors onto result.err instead of throwing. |
err | any[] | Pre-existing array to accumulate errors into (implies collect). |
explain | any[] | Capture a structured trace of the unification. |
debug / trace | boolean | Enable parser debug / parse tracing. |
deps | object | Dependency record populated by @"…" loads. |
log | number | Parser 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 (G5, 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. 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 the distribution layer (G6).
The bundled vocabulary. @"std/system" (G4, the system
vocabulary) is served
from the engine rather than from disk, so it needs neither the
filesystem nor package resolution and resolves under every include
capability except 'none'. It appears 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
identity structure is observable too (G4):
result.graph in TypeScript — also available as the pure function
graphOf(val) — and Aontu.Graph in Go. It has two parts:
{
entities: [ { id: 'svc/auth', paths: ['$.services.auth'] }, … ],
edges: [ { from: 'svc/billing', key: 'dependsOn',
to: 'svc/auth', at: '$.services.billing.dependsOn.0' }, … ],
}
entitiesis the entity index: eachid(name)and every tree path that holds it. More than one path is the normal case — the merge puts an entity’s value at every position that declared it — and it is why moving an entity to a new path breaks$.pathreferences but no entity address.edgesis the edge set: one entry per checked link.fromis the entity the link sits inside (empty outside every entity),keyis the nearest map key below that entity — so a link inside a list is an edge under its relation, not under its index — andatis where the link is written.
Both are deterministic: ids, paths and 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 entity slices are traversals over these; 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$namevariables.ctx.err: any[]— collected errors (whencollect).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:
| Member | Description |
|---|---|
canon: string | Reparseable canonical form (see language reference). |
gen(ctx): any | Emit 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 used to name that leaf and is now the pure
supertype), 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 kind | Source | generate() returns |
|---|---|---|
integer | x:5 | number, or bigint past Number.MAX_SAFE_INTEGER (see below) |
float | x:1.5 | number |
biginteger | x:0d5 | bigint |
bigdecimal | x:0d0.1 | Decimal |
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 there its shortest form is the right answer
(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
indenthasJSON.stringify’sspacesemantics: a number of spaces (clamped to0–10) or a literal string (truncated to 10 characters). Omitted or0gives compact output — no spaces, no newlines.- A
bigintwrites its digits. ADecimalwrites its plain digit form (1000.0,0.1,-1.5) — no0dmarker, since that belongs to canon and is not JSON, but an integral bigdecimal keeps its.0so 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 bygenerate(), 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 onlygenerate()output, since this is a general emitter — and it is the one place the result deliberately differs fromJSON.stringify. - Ordinary values are otherwise written exactly as
JSON.stringifywrites them: the same string escaping,nullforNaNandInfinity, andundefined/function/symbol dropped from an object but written asnullinside an array. An object with atoJSONmethod is asked for its replacement (Decimalis handled as a number before that check). - U+2028 and U+2029 are escaped, which
JSON.stringifydoes 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
undefinedbecomesnull. - It throws
AontuErrorif the value contains a reference cycle. A shared subtree — which unification produces routinely — is fine; only a true cycle is refused, as inJSON.stringify.
The output is byte-identical to the Go port’s encoding/json with
SetEscapeHTML(false) for the same document; that equivalence is what
the shared suite’s gens mode pins. 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:
| Member | Description |
|---|---|
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(): string | Plain digit form — what exactJSON writes. |
canon(): string | Canonical form, with the 0d marker. |
equals / compare / add / negate / ceil / floor / isZero | Exact 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 get` above):
// get(src, path, {view?, depth?, path?, trust?}) ->
// {ok, out, findings}; Go: aontu.New().Get(src, path, opts)
why // provenance (see `aontu why` above):
// why(src, path, {path?, trust?}) -> {ok, record, findings},
// record = {path, value, conjuncts}; Go: (*Aontu).Why
patch // the overlay patch (see `aontu 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
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 the filesystem and require()s a .js path.
Opening an untrusted source is running it, so pass a profile
whenever the source is not yours:
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.)
| Method | Signature | Notes |
|---|---|---|
Parse | Parse(src string) (Val, error) | AST, not unified. |
Unify | Unify(src string) (Val, error) | Parse + fixpoint unify. |
UnifyVars | UnifyVars(src string, vars map[string]Val) (Val, error) | Unify with $name variables. |
Generate | Generate(src string) (any, error) | Parse → unify → native Go value. |
GenerateVars | GenerateVars(src string, vars map[string]Val) (any, error) | Generate with variables. |
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 kind | Source | Generate returns |
|---|---|---|
integer | x:5 | int64 |
float | x:1.5 | float64 |
biginteger | x:0d5 | *big.Int |
bigdecimal | x: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 load-bearing. 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:
| Method | Description |
|---|---|
String() string | Plain digit form (1000.0, 0.1, -1.5). |
MarshalJSON() ([]byte, error) | The same digits, as a raw JSON number. |
Canon() string | Canonical 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 is the
equivalence the shared suite’s gens mode pins.
Ctx and errors
Ctxcarries the root, variables, and collected errors through a run; you normally letUnify/Generatecreate it.AontuError{ Msg string }implementserrorand is returned (wrapped) for conflicts; its message matches the TypeScript phrasing (e.g.Cannot unify value: 2 with value: 1).
Variables in Go
UnifyVars/GenerateVars accept a map[string]Val. Build the values
with the exported constructors:
| Constructor | Returns |
|---|---|
NewString(s string) Val | string scalar |
NewInteger(i int64) Val | integer scalar — refuses an int64 binary64 cannot carry exactly (see below) |
NewNumber(f float64) Val | float scalar (the name is kept for API compatibility; the kind it builds is KindFloat) |
NewBigInteger(n *big.Int) Val | biginteger scalar — the exact unbounded integer leaf |
NewBigDecimal(s string) (Val, error) | bigdecimal scalar — the exact base-10 leaf |
NewBoolean(b bool) Val | boolean scalar |
NewNull() Val | null scalar |
NewScalarKind(k Kind) Val | type constraint (KindString, KindBoolean, KindNull, and the numeric lattice KindNumber with its leaves KindInteger, KindFloat, KindBigInteger, KindBigDecimal) |
NewMap(map[string]Val) Val | map (keys inserted in sorted order) |
NewList([]Val) Val | list |
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, .$KEY), $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. Two things
still differ by construction: the aontu.version field, because the
npm and Go module version series are independent, and the wording of a
“cannot read
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, and Test coverage for what
each suite exercises.