Keep schema and helper fields out of the output
Mark schema and helper fields with `type()` or `hide()` so they constrain and compute without being generated.
Rendered from
docs/how-to/keep-schema-out-of-output.md
in the engine repository, where a correction belongs, and where the
test suite executes every example on this page.
A schema written next to its data has one job left after
constraining it: staying out of the generated JSON. A field whose
value is marked with type(...) or hide(...) is omitted when the
enclosing map generates, and still participates in unification. Park
the schema at its own key and reference it where it should apply:
_schema: type({ id:integer name:string })
users: { &: $._schema ada: { id:1 name:ada } bob: { id:2 name:bob } }
{ "users": { "ada": { "id": 1, "name": "ada" },
"bob": { "id": 2, "name": "bob" } } }
_schema never appears, and it still constrains. Give bob a
string id in users.aon:
_schema: type({ id:integer name:string })
users: { &: $._schema ada: { id:1 name:ada } bob: { id:"two" name:bob } }
$ aontu users.aon
[aontu/no_scalar_unify]: Cannot unify values at path $.users.bob.id
...
$ echo $?
1
A mark belongs to the field its wrapper was written at, and a
reference copies the value with the marks cleared, which is why
$._schema constrains the children without hiding them, and why
marking _schema does nothing to any other field. Mark the field
the data itself arrives at and you silence the whole thing:
user: type({ id:integer })
user: id: 7
{}
Unified, constrained, then omitted. type() on the data’s own field
is almost never what you meant.
hide(...) is the same mechanism for values you compute with but do
not emit:
secret: hide("s3cret")
token: $.secret
{ "token": "s3cret" }
The reference emits because it copied the value and left the mark
behind; copy(...) is the explicit spelling of that copy when you
want it on the page.
Hide an invariant, keep it armed
The same mark carries policy. A rule that must hold on every evaluation, without appearing anywhere in the output, is a hidden block: here, “exactly one role holds the tenant”:
roles: owner: { tenantOwner:true rank:100 }
roles: admin: { tenantOwner:false rank:80 }
roles: auditor: { tenantOwner:false rank:20 }
registry_invariant: hide({
one_owner: length(1) & filter($.roles, { tenantOwner:true })
})
{ "roles": { "admin": { "rank": 80, "tenantOwner": false },
"auditor": { "rank": 20, "tenantOwner": false },
"owner": { "rank": 100, "tenantOwner": true } } }
The filtered set must have length 1, and the check costs the output
nothing. Promote admin to tenantOwner: true in rbac.aon and
the hidden block fails loudly:
roles: owner: { tenantOwner:true rank:100 }
roles: admin: { tenantOwner:true rank:80 }
roles: auditor: { tenantOwner:false rank:20 }
registry_invariant: hide({
one_owner: length(1) & filter($.roles, { tenantOwner:true })
})
$ aontu rbac.aon
[aontu/constraint]: Cannot unify values at path $.registry_invariant.one_owner
...
$ echo $?
1
The refusal prints the offending set (both roles claiming the tenant, by name), so the reviewer reads the violation itself rather than a bare verdict.
Marks are specified in Marks: type and
hide. A
type()-marked block is also how you build a vocabulary of named
constraints: name a reusable
constraint. The invariant recipe
runs live in the RBAC policy use
case.