Skip to content

feat(gates): metamodelVersion must move when the metamodel does — 0.9 → 0.10 - #322

Merged
dmealing merged 2 commits into
mainfrom
chore/metamodel-version-gate
Aug 21, 2026
Merged

feat(gates): metamodelVersion must move when the metamodel does — 0.9 → 0.10#322
dmealing merged 2 commits into
mainfrom
chore/metamodel-version-gate

Conversation

@dmealing

Copy link
Copy Markdown
Member

Why

PR #321 made metamodelVersion the METADATA-compatibility axis — a breaking metamodel
change 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 from
object.value and shrank @role, and 0.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 in ci-local.sh's gates lane (so
local-ci.yml enforces it on every push to main).

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

Baseline is a release tag, not HEAD~1, deliberately: the version promises against what
adopters actually have, and a per-commit baseline would demand a bump from every PR in a
release cycle rather than the first one.

Change Required move
type/subtype removed · attr removed, made required, retyped or re-arrayed · enum member removed or an open attr closed · child rule removed, min raised or max lowered · default subtype changed major
type/subtype added · optional attr added · enum member added · child rule added or relaxed · default subtype added minor
prose only (description / rules / whenToUse) none — but read the warning

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. At 1.0
the 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:

BREAKING (1):
  - attr removed: template.output @promptStyle
ADDITIVE (2):
  - attr added: template.prompt @promptStyle
  - attr added: template.prompt @responseFormat

declared: 0.9 (unchanged since v0.23.2)
required: a MINOR bump

That is ADR-0052, merged yesterday. Hence metamodelVersion 0.9 → 0.10 in this PR,
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 a partial edit is caught by registry-conformance is verified, not assumed
reverting the TS constant alone turns registry-conformance.test.ts red with "TS registry
drifted 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 that
checked nothing. So it fails loud and names the likely cause. --allow-no-baseline opts a
genuinely tagless checkout out. (local-ci.yml's gates job already uses fetch-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.value was a breaking metamodel change whose only manifest
edit was the rules PROSE string. The loader enforced the new rule; the structured
vocabulary was untouched.

So prose changes are reported as a warning with a direct questiondid 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 directions
against synthetic manifests, following the test-publish-set.mjs pattern. The gate itself
can only ever prove the happy path once it is 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. Includes 0.9 → 0.10 satisfying numerically where it regresses
lexically — the two-digit-minor trap, and the exact step this gate first demanded.

Prompts + docs

  • releasing skill — Phase-0 preflight row, plus a "two numbers, two contracts" section
  • docs/RELEASING.md — classification table, the release checklist for a bump, and what
    the gate cannot see
  • AGENTS.md / CLAUDE.md — any vocabulary change is also a version edit; use --set
  • agent-context/templates/always-on.md.mustache — adopter-facing: which number tells
    you your metadata needs work vs your build (5 byte-gated fixture sets regenerated)
  • The design doc + ADR-0035 Am. 2 gain the enforcement half they were missing
  • CHANGELOG — the version move, per the rule this PR writes down

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 gates green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15

dmealing and others added 2 commits August 20, 2026 21:15
… → 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
dmealing merged commit 485eed5 into main Aug 21, 2026
1 check passed
@dmealing
dmealing deleted the chore/metamodel-version-gate branch August 21, 2026 03:49
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant