A schema is never finished. Requirements shift, a field that was nice-to-have becomes mandatory, a format gets stricter. The hard part is making the change without turning every consuming repo’s CI red the moment you publish. This page covers the two things that make schema evolution safe. Versioning the standard lets consumers choose when to adopt a change. Understanding dialects keeps the JSON Schema flavor you write in from being the thing that breaks.
When many repos validate against one schema, an in-place edit is a deployment to all of them at once. Add a required field, and every document that lacks it fails on the next run, across repos you don’t own and can’t fix. A version turns that all-at-once edit into a choice each consumer makes on their own schedule. They keep pinning the old version until they’re ready, then move to the new one deliberately.
So the first move in evolving a schema safely is making sure your schema has a version that consumers can pin to.
How you express the version depends on which of the three reference kinds your consumers use to name the schema. (For how manni meta classifies a reference as built-in, file, or URL, see the three reference kinds.)
A built-in id already carries its version in the third segment of vendor:name:version. The bundled OKF schema is google:okf:0.1, and the 0.1is the pin. A future revision ships as a new id, google:okf:0.2. A consumer naming google:okf:0.1 keeps validating against exactly the schema they chose until they edit that reference themselves.
manni.config.yaml
meta:
schemas:
- google:okf:0.1# pinned; a new built-in version is a new id
Built-in versions are decided by manni meta, not by you: you adopt them, you don’t author them. If you’re defining your own standard, you’ll version a file or URL schema instead.
A schema you keep in the repo has no built-in version field, so encode the version in the path or filename. Both of these read as a pin at a glance:
manni.config.yaml
meta:
schemas:
# version in the filename
- ./schemas/metadata-1.2.schema.json
# ...or version in a directory
- ./schemas/v1/metadata.schema.json
When you ship a breaking change, add metadata-1.3.schema.json (or v2/metadata.schema.json) alongside the old file rather than overwriting it. Consumers move by editing the reference, and you can keep the previous version in the repo for as long as anyone still pins it.
A URL schema (the foundation for governing one schema across many repos) versions the same way, in the path. Publish each version at a distinct, stable URL:
Consumers pin by referencing a versioned URL, never a “latest” one. Keep old versions reachable so pinned repos don’t break, and announce a new version rather than mutating an existing URL in place. Consumers can also vendor a versioned URL into their own repository. A release is then pinned by hash rather than fetched on every run. Both approaches, and their tradeoffs, are covered in Govern a shared schema across repos.
Once your schema is versioned, the next question is which changes you can make freely and which need a careful rollout. The line runs between changes that can only accept more documents and changes that can reject documents that used to pass.
Safe (additive)
Changes that never fail a document that passed before:
Adding a new optional field to properties (validated when present, not demanded).
Loosening a constraint, such as removing a field from required, widening an enum, or dropping a format.
Relaxing additionalProperties from false to true.
Editing description/title text in the schema.
These you can usually ship as a routine update without a staged rollout.
Breaking (tightening)
Changes that can fail a document that passed before:
Adding a field to required.
Adding a new constraint, such as a format, a pattern, a narrower enum, or a minLength.
Tightening additionalProperties from true to false.
Changing a field’s type.
These need the staged ratchet below, and a new version so consumers opt in.
The most common breaking change is also the most useful one: promoting a field from recommended to required. That’s worth its own playbook.
Demanding a field that documents don’t yet carry fails every one of them at once. The safe path is to add the field, populate it across the repo while it’s still optional, and only then make it required. The day you flip the switch, everything already complies.
Add the field as optional. Describe it in properties but leave it out of required. It’s validated when present and ignored when absent, so nothing fails yet.
Backfill the field across existing documents. With the field optional, manni meta won’t block the repo while you work through it. Land the values incrementally.
Promote the field to required. Once coverage is complete, move it into required and ship that as a new schema version. Consumers adopt the stricter version when they pin to it.
This add-then-ratchet pattern is the heart of tightening a standard without a flag day. The full walkthrough lives in Roll out a new required field without breaking the build. It covers staging the rollout across a large repo, and using config to apply the stricter schema gradually.
When the corpus and the schema live in the same repo, the three steps above
collapse into one statement. manni meta query treats the resolved schema as the
table definition, so ALTER TABLE edits it:
Terminal window
npx@hawkeyexl/mannimetaquery"ALTER TABLE docs
ADD COLUMN reviewed TEXT NOT NULL DEFAULT 'pending'"docs/
adds reviewed to properties and requiredand backfills every file in the same write. The corpus is compliant with the stricter schema the moment the schema is stricter. DROP COLUMN and RENAME COLUMN retire and rename a key from the schema and every file together. Run any of them with --dry-run first to see the exact plan before it applies. When the corpus resolves more than one schema and the statement cannot tell which to evolve, -s <schema> names the contract directly. Same guards, your choice of target.
The constraints schema authors reach for first, format and enum, ride the same statement. A declared type equal to a format name the validator enforces writes the format, and CHECK (col IN (…)) writes the enum:
Terminal window
# reviewed_on: { type: string, format: date }, required, every file backfilled
npx@hawkeyexl/mannimetaquery"ALTER TABLE docs
ADD COLUMN reviewed_on DATE NOT NULL DEFAULT '2026-08-26'"docs/
ADD COLUMN status TEXT CHECK (status IN ('draft','review','final'))"docs/
so evolving “one of these values” or “must be a date” is one reviewed statement rather than a hand edit plus a hand migration. (DATETIME and TIMESTAMP alias to date-time. Hyphenated format names are quoted types, as in ADD COLUMN updated "date-time".) The guard that keeps the ratchet honest extends with it. A DEFAULT the mapped format rejects, such as DATE DEFAULT 'yesterday', refuses before anything is written. A DEFAULT outside the enum is refused by the CHECK itself. A rename carries the whole hand-written property. An enum, format, or description you added by hand survives RENAME COLUMN intact.
What “the schema” means follows your reference kind. A file schema you maintain is edited in place, keeping your indent and git as the review surface. An integrity: pin is refreshed if the config carries one. A built-in is immutable, so it forks to a local copy, with the config and any in-file $schema repointed in the same write. What DDL deliberately does not do is bump your version for you. The preview names the schema file it will edit. Encoding the new version, whether in the filename, the $id, or the URL, stays the deliberate move this page is about. Additive ADD is the minor-version case; DROP and RENAME are the breaking ones.
The second thing that can break a schema is the JSON Schema dialect you wrote it in, rather than a field you changed. JSON Schema has evolved through several versions: draft-04, draft-06, draft-07, 2019-09, and 2020-12. They aren’t fully compatible, and a schema written for one dialect can fail to compile under another. manni meta handles this for you. It reads each schema’s own $schema line, the URI at the top that names the dialect’s meta-schema. Then it compiles that schema with the matching validation engine.
manni meta supports these dialects:
Dialect
Detected from a $schema URI containing
2020-12
2020-12 (also the fallback)
2019-09
2019-09
draft-07
draft-07 or draft/7
draft-06
draft-06 or draft/6 (shares the draft-07 engine)
draft-04
draft-04 or draft/4
If a schema omits its $schema line, or uses one manni meta doesn’t recognize, validation falls back to 2020-12. That is the dialect of the built-in schemas.
Relying on the fallback works, but stating the dialect removes ambiguity for manni meta and for anyone reading the schema. Keep a $schema line at the top of every schema file, pointing at the meta-schema for the dialect you wrote in:
Both schemas validate the same documents here; the only difference is which engine manni meta compiles them with. Declaring the dialect is what lets you write to a specific version’s features without guessing how manni meta will read the file.
A file can be validated against more than one schema, through a list-valued $schema, several config entries, or layered --schema flags. Those schemas don’t all have to share a dialect. manni meta detects the dialect of each schema independently and compiles each one in its own engine. A 2020-12 house-style schema and a draft-07 schema you fetched from a partner can validate the same file in the same run. Each is held to its own dialect’s rules.
This is why adopting a schema from elsewhere never forces you to rewrite it to match your other schemas. That covers an older internal standard, or a third-party schema that still targets draft-07. Drop it into the set as-is; its $schema line tells manni meta how to read it.