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 at the commit this was built fromspec_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 being built, 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.
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.2.0 and reads releases in 1.0.0 <= schema_version < 2.0.0. ...see docs/schema-compatibility.md$ echo $?2The 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.
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 |
Rendered from openregs/openregs@cbe9615:docs/schema-compatibility.md