Define a recursive schema
Reference a definition inside itself to get a schema that applies at every depth of the data.
Rendered from
docs/how-to/define-a-recursive-schema.md
in the engine repository, where a correction belongs, and where the
test suite executes every example on this page.
An approval chain, a comment thread, a filesystem tree: the data is self-similar, and the schema should be written once. Reference the definition inside itself: that reference means the fixpoint, and the schema applies at every depth of the data:
spec: hide({
Step: {
approver: string & re("^[a-z]+@acme[.]example$")
decision: *pending|pending|approved|rejected
then?: $.spec.Step
}
})
policy: $.spec.Step & {
approver: "lead@acme.example"
decision: approved
then: { approver:"cfo@acme.example" then:approver:"audit@acme.example" }
}
{
"policy": {
"approver": "lead@acme.example",
"decision": "approved",
"then": {
"approver": "cfo@acme.example",
"decision": "pending",
"then": {
"approver": "audit@acme.example",
"decision": "pending"
}
}
}
}
$.spec.Step written inside Step is the whole trick: no marker, no
unrolled copies, and every level of the chain met
the re() and got the ranked decision default. The recursive position
expands one level per meet with concrete data, so validation descends
exactly as far as the data does: no maximum depth to configure, no
blind spot past it.
Guard the recursion
The ? on then?: is the guard: an optional recursive tail simply
drops where the data stops. A ranked default guards the same way,
ending the structure with an explicit value instead of an absence:
schema: hide({ Node: { v:integer next: *null|$.schema.Node } })
doc: $.schema.Node & { v:1 next:v:2 }
{
"doc": {
"v": 1,
"next": {
"v": 2,
"next": null
}
}
}
The recursion also runs through list templates, which is how a comment thread types its replies:
spec: hide({
Comment: { author:string text:string replies?: [&: $.spec.Comment] }
})
thread: $.spec.Comment & {
author: "alix"
text: "ship it"
replies: [
{ author:"bo" text:"+1" }
{ author:"cy" text:"hold on" replies: [{ author:"alix" text:"?" }] }
]
}
{
"thread": {
"author": "alix",
"text": "ship it",
"replies": [
{"author": "bo", "text": "+1"},
{"author": "cy", "text": "hold on",
"replies": [{"author": "alix", "text": "?"}]}
]
}
}
A wrong value three levels down is an ordinary located error
($.thread.replies.1.replies.0.author), because each level of data
expanded the schema one more level to meet it.
The unguarded tail refuses
Guardedness is emergent: the engine never analyses the schema for
well-foundedness, the data decides. Drop the ? and the schema still
evaluates, but no finite document can satisfy it. Write this as
chain.aon:
spec: hide({ Step: { approver:string then:$.spec.Step } })
doc: $.spec.Step & {
approver: "lead@acme.example"
then: approver: "cfo@acme.example"
}
$ aontu chain.aon
[aontu/recursion_unexpanded]: Cannot recurse value at path $.doc.then.then
...
$ echo $?
1
The refusal lands at $.doc.then.then (the exact position the data
ran out), and the error’s hint states the repair:
Guard the recursion -- an optional key (next?:) drops when nothing
arrives, and a preferred alternative (*null | $.Node) generates --
or supply the data.
The canon stays finite
An infinitely deep type still has a one-line canonical form, because
recursion renders symbolically. Put the vocabulary alone in
spec.aon:
spec: hide({
Step: {
approver: string & re("^[a-z]+@acme[.]example$")
decision: *pending|pending|approved|rejected
then?: $.spec.Step
}
})
$ aontu --canon spec.aon
{"spec":{"Step":{"approver":re("^[a-z]+@acme[.]example$"),"decision":*"pending"|"pending"|"approved"|"rejected","then"?:$.spec.Step}}}
$ aontu hash spec.aon
aon1-sgTj1hvqaL8vHdKZrlH7eaPbKeE9UW28b9WGBlVW9hw
The definition stays one reference deep ("then"?:$.spec.Step), so
the aon1- hash pins the mu-form of the schema, not any unrolling of
it. That single string is a version pin for the whole recursive
vocabulary: data instances unroll to their own depth without moving
it.
Vet plain JSON at any depth
The same anchored vet that checks flat records checks recursive ones,
against data that carries no aontu syntax at all. With spec.aon
still in place, put a chain in chain-good.json:
{
"approver": "lead@acme.example",
"decision": "approved",
"then": {
"approver": "cfo@acme.example",
"decision": "pending"
}
}
and a chain that goes wrong one level down in chain-bad.json:
{
"approver": "lead@acme.example",
"decision": "approved",
"then": {
"approver": "EXTERNAL@other.example",
"decision": "maybe"
}
}
Now vet both against the definition:
$ aontu vet --at '$.spec.Step' spec.aon chain-good.json
verdict: valid
$ aontu vet --at '$.spec.Step' spec.aon chain-bad.json
verdict: invalid
$.spec.Step.then.approver: constraint [conflict]
[aontu/constraint]: Cannot unify values at path $.spec.Step.then.approver
expected: re("^[a-z]+@acme[.]example$")
actual: "EXTERNAL@other.example"
data: chain-bad.json:5:17 ("EXTERNAL@other.example")
schema: spec.aon:3:24 (re("^[a-z]+@acme[.]example$"))
$.spec.Step.then.decision: empty [conflict]
[aontu/empty]: Cannot unify values at path $.spec.Step.then.decision
data: chain-bad.json:6:17 ("maybe")
schema: spec.aon:4:15 (*"pending"|"pending"|"approved"|"rejected")
$ echo $?
1
Both findings are located in the schema’s namespace at the depth the data failed, with the data site beside them.
The live version of all of this is
use-cases/13-recursive-schema,
an approval-chain model whose check.sh asserts every moment shown
here. The semantics are specified in Recursive
references
(fixpoints),
mutual recursion and recursive aliases included.