Skip to content

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:

axisversions bywhat it sayswhere it is written
spec/semver MAJOR.MINOR.PATCHthe shape of the data: JSON Schemas, taxonomies, the fields a release manifest carriesspec/VERSION
a content releasedate tag YYYY.MMwhen the law was read — fixreg@2025.04 is the canon as of 2025-04-01the 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.

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 by
spec_schema_range:
min: 1.0.0 # the oldest spec version that can read it
max: 2.0.0 # exclusive: the first that cannot

schema_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.

openregs.compat.check_document() is the one gate, and it refuses on either of two independent statements:

  1. the consumer’s — the release’s declared major is not in SUPPORTED_SCHEMA_MAJORS;
  2. the release’s — this build’s own spec version falls outside the spec_schema_range the 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-schema
release verify: release fixreg@2099.01 at fixtures/releases/future-schema/release-meta.yaml
declares spec schema_version 2.0.0, which this build of openregs cannot read: it
implements spec 1.4.0 and reads releases in 1.0.0 <= schema_version < 2.0.0. ...
see https://docs.openregs.io/schema/
$ echo $?
2

That 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:

consumerentry point
openregs release verify_run_release_verify
openregs diff, openregs impact, openregs bump, the feedopenregs.release.published_meta()
openregs serve, openregs serve --mcp, openregs evalopenregs.serve.startup.verify_for_serving() and ServedRelease.from_directory()
openregs pullopenregs.release.pull.pull_release(), before a byte is staged
openregs compileopenregs.codegen._checked()
openregs index verifyopenregs.combinations, against the combination’s own spec_schema_range
the release builderopenregs.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

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 to SUPPORTED_SCHEMA_MAJORS in 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.

spec versionwhat changed
1.0.0the initial published shapes: atoms, unit metadata, index combinations, controls, sources, trust, deprecations, embeddings, entity types
1.1.0additive — 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.0additive — 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.0additive — 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.0additive — 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