Schema compatibility — spec versions, release tags, and what refuses what
OpenRegs carries two independent version axes, and keeping them independent is a design rule rather than an accident:
| axis | versions by | what it says | where it is written |
|---|---|---|---|
spec/ | semver MAJOR.MINOR.PATCH | the shape of the data: JSON Schemas, taxonomies, the fields a release manifest carries | spec/VERSION |
| a content release | date tag YYYY.MM | when the law was read — fixreg@2025.04 is the canon as of 2025-04-01 | the release’s directory name and its release-meta.yaml tag |
Neither axis may drag the other. Bumping spec/ must not invalidate a release
that was already published and signed; cutting a new release must not require a
spec bump. A consumer therefore never asks “is this release new enough?” — it
asks “is this release shaped like something I can read?”, and the two
questions have different answers.
What the two sides declare
Section titled “What the two sides declare”Every release declares what it was built under. release-meta.yaml carries
two fields, written by openregs release build and never by hand:
schema_version: 1.0.0 # the spec version of the engine this was built byspec_schema_range: min: 1.0.0 # the oldest spec version that can read it max: 2.0.0 # exclusive: the first that cannotschema_version is read out of spec/VERSION at the commit naming the engine
the release was built by, not from the checkout doing the building. That is what
decouples the axes: rebuilding fixreg@2025.04 from its own commit on a machine
whose spec/ has moved on since reproduces the bytes that release shipped, and a
spec bump never restamps — and so never invalidates the digests and signatures of
— a published release.
Which commit names the engine depends on which kind of checkout holds the corpus,
and there are two. The engine’s own carries spec/ beside regimes/, so the
commit being built is an engine commit and the version comes straight out of it;
that is every release this repository has published. A regime data repository
has no spec/ and never will — a corpus reads the schemas from the installed
engine rather than carrying a copy of shapes it does not own — so its version is
the one carried by the engine commit its core-pin.yaml pins
(docs/runbooks/new-regime.md). The pin is itself read
at the corpus commit being built, so the version stays a function of the sha
release-meta.yaml records and of nothing else; a build whose engine checkout
cannot resolve that pin stops and says so rather than guessing.
openregs.release.source.spec_version_at is the one place this is decided.
spec_schema_range is derived from that version by
openregs.compat.range_for(): the floor is the first version of the major, not
the version it was built under, because minors are additive. A release built
under 1.1.0 is readable by a consumer implementing 1.0.0; writing min: 1.1.0
would be a release claiming a break that did not happen.
Every consumer declares what it can read. SUPPORTED_SCHEMA_MAJORS in
tooling/openregs/compat.py is the CLI’s and
the server’s own statement — today (1,). There is deliberately no
minor-level or patch-level constraint anywhere in that module: within one
major, a consumer accepts every release, which is what makes “a minor bump
cannot break a consumer” true by construction rather than by promise.
What happens when they disagree
Section titled “What happens when they disagree”openregs.compat.check_document() is the one gate, and it refuses on either of
two independent statements:
- the consumer’s — the release’s declared major is not in
SUPPORTED_SCHEMA_MAJORS; - the release’s — this build’s own spec version falls outside the
spec_schema_rangethe release declares.
The refusal names both versions and points back at this document. It is a non-zero exit, never a warning and never a partial read:
$ openregs release verify fixtures/releases/future-schemarelease verify: release fixreg@2099.01 at fixtures/releases/future-schema/release-meta.yamldeclares spec schema_version 2.0.0, which this build of openregs cannot read: itimplements spec 1.4.0 and reads releases in 1.0.0 <= schema_version < 2.0.0. ...see https://docs.openregs.io/schema/$ echo $?2That last line is a URL and not docs/schema-compatibility.md on purpose. This
refusal is raised by a server answering a hosted API and by whatever build an
operator happens to be running, and neither reader is standing in a checkout of
the engine. /schema/ is a permalink: the documentation site promises to
keep it resolving whatever this page is later renamed to, which is what a string
compiled into a shipped binary needs. openregs.compat.UPGRADE_DOC is the one
place it is written down.
The gate runs first, before digests, before signatures, before a row of the
bundle is read — including under openregs serve --insecure-skip-verify, because
that flag waives trust and no amount of trust makes an unreadable shape
readable. Nothing is weakened by that ordering: a release that passes the gate
goes on to exactly the verification it would have had.
Where the gate sits, plane by plane:
| consumer | entry point |
|---|---|
openregs release verify | _run_release_verify |
openregs diff, openregs impact, openregs bump, the feed | openregs.release.published_meta() |
openregs serve, openregs serve --mcp, openregs eval | openregs.serve.startup.verify_for_serving() and ServedRelease.from_directory() |
openregs pull | openregs.release.pull.pull_release(), before a byte is staged |
openregs compile | openregs.codegen._checked() |
openregs index verify | openregs.combinations, against the combination’s own spec_schema_range |
| the release builder | openregs.release.build._spec_schema() — it refuses to build a canon whose spec major it does not implement, which is the same rule pointed the other way |
Upgrading
Section titled “Upgrading”A release refuses to load and names a major above yours. Your build is
older than the release. Upgrade openregs to a build whose
SUPPORTED_SCHEMA_MAJORS includes that major; there is no flag that reads it
anyway, because a major bump means the shapes changed meaning and a
half-understood obligation is worse than none. Until you upgrade, the previous
release of that regime is still readable — releases are never retracted by a
spec bump.
A release refuses to load and names a major below yours. Your build dropped support for a shape that release still uses. Read it with the build it was published for, or re-cut the release from its canon commit with the current tooling.
You are bumping spec/ yourself.
- Minor or patch — additive only: new optional fields, new schema files, new
taxonomy entries. Edit
spec/VERSION, make the change, and stop. No consumer changes, no release is rebuilt, no already-published release moves. Releases cut after the bump declare the new version and old consumers keep loading them. - Major — a field changes meaning, is removed, or becomes required. Bump
spec/VERSION’s major, then add the new major toSUPPORTED_SCHEMA_MAJORSin the same change, and say in this document what moved. Published releases keep declaring the major they were built under; a build that supports both majors is what makes the transition survivable.
Either way, write the row in the version history below. The spec/VERSION
line says which shapes a release was built under; the row is the only place a
consumer can read what actually moved, and a version whose meaning lives only in
a diff is a version nobody can look up.
Both halves of that are checked rather than remembered. make check-spec-version
— tooling/ci/spec_version.py, run inside the
required test context — compares the working tree against the commit the branch
left, and fails when a shape under spec/schemas/ or spec/taxonomies/ moved
and spec/VERSION did not, when the version went backwards, or when it moved
without a row here. Reformatting a schema is not a change: both sides are parsed
and compared as data, so only a moved structure counts. Which size of bump the
change deserves is the judgement above and is left to the reviewer the bump is
now visible to — the check knows that the version moved, never that it moved by
the right amount. Nothing enforced any of it before 1.3.0, which is why that row
has four changes in it instead of one.
Version history
Section titled “Version history”| spec version | what changed |
|---|---|
| 1.0.0 | the initial published shapes: atoms, unit metadata, index combinations, controls, sources, trust, deprecations, embeddings, entity types |
| 1.1.0 | additive — spec/VERSION written down, and spec/schemas/release-meta.schema.json added so the release manifest’s own shape is spec’d rather than implicit. No existing document becomes invalid; consumers built against 1.0.0 read 1.1.0 releases unchanged |
| 1.2.0 | additive — the optional licenses block in the release manifest: {code: Apache-2.0, data: CC-BY-4.0}, the licensing split stated where a downstream packager can read it. Not required, so a release cut before it declares nothing and stays valid; the three standing releases were rebuilt from their own commits to carry it, which changed only release-meta.yaml |
| 1.3.0 | additive — four things moved between 1.2.0 and here while spec/VERSION stood still, because nothing checked that it had not. spec/schemas/controls.schema.json gained the optional reviewed_against: the release a reviewer actually read when they decided a control entry, held apart from the file’s own release pin so that taking a newer release cannot carry every entry forward as though somebody had re-read the obligation. spec/schemas/trust.schema.json gained identities and applies_to, and a roster schema_version of 2 — how a keyless signing identity is anchored where there is no long-lived key to pin — and a roster still stamped 1 parses unchanged. Every schema’s $id moved from openregs.org to openregs.io, a fictional TLD corrected to the project’s own; the $refs between them moved in the same commit and nothing resolves either over a network, so no document’s validity turned on it. And spec/schemas/branch-protection.schema.json gained pull_request.review_enforcement, which is required — the one entry here that is not additive, and deliberately still not a major. A branch-protection policy is this repository’s own governance and is never part of a release: no release-meta.yaml carries one, no consumer reads one, and the single instance of that shape anywhere was edited in the commit that made the field required. There is therefore no document that 1.2.0 made valid and 1.3.0 makes invalid, which is what a major would have to be protecting |
| 1.4.0 | additive — spec/schemas/scope.schema.json’s instrument_id pattern was widened from ^[0-9A-Z][0-9A-Za-z._/-]*$ to a union of three alternatives, the first of which is that pattern kept verbatim. An instrument id had to open with a digit or a capital, so the only publisher form it admitted was a CELEX number: a legislation.gov.uk document id (eur-2024-1 — the key the engine’s own fixtures/scope/uk-instruments.catalogue.json is keyed by) opens lowercase, and a CFR part citation (12 CFR 1) carries spaces. The field’s own $comment had promised all three since 1.0.0, so a UK or US corpus repo could not declare the instruments it exists for and the schema contradicted its own documentation. The two new alternatives are the shapes the adapters themselves parse — [a-z]+-[0-9]{4}-[0-9A-Za-z]+ from adapters/uk.py’s _DOCUMENT_ID, [0-9]{1,2} CFR [0-9]+[A-Za-z]? from adapters/us.py’s _CITATION, in the one spelling CfrPart.citation emits. Nothing narrows: the union contains the 1.3.0 pattern as written, so every id valid at 1.3.0 — every CELEX, and the UNSCOPED placeholder openregs init-regime writes — is valid here, which is what makes this a minor and not a major. SUPPORTED_SCHEMA_MAJORS is untouched. What the widened pattern still refuses is the half a regex cannot state: an eId (art_5__para_2, art_7), because an eId addresses a unit inside an instrument and scope is declared per instrument; and a second spelling of an instrument the pattern already admits — eur/2024/1, title-12-part-1, 12 CFR part 1, 12 cfr 1 — because one instrument declared under two names is two declarations and the duplicate check compares strings. Said plainly: the two alternatives added are tight, the one inherited is not — it admits any string opening with a digit or a capital, and tightening it would be the major this change is not |
Rendered from openregs/openregs@f3a2d10:docs/schema-compatibility.md