Export JSON Schema
Export a model as JSON Schema 2020-12 with `aontu jsonschema`, and read the loss report it owes you.
Rendered from
docs/how-to/export-json-schema.md
in the engine repository, where a correction belongs, and where the
test suite executes every example on this page.
JSON Schema is what the rest of the world reads: an MCP tool’s
inputSchema must be one, structured-output APIs constrain
generation to one, OpenAPI embeds one. aontu jsonschema exports the
unified value as draft 2020-12 to stdout, and names every loss on
stderr, because a converter that silently dropped a constraint would
hand you a schema that admits more than the model does.
Export a whole document
A document whose root is one close() expression exports as one
schema object, sealed at the root. Write this as event.aon:
close({
id: string & re("^evt_[0-9a-f]{12}$")
kind: created|updated|deleted
priority: *normal|low|high
note?: string & length(min(1) & max(500))
})
$ aontu jsonschema event.aon
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"id": {
"pattern": "^evt_[0-9a-f]{12}$",
"type": "string"
},
"kind": {
"enum": [
"created",
"updated",
"deleted"
]
},
"note": {
"maxLength": 500,
"minLength": 1,
"type": "string"
},
"priority": {
"default": "normal",
"enum": [
"normal",
"low",
"high"
]
}
},
"required": [
"id",
"kind",
"priority"
],
"type": "object"
}
Everything here crossed exactly: re() as pattern, the scalar
disjunctions as enum with the * preference as default,
length() on a string as minLength/maxLength, the optional
note out of required, and the root close() as
additionalProperties: false: the one thing the two languages say
identically. This output is pasteable into an OpenAPI components
entry with nothing to strip.
Export one definition with --at
--at <path> names the subtree to export, the same anchor
vet --at takes. This is the MCP
move: keep a registry of tools in one document and answer each tool’s
inputSchema from its own anchor. Write a one-tool registry as
tools.aon:
argschemas: type(close({
search_docs: close({
query: string & length(min(1) & max(256))
limit?: integer & min(1) & max(50)
scope?: workspace|org|web
})
}))
$ aontu jsonschema --at '$.argschemas.search_docs' tools.aon
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"limit": {
"maximum": 50,
"minimum": 1,
"type": "integer"
},
"query": {
"maxLength": 256,
"minLength": 1,
"type": "string"
},
"scope": {
"enum": [
"workspace",
"org",
"web"
]
}
},
"required": [
"query"
],
"type": "object"
}
The export reads straight through the type() mark, and the
closedness the agent must respect crosses without loss: a
hallucinated argument is a refusal on the aontu side and
additionalProperties: false on the JSON Schema side. Stderr stayed
empty for this run because the registry is written in the crossing
subset: kinds, scalar enums, bounds, re(), string length(),
optional keys, close(). The full crossing table is in the
reference under
aontu jsonschema.
Read the loss report
Constructs JSON Schema cannot say still export, as the nearest
admissible schema, and each one is named on stderr with its path and
construct. Collect the classes in report.aon:
report: {
total: number & must((v) => 0 <= v, "total must not be negative")
amountEur: bigdecimal
audit: hide("kept-off-the-wire")
attempts: [&: integer] & length(max(3))
}
$ aontu jsonschema --at report report.aon
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"amountEur": {
"type": "number"
},
"attempts": {
"items": {
"type": "integer"
},
"maxItems": 3,
"minItems": 0,
"type": "array"
},
"total": {}
},
"required": [
"amountEur",
"attempts",
"total"
],
"type": "object"
}
That exit was 0: a lossy export is still an export, so redirecting
stdout writes a usable schema. The report went to stderr, and
--strict turns it into a failure for pipelines that must not ship
a schema admitting more than the model does:
$ aontu jsonschema --strict --at report report.aon
...
lossy: $.report.amountEur bigdecimal: JSON has one number type and it is binary64, so the EXACTNESS this leaf exists for cannot be carried; the schema says "number" and a consumer may round
lossy: $.report.attempts length: a count with no domain is exported as minItems/maxItems; JSON Schema has no keyword that counts a string OR a container
lossy: $.report.audit hide: a hidden entry is not generated, so it is omitted from the schema; a consumer is neither asked for it nor allowed to know about it
lossy: $.report.total nil: this is not a value yet, so there is nothing to constrain a consumer to; the schema admits anything here
$ echo $?
1
--format json carries the same report as data (verdict: "lossy",
each loss as {path, construct, reason}, the schema embedded) for a
build step that wants to allowlist specific losses rather than fail
on any. The bigdecimal loss is the one with a way around it:
carry exact money over JSON crosses
the export loss-free as a decimal string with a conversion mark.
Four edges
The report above already pins two of them. First, must() holds the whole value residual, so number & must(...) exports {} under the construct name nil: the check is
opaque by construction, and the number kind beside it is lost with
it (a concrete 5 & must(...) exports {} all the same). Second,
length() on a list has no domain until data arrives, so attempts
exported real minItems/maxItems and was still reported: the
keywords are the sizing atom’s best rendering, not its meaning.
Third, a spread template crosses as additionalProperties (or
items) only when it is a bare kind. A template carrying a
constraint call stays residual and exports {}, reported as
unresolved. Put both in spreads.aon:
labels: { &: string }
annotations: { &: string & length(max(63)) }
$ aontu jsonschema --strict spreads.aon
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"annotations": {
"additionalProperties": {},
"properties": {},
"type": "object"
},
"labels": {
"additionalProperties": {
"type": "string"
},
"properties": {},
"type": "object"
}
},
"required": [
"annotations",
"labels"
],
"type": "object"
}
lossy: $.annotations.& unresolved: this is not a value yet, so there is nothing to constrain a consumer to; the schema admits anything here
$ echo $?
1
labels admits string values; annotations admits anything, and
says so. The same split decides list templates: [&: string]
crosses as items, a constrained element template does not.
Fourth, deprecate() crosses as the annotation 2020-12 has for it,
deprecated: true, and what the deprecation SAYS does not, because
the draft has no field for it. That half is reported. Write
legacy.aon:
region: deprecate(string & re("^[a-z]{2}-[a-z]+-[0-9]$"), {
msg: "renamed"
use: "$.zone"
since: "2.0.0"
})
$ aontu jsonschema --strict legacy.aon
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"region": {
"deprecated": true,
"pattern": "^[a-z]{2}-[a-z]+-[0-9]$",
"type": "string"
}
},
"required": [
"region"
],
"type": "object"
}
lossy: $.region deprecate: JSON Schema 2020-12 has the `deprecated` flag and no field for what it SAYS, so msg/use/since cannot cross; the schema marks the property deprecated and a consumer must read the model for the reason
$ echo $?
1
A consumer of the exported schema learns that the property is
deprecated, which is the part that changes what a client does. It does
not learn the reason, the replacement or the version (msg, use
and since have nowhere to go in 2020-12), so --strict reports that
half and exits 1. Announce renames through a channel that carries the
text.
The refusals
A lossy export exits 0; a run that cannot produce a truthful schema
at all exits 4, and stdout stays empty: never a partial schema. An
--at that names nothing is one such run:
$ aontu jsonschema --at '$.reprot' report.aon
$: no_path [reference]
[aontu/no_path]: Cannot at value at path $
...
$ echo $?
4
So is a document that does not stand up on its own, such as a
dangling reference in dangling.aon:
spec: owner: $.people.alice.email
people: {}
$ aontu jsonschema dangling.aon
$.spec.owner: no_path [reference]
[aontu/no_path]: Cannot resolve value at path $.spec.owner
data: dangling.aon:1:14 ($.people.alice.email)
$ echo $?
4
That is not a loss to report: the verb exports what a document means, and this one does not mean anything.
The live version is use-cases/14-jsonschema-export: a three-tool registry exported per-anchor with empty stderr, a wire message exported whole, the money convention crossing intact, and every loss class pinned by golden files, including the exports re-checked under a stock JSON reader.