04. Schema-evolution governance for a shared customer-profile schema
Governance of a shared schema across v1→v3
Exercises subsume profiles, breaking --against, deprecate(), aontu_policy.compat, hash, diff
Rendered from
use-cases/04-schema-evolution/README.md
in the engine repository. The models, the expected output and the
check.sh that drives the CLI over all of it are in
use-cases/04-schema-evolution/.
Scenario
A customer-profile schema is the ground truth that dozens of services
(and, increasingly, coding agents) read and write. Once it is shared,
validation is the easy part; the hard problem is evolution: which
changes are safe to ship, which break a consumer that was correct
yesterday, and who decides. This use case models three released
versions and a queue of proposals, and drives the evolution verbs
(subsume, breaking, hash, the MCP diff tool) as the governance
machinery a schema registry needs:
profile-v1.aon: v1.0.0: closed customer record (id, email, name, free-formphone?, stricttierenum, consent flags with safe defaults), plus theaontu_policy.compatdeclaration.profile-v2.aon: v2.0.0: additive minor (optionallocale, optional validatedcontactblock) andphonemarkeddeprecate(string, {msg, use, since}).profile-v3.aon: v3.0.0, a major:phoneremoved, requiredregionadded.proposals/: the PR queue: a narrowed constraint, an added required key, a default flip, the staged deprecated-removal, and an adversarial PR that waives its own gate.probes/: small paired documents, one question each: undecided verdicts, the gen profile, hash stability, and a version string carried inside the document.data/: instances a service or agent might emit, valid and not.
The model tree
Each released version is one document of the same shape, which is what
makes them comparable at all. profile-v2.aon is drawn here: the
profile record the gate reasons about, beside aontu_policy, the
waiver block that says which of its findings a reviewer has accepted.
$
├── aontu_policy
│ └── compat *"backward"|"forward"|"full"|...
└── profile
├── consent
│ ├── analytics *false|boolean
│ └── marketing *false|boolean
├── contact
│ ├── phone re("^[+][0-9]{7,15}$")
│ └── verified *false|boolean
├── email re("^[^@ ]+@[^@ ]+[.][^@ ]+$")
├── id re("^C[0-9]{7}$")
├── locale re("^[a-z][a-z](?:-[A-Z][A-Z]...
├── name string&length(integer&min(1))
├── phone string
└── tier "standard"|"premium"|"enterpr...
aontu view doc --depth 3 profile-v2.aon draws it, and check.sh pins it
with --out --check. A key with (n) after it is a container the
depth bound stopped at, and n is how many keys are not drawn; a
leaf carries its canon, which is the kind of thing it is rather
than its value.
How the model is designed
close()everywhere. A governed record refuses undeclared keys; closedness is also what makes removal of a key visible to the gate (an open map would silently admit stragglers).- Constraint atoms carry the field contracts:
re()for id, email, phone (E.164), locale;length(min(1))for name;integer & min(0)for the proposed loyalty balance.re()takes a portable pattern subset that excludes a quantifier applied to a group containing another quantifier, so the locale pattern is written^[a-z][a-z](?:-[A-Z][A-Z])?$, with the two-letter repeat spelled out. *false | booleanfor consent flags: a boolean default is only overridable by a boolean, so absent flags default to refused and anything that is not a boolean is refused.tieris a literal enum,"standard" | "premium" | "enterprise", with no default; v3’s requiredregionis a regex constraint,string & re("^(us|eu|apac)$"). An instance that omitstiervets asdisjunct_no_genand one that omitsregionasmapval_required, both class incomplete, exit 3.deprecate(x, {msg, use, since})onphonein v2, pointing at its successor$.profile.contact.phone. The mark rides the value: v2 admits exactly what v1 admits, vet reports the field at severitywarning(exit stays 0), and one version later the same mark is what--allow-deprecated-removalkeys on.aontu_policy: hide({compat: *backward | ...})in every version;hide()keeps it out of generated output.breakingreads$.aontu_policy.compatfrom the new document, and--modeon the command line takes precedence over it (seeaontu breaking).- The version number lives in the filename and the git tag, not in
the document: a concrete version string is compared like any other
value, so a whole-document gate reads a bump as a narrowing. The
probes/meta-*.aonpair shows this below, and--atanchors the gate beneath it.
A gate finding names the path, both sides’ canon, and file:row:col
sites in both documents. Refusing proposals/narrow-email.aon against
v2:
$ aontu breaking --against profile-v2.aon proposals/narrow-email.aon
verdict: breaking
$.profile.email: compat_narrowed [compat]
the general residual does not contain the specific residual
expected: re("^[^@ ]+@example[.]com$")
actual: re("^[^@ ]+@[^@ ]+[.][^@ ]+$")
general: proposals/narrow-email.aon:6:19 (re("^[^@ ]+@example[.]com$"))
specific: profile-v2.aon:14:19 (re("^[^@ ]+@[^@ ]+[.][^@ ]+$"))
Containment is decided between the two re() patterns themselves, so
the narrowing is reported at the pattern, with both patterns as the
witness.
Because the policy declaration is read from the new document, a
proposal that pins its own policy to none passes the gate.
proposals/waive-gate.aon makes the same change as
require-loyalty.aon and adds aontu_policy: hide({compat: "none"}):
$ aontu breaking --against profile-v2.aon proposals/waive-gate.aon
verdict: compatible
$ echo $?
0
A CI pipeline passes --mode backward on the command line, and the
same proposal is refused with compat_required_added, exit 1.
hide() keeps a value out of generated output but not out of the
comparison. probes/meta-v1.aon and probes/meta-v2.aon share one
schema body and differ only in meta.version:
$ aontu breaking --against probes/meta-v1.aon probes/meta-v2.aon
verdict: breaking
$.meta.version: compat_narrowed [compat]
a concrete value subsumes only itself
expected: "2.0.0"
actual: "1.0.0"
--at anchors the gate at the contract, and keeps --mode, the policy
declaration and the --allow-* flags:
$ aontu breaking --against probes/meta-v1.aon --at '$.profile' probes/meta-v2.aon
verdict: compatible
$ echo $?
0
aontu vet reports every deprecated field the schema declares, whether or not
the instance uses it. When the instance omits phone, the site is the
schema’s:
$ aontu vet --at '$.profile' profile-v2.aon data/customer-ok.json
verdict: valid
$.phone: deprecated [compat]
deprecated: free-form phone is unvalidated; write E.164 to contact.phone (use $.profile.contact.phone) (since 2.0.0)
schema: profile-v2.aon:20:11 (string)
When the instance uses the field, the site is the data’s:
data: data/customer-legacy-phone.json:5:12 ("555 0193"). The site
role (schema or data) is carried in --format json, so tooling
can tell the two apart; the exit code is 0 either way.
The release history, drawn, and it is not a chain
Mermaid source
%% aontu subsumption poset at=$.profile profile=defaults documents=7 nodes=6
graph BT
n0["narrow-email"]
n1["profile-v1"]
n2["profile-v2"]
n3["profile-v3"]
n4["require-loyalty = waive-gate"]
n5["v3-remove-phone"]
n1 --> n2
n5 --> n2Drawn by aontu view poset --at '$.profile' over the seven documents,
from subsume alone, and pinned as a golden by check.sh. An edge
means the upper document admits everything the lower one does.
Three things a reader gets here that seven subsume runs do not:
- v3 is attached to nothing. It neither generalises nor narrows v1 or v2: the structural signature of a major break, at a glance rather than inferred from a version number.
require-loyaltyandwaive-gateare one node. Two independently written proposals that subsume each other at this anchor, so they make the identical schema change. No diff between them would say that: they differ in comments and in one policy value.- Seven documents, six nodes. The collapse is by mutual subsumption, not by hash: two documents can mean the same thing and hash differently, so a diagram keyed on the canon-hash alone would draw a two-cycle and call it a partial order.
The picture is profile-dependent: at --profile values the
default-change proposal joins v2’s node, because the admitted value
set is unchanged and only the materialised default moves. The profile
and the anchor are therefore printed into the diagram’s first line.
What check.sh proves
check.sh drives the CLI end to end and asserts every outcome: exit
codes, error and reason codes grepped from the reports, and JSON
reports diffed against the expected/ goldens.
- All three released versions render with
--canon, and v2’s canonical form matchesexpected/profile-v2.canon. - A conforming v2 instance (
data/customer-ok.json) isverdict: valid, exit 0, with thedeprecatedwarning forphoneanchored at its schema site (schema: profile-v2.aon). - A legacy instance still using
phoneis valid, and the--format jsonreport matchesexpected/vet-legacy-phone.json: onedeprecatedfinding at severitywarning, sited at the data (data/customer-legacy-phone.json:5:12). - The same legacy instance against v1 is valid with no deprecation warning.
- A malformed email is
verdict: invalid, exit 1,[aontu/constraint]. - An undeclared key (
twitter) is refused by the closed map: exit 1,[aontu/closed]. - An instance that omits the required literal-enum key
tierisverdict: incomplete, exit 3,disjunct_no_gen. - An instance without v3’s required
regionisverdict: incomplete, exit 3,mapval_requiredat$.profile.region. subsume profile-v2.aon profile-v1.aonanswersverdict: subsumes, exit 0: v2 admits every v1 instance.subsume profile-v1.aon profile-v2.aonis refused withcompat_narrowedat$.profile.contactand$.profile.locale: under closed maps, an addition is not forward-compatible.breaking --against profile-v1.aon profile-v2.aonisverdict: compatible, exit 0 (additive plus deprecate).- Narrowing the email pattern (
proposals/narrow-email.aon) is refused: exit 1,compat_narrowedat$.profile.email. - Adding a required key (
proposals/require-loyalty.aon) is refused: exit 1,compat_required_addedat$.profile.loyalty. - v3 against v2 is
breaking, exit 1, and the--format jsonreport matchesexpected/breaking-v3-report.json:compat_required_addedat$.profile.regionandcompat_narrowedat$.profile.phone, each with itsgeneralandspecificsites. --allow-deprecated-removaldoes not excuse the requiredregionkey: v3 against v2 still exits 1 withcompat_required_added.- Removing the deprecated
phonealone (proposals/v3-remove-phone.aon) fails plain: exit 1,compat_narrowedat$.profile.phone. - The same removal passes with
--allow-deprecated-removal:"verdict": "compatible", exit 0, and thecompat_narrowedfinding is kept at"severity": "warning". - Flipping the marketing-consent default
(
proposals/default-change.aon):--profile valuesanswerssubsumes, exit 0, because the admitted set is unchanged;--profile defaults(the gate’s default) exits 1 withcompat_default_changed(“the effective default changed: previously generable documents materialise differently or become incomplete”), matchingexpected/subsume-default-change.json. - Hiding a generated field (
probes/hide-score-*.aon) is invisible tovaluesanddefaults(both exit 0) and caught only by--profile gen: exit 1,compat_marks_changed, with the mark diffgeneral {"type":false,"hide":true}, specific {"type":false,"hide":false}. - Under
--profile gen, v2 subsumes itself:verdict: subsumes, exit 0. - A
must()check added on the new side (probes/must-email-domain.aon) stops the gate:verdict: undecided, exit 3,sub_evaluate_only(“an evaluate-only check (must) makes the admitted set opaque”). --allow-undecidedturns that into an explicit override: exit 0,sub_evaluate_onlystill reported.- A spread template that reads its own key with
key()(probes/routing-v2.aonagainstrouting-v1.aon) isundecided, exit 3,sub_path_dependent_spread(“a path-dependent spread template cannot be compared structurally”). proposals/waive-gate.aon, which pins its own policy tonone, passes the gate:verdict: compatible, exit 0.- With
--mode backwardon the command line the same proposal is refused: exit 1,compat_required_added. - A version string carried inside the document (
probes/meta-v1.aonagainstmeta-v2.aon) is reported ascompat_narrowedat$.meta.version, exit 1. breaking --at '$.profile'on the same pair isverdict: compatible, exit 0, andsubsume --at '$.profile'answerssubsumes.aontu hash profile-v2.aonandaontu hash probes/v2-reformatted.aon(keys reordered, comments rewritten, whitespace collapsed) print the same pin,aon1-oUzLyquaExn0c2SEql1U-otsj-B0YP22HStsxHvucyU.- v3 hashes differently: a semantic change moves the pin.
hash --formprints the hashed text with itsclose/hide/deprecatemarks;--canonprints the value without them.aontu diff profile-v2.aon profile-v3.aonis a usage refusal, exit 2 (“a mistyped verb reads as a filename”). The change list comes from the MCP server’sdifftool, called over stdio with JSON-RPC, and matchesexpected/diff-v2-v3.json:removed $.profile.phonewith its deprecate canon,added $.profile.region. The tool’s own description scopes it: “Whether a change is BREAKING is the breaking verb’s question, not this one.”- The CI form gates a working file against its committed ancestor:
with v1 committed and v2 in the working tree,
aontu breaking --against 'git#HEAD' profile.aoniscompatible, exit 0; with the narrowed proposal in the working tree it exits 1 withcompat_narrowed. - The subsumption poset above renders from the seven documents with
aontu view posetand matchesexpected/diagram-poset.mmd.
Running it
./check.sh, from anywhere; set AONTU= (and MCP= for the server)
to point at another build. git must be on the path: the git#HEAD
step is part of the case’s contract, and a run that cannot exercise it
fails rather than skips. Every step prints a numbered line, and the
script stops at the first failure.
The gate as CI runs it, one line against the committed version of the same file:
aontu breaking --against 'git#HEAD' profile.aon
The how-to guides Gate schema changes and Pin what a document means walk the same gate and the same hash pin in a smaller setting.