feat(gates): metamodelVersion must move when the metamodel does — 0.9 → 0.10 - #322
Merged
Conversation
… → 0.10 ADR-0035 Amendment 2 (#321) made `metamodelVersion` the METADATA-compatibility axis: a breaking metamodel change moves ITS major, not the package major. It handed the promise to a number nobody had ever moved. **Measured: `metamodelVersion` read `"0.9"` from PR #145 (2026-07-02) through 57 releases** — including `0.21.0`, the deliberate pre-1.0 breaking slot that retired assembly origins from `object.value` and shrank `@role`, and `0.22.0`, which added a whole registered type family. It was a label, not a version. A promise carried by a number no one maintains is not a promise. **The gate.** `scripts/check-metamodel-version.mjs`, in `ci-local.sh`'s `gates` lane, diffs `expected-registry.json` — already the byte-exact bill of materials every port is gated against, so no new artifact — against its content at the **last release tag**, classifies every difference, and fails if the declared version did not move by at least that much. Same shape as `buf breaking --against '.git#tag=…'` / `oasdiff`: compare to the released baseline, classify, require the declared version to match. The baseline is a release tag rather than HEAD~1 deliberately — the version promises against what adopters have, and a per-commit baseline would demand a bump from every PR in a cycle instead of the first. Removal and narrowing are breaking (type/subtype removed, attr removed or made required or retyped, enum member removed, an open attr closed, child rule removed or its bounds tightened, default subtype changed); addition and relaxation are additive. **Pre-1.0 a breaking change moves the MINOR**, for the same reason the package line works that way at `0.x`: `0.y` makes no compatibility claim there is anything to break. **It caught a real one on its first run**, against `v0.23.2`: `@promptStyle` removed from `template.output` (breaking) plus `@promptStyle`/`@responseFormat` added to `template.prompt` (additive), with the version still `"0.9"`. Hence `0.9 → 0.10`, written by the gate's own `--set`, which updates the manifest and all four port constants at once (Kotlin emits through the JVM's). That the partial edit is caught by `registry-conformance` is verified, not assumed — reverting the TS constant alone turns `registry-conformance.test.ts` red. **A missing baseline FAILS rather than passing.** This repo has 90 release tags; the only way to see zero is a checkout that did not fetch them, and a baseline-less run would pass unconditionally — a green tick that checked nothing. `--allow-no-baseline` opts a genuinely tagless checkout out. **The blind spot is stated, not hidden.** A rule can change with NO machine-readable footprint: #210 retired assembly origins from `object.value` and its only manifest edit was a `rules` PROSE string. So `description`/`rules`/`whenToUse` changes are reported as a warning asking *did the rule change, or only its wording?* rather than classified — a typo fix and a semantics change are indistinguishable there, and failing on every wording edit trains people to ignore the gate. Answering it is a human step in every release, and the gate asks it every time rather than pretending it covered it. `scripts/test-metamodel-version.mjs` drives all 15 classification rules in BOTH directions against synthetic manifests — the gate itself can only ever prove the happy path once green, so a rule silently backwards (a `max` comparison inverted, a narrowing read as a widening) would otherwise ship as a gate that passes breaking changes. 40 checks, incl. `0.9 → 0.10` satisfying numerically where it regresses lexically. Prompts + docs so the rule is known where it is needed: the `releasing` skill's Phase-0 preflight table and a "two numbers, two contracts" section; `docs/RELEASING.md` with the classification table and the release checklist; AGENTS.md/CLAUDE.md; the adopter-facing always-on prompt (which number tells you your *metadata* needs work vs your *build*); the design doc and ADR-0035 Am. 2 gain the enforcement half they were missing. Verified with the bump in place: TS 6573 · C# 291+53+942+363 · Java+Kotlin BUILD SUCCESS · Python 1825 · `ci-local --only gates` green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
Review + simplify pass on #322. Four findings, all real; two were the gate failing in exactly the way it takes pains to refuse elsewhere. **It failed open on ANY baseline error.** `manifestAt()` swallowed every exception into `null`, and `main()` read `null` as "pre-marker tag — skipping" and exited 0. So a typo'd `--against v9.9.9` printed `fatal: invalid object name`, then a green tick (verified). Same for a renamed manifest path, malformed baseline JSON, or a partial clone whose objects were never fetched. That is precisely the failure the no-baseline branch already refuses by name — it does not get a side door. The read now distinguishes "genuinely not there yet" (skip) from "something went wrong" (fail), asking the TREE via `git ls-tree` whether the path exists rather than inferring absence from a failed `git show` — in a blobless clone the ref and trees resolve while the blob does not, and that means "not fetched", not "not present". **`satisfies()` accepted a version REGRESSION.** `cur.major > base.major || cur.minor > base.minor` reads as "moved somehow": `1.0 → 0.11` passed it (0 > 1 false, 11 > 0 true), as did `2.0 → 1.9`. Concretely, post-1.0 someone typing `--set 0.11` out of pre-1.0 habit — or a bad merge resolving the manifest to an older value — would ship a release declaring a LOWER metamodel version than the one before it, with every port byte-matching the manifest so registry-conformance stays green too. Now a `(major, minor)` tuple compare. The existing "0.10 → 0.9 must not satisfy" case only covered a SAME-major regression, which is what made this look tested; the two cross-major cases and a forward `1.9 → 2.0` are pinned now. **The pre-marker skip was keyed on the wrong marker.** It triggered when the manifest FILE was absent, but the file predates the `metamodelVersion` key (PR #145) — so `--against v0.15.0`, a documented flag on a real tag, died with `metamodelVersion must be "<major>.<minor>" (got undefined)`. It now skips on the KEY's absence and says so. **`--against --explain` silently became a ref named "--explain"**, which resolved to nothing and (before the first fix) passed. A flag is no longer swallowed as a value. **Stale prose that `--set` re-stales every bump.** `registry-manifest.ts`'s JSDoc said the value is `"0.9"` directly above the line this PR set to `"0.10"`; RELEASING.md, ADR-0035 Am. 2 and the design doc each restated it too. `--set` rewrites declarations, not sentences, so every copy in prose goes stale on the next bump. Dropped the literal from all four rather than adding prose sites to `SITES` — matching a sentence with a regex is the fragile half of that choice, and the value already lives authoritatively one line below. The JSDoc now says why, so it does not come back. Simplifier pass (behaviour-preserving): cached `Boolean(isArray)` and `min ?? 0`, each computed twice per side, matching the `bv`/`cv` and `bMax`/`cMax` caching already in those same functions; replaced the file's only nested ternary. It also caught a miscount in my header comment ("6 sites" / "five port constants" against a 5-entry `SITES`) — the runtime output was always right. Every finding re-probed closed: bad ref → exit 1, flag-as-value → exit 1, `v0.15.0` → skip with a clear reason, `1.0 → 0.11` and `2.0 → 1.9` → false. 43 classifier checks (was 40), `ci-local --only gates` green, TS build + typecheck clean, metadata 2406 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
dmealing
added a commit
that referenced
this pull request
Aug 21, 2026
…9 → 0.10 move ADR-0052's entry had been parked under `[Unreleased]` to avoid pre-answering "which release carries the break". That placement does not hold anything back: the code merged to `main` in #318, so whatever cuts next from `main` ships it either way. Parked, the only thing it achieved was a `0.24.0` section that omits a breaking change present in its own tarball. So `0.24.0` says what it ships. Its summary previously justified MINOR by two DEFAULT FLIPS alone; it is the pre-1.0 breaking slot, and now reads that way — with the `⚠️ BREAKING FOR METADATA AUTHORS` callout `0.21.0` established, naming the four adopter-visible consequences and pointing at the migration guide. It also folds in the `metamodelVersion` `0.9` → `0.10` move and its gate (#322), placed after the ADR-0052 section it is caused by rather than before it. Per ADR-0035 Amendment 2 a release that moves that number must say so — and this is the first release in the project's history that has one to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
PR #321 made
metamodelVersionthe METADATA-compatibility axis — a breaking metamodelchange moves ITS major, not the package major. Then I checked whether that number had ever
moved.
It read
"0.9"from the day it shipped (PR #145, 2026-07-02) through 57 releases —including
0.21.0, the deliberate pre-1.0 breaking slot that retired assembly origins fromobject.valueand shrank@role, and0.22.0, which added a whole registered type family.It was a label, not a version. #321 handed the compatibility promise to a number nobody was
maintaining. This is the missing half.
The gate
scripts/check-metamodel-version.mjs, registered inci-local.sh'sgateslane (solocal-ci.ymlenforces it on every push to main).It diffs
expected-registry.json— already the byte-exact bill of materials every port isgated against, so no new artifact — against its content at the last release tag,
classifies every difference, and fails if the declared version did not move by at least
that much. Same shape as
buf breaking --against '.git#tag=…'/oasdiff: compare to thereleased baseline, classify, require the declared version to match.
Baseline is a release tag, not
HEAD~1, deliberately: the version promises against whatadopters actually have, and a per-commit baseline would demand a bump from every PR in a
release cycle rather than the first one.
minraised ormaxlowered · default subtype changeddescription/rules/whenToUse)Pre-1.0 a breaking change moves the MINOR, for the same reason the package line works
that way at
0.x:0.ymakes no compatibility claim there is anything to break. At1.0the major becomes real. (This is the one judgement call I made inside the ratified policy —
easy to overrule, it's one function.)
It caught a real one on its first run
Against
v0.23.2:That is ADR-0052, merged yesterday. Hence
metamodelVersion0.9 → 0.10 in this PR,written by the gate's own
--set, which updates the manifest and all four port constants atonce (Kotlin emits through the JVM's).
That a partial edit is caught by
registry-conformanceis verified, not assumed —reverting the TS constant alone turns
registry-conformance.test.tsred with "TS registrydrifted from the committed manifest."
A missing baseline FAILS
This repo has 90 release tags; the only way to see zero is a checkout that did not fetch them
(
fetch-depth: 1), and a baseline-less run would pass unconditionally — a green tick thatchecked nothing. So it fails loud and names the likely cause.
--allow-no-baselineopts agenuinely tagless checkout out. (
local-ci.yml'sgatesjob already usesfetch-depth: 0.)The blind spot, stated rather than hidden
A rule can change with no machine-readable footprint. #210 is the proof: retiring
assembly origins from
object.valuewas a breaking metamodel change whose only manifestedit was the
rulesPROSE string. The loader enforced the new rule; the structuredvocabulary was untouched.
So prose changes are reported as a warning with a direct question — did the RULE
change, or only its wording? — not classified. A typo fix and a semantics change are
indistinguishable there, and failing on every wording edit would train people to ignore the
gate. Answering it is a human step in every release, and the gate asks every time rather
than pretending it covered it.
Tests for the classifier itself
scripts/test-metamodel-version.mjs— 40 checks driving all 15 rules in both directionsagainst synthetic manifests, following the
test-publish-set.mjspattern. The gate itselfcan only ever prove the happy path once it is green, so a rule silently backwards (a
maxcomparison inverted, a narrowing read as a widening) would otherwise ship as a gate that
passes breaking changes. Includes
0.9 → 0.10satisfying numerically where it regresseslexically — the two-digit-minor trap, and the exact step this gate first demanded.
Prompts + docs
releasingskill — Phase-0 preflight row, plus a "two numbers, two contracts" sectiondocs/RELEASING.md— classification table, the release checklist for a bump, and whatthe gate cannot see
--setagent-context/templates/always-on.md.mustache— adopter-facing: which number tellsyou your metadata needs work vs your build (5 byte-gated fixture sets regenerated)
Verification
With the bump in place: TS 6573 pass / 38 skip / 0 fail · C# 291 + 53 + 942 + 363
(1 skip), 0 fail · Java + Kotlin reactor BUILD SUCCESS · Python 1825 pass / 0 fail ·
ci-local.sh --only gatesgreen.🤖 Generated with Claude Code
https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15