---
title: "04. Schema-evolution governance for a shared customer-profile schema"
description: "Governance of a shared schema across v1→v3"
source: "https://aontu.dev/use-cases/04-schema-evolution/"
---

# 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`](https://github.com/aontu-lang/aontu/blob/main/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/`](https://github.com/aontu-lang/aontu/tree/main/use-cases/04-schema-evolution/).

The model tree: one released version of the customer profile

## 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-form `phone?`, strict `tier` enum, consent flags with safe defaults), plus the `aontu_policy.compat` declaration.
-   `profile-v2.aon`: v2.0.0: additive minor (optional `locale`, optional validated `contact` block) and `phone` marked `deprecate(string, {msg, use, since})`.
-   `profile-v3.aon`: v3.0.0, a major: `phone` removed, required `region` added.
-   `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.

```plaintext
$
├── 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 | boolean`** for 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.
-   **`tier`** is a literal enum, `"standard" | "premium" | "enterprise"`, with no default; v3’s required `region` is a regex constraint, `string & re("^(us|eu|apac)$")`. An instance that omits `tier` vets as `disjunct_no_gen` and one that omits `region` as `mapval_required`, both class incomplete, exit 3.
-   **`deprecate(x, {msg, use, since})`** on `phone` in v2, pointing at its successor `$.profile.contact.phone`. The mark rides the value: v2 admits exactly what v1 admits, vet reports the field at severity `warning` (exit stays 0), and one version later the same mark is what `--allow-deprecated-removal` keys on.
-   **`aontu_policy: hide({compat: *backward | ...})`** in every version; `hide()` keeps it out of generated output. `breaking` reads `$.aontu_policy.compat` from the new document, and `--mode` on the command line takes precedence over it (see [`aontu breaking`](https://aontu.dev/docs/reference-api#aontu-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-*.aon` pair shows this below, and `--at` anchors 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:

```plaintext
$ 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"})`:

```plaintext
$ 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`:

```plaintext
$ 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:

```plaintext
$ 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:

```plaintext
$ 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

aontu subsumption poset at=$.profile profile=defaults documents=7 nodes=6

Mermaid source

```mermaid
%% 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 --> n2
```

Drawn 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-loyalty` and `waive-gate` are 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.

1.  All three released versions render with `--canon`, and v2’s canonical form matches `expected/profile-v2.canon`.
2.  A conforming v2 instance (`data/customer-ok.json`) is `verdict: valid`, exit 0, with the `deprecated` warning for `phone` anchored at its schema site (`schema: profile-v2.aon`).
3.  A legacy instance still using `phone` is valid, and the `--format json` report matches `expected/vet-legacy-phone.json`: one `deprecated` finding at severity `warning`, sited at the data (`data/customer-legacy-phone.json:5:12`).
4.  The same legacy instance against v1 is valid with no deprecation warning.
5.  A malformed email is `verdict: invalid`, exit 1, `[aontu/constraint]`.
6.  An undeclared key (`twitter`) is refused by the closed map: exit 1, `[aontu/closed]`.
7.  An instance that omits the required literal-enum key `tier` is `verdict: incomplete`, exit 3, `disjunct_no_gen`.
8.  An instance without v3’s required `region` is `verdict: incomplete`, exit 3, `mapval_required` at `$.profile.region`.
9.  `subsume profile-v2.aon profile-v1.aon` answers `verdict: subsumes`, exit 0: v2 admits every v1 instance.
10.  `subsume profile-v1.aon profile-v2.aon` is refused with `compat_narrowed` at `$.profile.contact` and `$.profile.locale`: under closed maps, an addition is not forward-compatible.
11.  `breaking --against profile-v1.aon profile-v2.aon` is `verdict: compatible`, exit 0 (additive plus deprecate).
12.  Narrowing the email pattern (`proposals/narrow-email.aon`) is refused: exit 1, `compat_narrowed` at `$.profile.email`.
13.  Adding a required key (`proposals/require-loyalty.aon`) is refused: exit 1, `compat_required_added` at `$.profile.loyalty`.
14.  v3 against v2 is `breaking`, exit 1, and the `--format json` report matches `expected/breaking-v3-report.json`: `compat_required_added` at `$.profile.region` and `compat_narrowed` at `$.profile.phone`, each with its `general` and `specific` sites.
15.  `--allow-deprecated-removal` does not excuse the required `region` key: v3 against v2 still exits 1 with `compat_required_added`.
16.  Removing the deprecated `phone` alone (`proposals/v3-remove-phone.aon`) fails plain: exit 1, `compat_narrowed` at `$.profile.phone`.
17.  The same removal passes with `--allow-deprecated-removal`: `"verdict": "compatible"`, exit 0, and the `compat_narrowed` finding is kept at `"severity": "warning"`.
18.  Flipping the marketing-consent default (`proposals/default-change.aon`): `--profile values` answers `subsumes`, exit 0, because the admitted set is unchanged; `--profile defaults` (the gate’s default) exits 1 with `compat_default_changed` (“the effective default changed: previously generable documents materialise differently or become incomplete”), matching `expected/subsume-default-change.json`.
19.  Hiding a generated field (`probes/hide-score-*.aon`) is invisible to `values` and `defaults` (both exit 0) and caught only by `--profile gen`: exit 1, `compat_marks_changed`, with the mark diff `general {"type":false,"hide":true}, specific {"type":false,"hide":false}`.
20.  Under `--profile gen`, v2 subsumes itself: `verdict: subsumes`, exit 0.
21.  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”).
22.  `--allow-undecided` turns that into an explicit override: exit 0, `sub_evaluate_only` still reported.
23.  A spread template that reads its own key with `key()` (`probes/routing-v2.aon` against `routing-v1.aon`) is `undecided`, exit 3, `sub_path_dependent_spread` (“a path-dependent spread template cannot be compared structurally”).
24.  `proposals/waive-gate.aon`, which pins its own policy to `none`, passes the gate: `verdict: compatible`, exit 0.
25.  With `--mode backward` on the command line the same proposal is refused: exit 1, `compat_required_added`.
26.  A version string carried inside the document (`probes/meta-v1.aon` against `meta-v2.aon`) is reported as `compat_narrowed` at `$.meta.version`, exit 1.
27.  `breaking --at '$.profile'` on the same pair is `verdict: compatible`, exit 0, and `subsume --at '$.profile'` answers `subsumes`.
28.  `aontu hash profile-v2.aon` and `aontu hash probes/v2-reformatted.aon` (keys reordered, comments rewritten, whitespace collapsed) print the same pin, `aon1-oUzLyquaExn0c2SEql1U-otsj-B0YP22HStsxHvucyU`.
29.  v3 hashes differently: a semantic change moves the pin.
30.  `hash --form` prints the hashed text with its `close`/`hide`/`deprecate` marks; `--canon` prints the value without them.
31.  `aontu diff profile-v2.aon profile-v3.aon` is a usage refusal, exit 2 (“a mistyped verb reads as a filename”). The change list comes from the MCP server’s `diff` tool, called over stdio with JSON-RPC, and matches `expected/diff-v2-v3.json`: `removed $.profile.phone` with 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.”
32.  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.aon` is `compatible`, exit 0; with the narrowed proposal in the working tree it exits 1 with `compat_narrowed`.
33.  The subsumption poset above renders from the seven documents with `aontu view poset` and matches `expected/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:

```sh
aontu breaking --against 'git#HEAD' profile.aon
```

The how-to guides [Gate schema changes](https://aontu.dev/how-to/gate-schema-changes) and [Pin what a document means](https://aontu.dev/how-to/pin-a-document-hash) walk the same gate and the same hash pin in a smaller setting.
