Status: design proposal, 2026-08-18. No code yet. Companion to data-api.md and architecture.md.
Everything k2g persists is a document validated against a JSON Schema in schemas/. Today those schemas may change freely, because every k2g in existence is a pre-release and the only files in the world are on machines whose owner is also the person changing the schema.
1.0 ends that. From then on, a schema change has to answer a question it has never had to answer: what happens to the file that is already on the operator's disk? This document proposes the answer, and the rules that decide when the question even arises.
parse_document refuses a document whose schema_version is not exactly the schema's
x-schema-version — in either direction
(parse.rs:75):
| the file says | what happens | the message |
|---|---|---|
| nothing | dropped | missing schema_version (schema is version N) |
| newer than the schema | dropped | file schema_version M is newer than supported N; rejected |
| older than the schema | dropped | file schema_version M is older than N; upgrade not yet supported |
"Dropped" means parse_document returns None: the document is not in the store, the
application runs without it, and the file on disk is untouched. A rejected machining
profile is a profile that has vanished from the screen.
The crate says as much about itself — "a newer file is rejected, and (for now) an older file is rejected too — an in-place upgrade path is planned" (lib.rs:85).
The application compensates in src/data/mod.rs, before the gate ever sees the data. Each
document kind gets a normaliser that rewrites an old shape and stamps the current
version:
| normaliser | what it repairs |
|---|---|
inject_schema_version |
a file with no schema_version at all |
normalize_machining_value |
the pre-v3 flat setup, wrapped into steps[0], and the version stamped to 3 |
normalize_step_value |
retired enabled flags, empty-string refs, the old route_board edge-only shape |
migrate_stock_ref |
the nested ref: { catalog: … } object flattened to source_catalog |
materialize_stock_overrides |
a stock tool whose overrides lack their base fields |
normalize_cnc_value, normalize_fixture_value |
the same, per profile kind |
They work, they are idempotent, and they are the reason a 0.x file still opens. What they are not is a versioned upgrade path:
- They are keyed on shape, not on version. Each detects "the old way" by a missing key and collapses every historical form directly to current. There is one rung, however many releases have passed.
- Nothing is kept. The upgraded document is written back over the original at the next edit. A migration that gets it wrong has destroyed the evidence.
- Nothing is tested per version. There is no file at version 1 in the repository that CI opens; the only proof a v1 file still loads is that nobody has reported otherwise.
- They are invisible. An upgraded document produces no record. The operator is not told that the file they have been keeping since March was rewritten.
catalog.yamldeclaresschema_version: 1, notx-schema-version: 1(catalog.yaml:3). The gate looks upx-schema-version, so for catalogs it never engages at all — a catalog's version is enforced only by the ordinaryconst: 1validation, which reports a different error kind and does not drop the file. Meanwhile catalog_normalizer.rs:59 states in a comment that catalog.yaml declaresx-schema-version: 1. The code's belief and the file disagree, and the belief is the one that reads correctly.id.yamlandunits.yamldeclarex-schema-version: 1but are$defs-only support files, never parsed as root documents. Harmless, and worth deleting so the keyword means one thing.
x-schema-version goes up when a document written by the previous release would be
wrong under the new schema. That is a narrower rule than "the schema changed", and the
distinction is the whole point:
| change | bump? | why |
|---|---|---|
add an optional property with a default |
no | the old document is still valid; the loader materialises the default |
add a value to an enum |
no | no existing document holds the new value |
widen a pattern, raise a maximum |
no | everything valid before is valid now |
add x-enum-labels, title, description |
no | annotation; nothing about a document changes |
| rename a property | yes | the old key is now unknown |
| remove a property | yes | unless it was optional and ignoring it is acceptable |
| change a property's type or unit | yes | the stored value no longer decodes |
| tighten a constraint | yes | a document that was valid may not be |
add to required |
yes | every existing document lacks it |
Two recent changes are worked examples of the top half. export_directories was added to
settings.yaml as an optional array with default: [] and deliberately kept out of
required — no bump. x-enum-labels added a keyword to eight schemas and changed no
document — no bump.
Replace "detect the old shape, collapse to current" with an explicit ladder per document kind:
// One entry per version boundary. `from` is the version the document declares.
const MACHINING_MIGRATIONS: &[Migration] = &[
Migration { from: 1, to: 2, apply: v1_to_v2 },
Migration { from: 2, to: 3, apply: v2_to_v3 }, // the flat setup → steps[0]
];Applied in order from the file's declared version to the schema's, on the raw
serde_json::Value, before validation — so the gate only ever sees a current
document, and each rung is a small function with one job. The normalisers above become the
rungs they always implicitly were.
The chain's shape is what buys the long tail: a file from 1.0 reaching a 1.7 build climbs six rungs it has never seen, and each of them was tested when it was written.
Before the first write of an upgraded document, copy the file to <name>.v<n>.bak. Once —
not per write, not per session.
This is the cheapest possible insurance against the failure that matters: a migration that is wrong rather than absent, discovered a week later. It also makes downgrading possible, which the version gate otherwise forbids outright (a newer file is rejected, so an operator who tries an older build finds an empty application).
Newer-than-supported stays a refusal — a build cannot guess what a later release meant.
But it must be said: today a dropped document is a warn! line and an empty screen, and
the two do not obviously belong to each other.
The security log already separates the two outcomes, since
config.rejected / config.problem were split apart: rejected means the document is not
in use, a problem means it loaded with a complaint. The remaining half is on screen — a
document dropped at load should raise the diagnostics banner naming the file and the
reason, in the same place every other blocking fault appears.
For each document kind, keep one file per historical version under
crates/datastore/tests/fixtures/versions/ (or the app's own test tree, for app-level
shapes). CI asserts:
- every declared past version has a fixture;
- each fixture migrates to current and then validates;
- a fixture at version N migrated to current equals the same document written natively at current (the property that keeps a rung honest as later rungs are added);
- a document one version newer than the schema is still refused.
A migration without a fixture is a migration nobody has run since the day it was written.
- Catalogs are supplied by third parties and are read-only. They need the same version gate (see the discrepancy above) but not the same upgrade chain: an unreadable catalog is re-imported, not migrated.
- Cross-document migrations — a change that moves data from one file to another — are out of scope. The writer is per-file and atomic per file, so a migration spanning two documents can half-happen. If one is ever needed, it needs its own design.
- Downgrade beyond the
.bakcopy. The gate refuses a newer file by design and this does not change that; the backup exists so an operator is not stuck, not so that versions become interchangeable.
- Fix the two declaration discrepancies (
catalog.yaml, the two support files) — small, and independent of everything else. - Add the migration registry and chain runner to
datastore, with the existing normalisers moved in as the first rungs. - Add the fixtures and the CI assertions.
- Surface a dropped document in the diagnostics banner.
- Write the bump rules into
CONTRIBUTING/claude.mdso the table above is consulted before a schema is edited, not after.