Schema and its evolution
Schemas change; addresses do not
A schema is a CX document (authored in the same syntax it validates — see the schema material in data), so a schema has a content address like any other value. This section is about what happens when the schema changes: how CX decides whether the change is compatible, how two revisions are linked, and why evolution never rewrites anything.
The headline rule, and the answer to every migration-anxiety question: identity is schema-independent, and nothing is ever overwritten. A value's address is computed from its canonical bytes — the schema in force does not participate — so evolving a schema never perturbs a single stored address. Migration is therefore always additive: new documents beside old ones, new readings of old events, never an in-place rewrite. (What that looks like operationally — upcasting journals, migrating stored documents — is Ring 2's half: event-evolution.)
Compatibility you can compute
Whether schema v2 accepts everything schema v1 accepted is not
a matter of judgment — it is a decidable, pure predicate
over the two declaration forms, and for every document valid
under v1, validation under v2 yields valid or a named
diagnostic — never a silently different meaning. The predicate
ships as cx schema compat OLD.cxs NEW.cxs: it classifies
every field-level change, derives the translator for the
mechanically-derivable classes (a lineage claim carrying the
rewrite rules as data — se-lineage), and refuses a
change that genuinely reinterprets data, with a prompt naming
the missing rule per change. Renames are declared
(--rename TYPE/OLD=NEW), never guessed — a removed field
plus an added same-shaped field is indistinguishable from a
rename, and a guessed rename would silently move data.
The changes that are safe within a schema line:
- Add a field with a default — old documents validate; the default materializes at validation time.
- Add an optional attribute — old writers do not produce it; old readers do not require it.
- Add a new element type — existing documents never reference it.
- Widen a constraint — loosening a length bound or widening an enum keeps every previously-valid document valid.
The changes that are breaking:
- Renaming or removing a field — breaks every reader or consumer that names it.
- Tightening a constraint — narrowing an enum, lowering a bound, or making an optional field required turns existing-valid documents into errors.
- Changing a type — a string field becoming an int is a different value model, a hard break.
- Adding a required field — even in open mode. Open
mode waives undeclared content, not declared
requirements; a new
[req]is a new obligation on every existing document, and the predicate says so.
One subtlety for tool authors: a compatibility verdict is computed on the form a consumer actually validates against. A schema exported to JSON Schema is a lossy projection, so \"additive in CX\" does not automatically mean \"additive in the export\" — the verdict for exported consumers is computed on the exported form.
Schema identity and lineage
A type's identity is (element name, schema content-hash) —
see value-model. It follows that Order under v1 and
Order under v2 are different type identities that happen to
share a name. Nothing intrinsic links them, because nothing
intrinsic could without perturbing identity.
The link is an explicit, detached lineage claim: a Lane-2
value naming the from schema address, the to schema
address, the relation (:additive, :narrowing, :split,
:merge), and — when one exists — the upcaster that carries
old data forward. For the derivable classes, cx schema
compat emits the claim with the rewrite rules as data
([derived …]), and the journal applies them natively and
losslessly — a derived claim IS a usable upcaster chain.
Lineage claims form a graph, and the graph
must admit a unique path between any two endpoints; an
ambiguous graph is rejected fail-closed at load, because two
different upgrade paths for the same pair is two different
meanings for the same data.
One rule keeps the whole scheme honest: a schema document that anchors a type identity (feeds a type-binding claim, a pair exchange, or any address-bound adoption) declares exactly one type. Multi-type schema documents remain legal for validation — but they cannot anchor identity, so editing one type in a shared file can never silently move another type's identity.
What to do when, in one table
| You want to… | Do this | Where it is covered |
|---|---|---|
| add a field, widen an enum | cx schema compat confirms it is additive and derives the lineage claim; publish deploys it silently |
this section |
| ship a breaking change | publish v2 as a new type identity + an authored lineage claim with an upcaster — the publish gate stops you until the claim exists | this section + event-evolution |
| reread old journal events under v2 | upcast at read time — journals are never rewritten | event-evolution |
| migrate stored documents | batch-migrate: new docs + ref advances; old docs stay addressable | event-evolution |
| assert a type at a trust boundary | exchange the (name, hash) pair; validate fail-closed | value-model |
The division of labor between the halves: Ring 0 owns the vocabulary — what compatible means, what a lineage claim is, why addresses never move. Ring 2 owns the machinery — folds that upcast, snapshots that remember which fold produced them, and the honest failure when an entry no upcaster covers appears mid-replay.