From 1819b85066f2c2762fd76e350f9da8f61d143d48 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 20 Aug 2026 21:15:50 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(gates):=20metamodelVersion=20must=20mo?= =?UTF-8?q?ve=20when=20the=20metamodel=20does=20=E2=80=94=200.9=20?= =?UTF-8?q?=E2=86=92=200.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15 --- .claude/skills/releasing/SKILL.md | 18 + AGENTS.md | 15 + CHANGELOG.md | 32 ++ agent-context/templates/always-on.md.mustache | 2 + docs/RELEASING.md | 34 +- ...6-08-20-two-contracts-versioning-design.md | 53 +++ .../expected/.metaobjects/AGENTS.md | 2 + .../expected/.metaobjects/CLAUDE.md | 2 + .../expected/.metaobjects/AGENTS.md | 2 + .../expected/.metaobjects/CLAUDE.md | 2 + .../python/expected/.metaobjects/AGENTS.md | 2 + .../python/expected/.metaobjects/CLAUDE.md | 2 + .../expected/.metaobjects/AGENTS.md | 2 + .../expected/.metaobjects/CLAUDE.md | 2 + .../expected/.metaobjects/AGENTS.md | 2 + .../expected/.metaobjects/CLAUDE.md | 2 + .../expected-registry.json | 2 +- scripts/check-metamodel-version.mjs | 412 ++++++++++++++++++ scripts/ci-local.sh | 12 + scripts/test-metamodel-version.mjs | 306 +++++++++++++ server/csharp/MetaObjects/RegistryManifest.cs | 2 +- .../registry/RegistryManifest.java | 2 +- .../src/metaobjects/registry_manifest.py | 2 +- .../metadata/src/registry-manifest.ts | 2 +- ...lity-commitment-and-version-unification.md | 10 + 25 files changed, 917 insertions(+), 7 deletions(-) create mode 100755 scripts/check-metamodel-version.mjs create mode 100755 scripts/test-metamodel-version.mjs diff --git a/.claude/skills/releasing/SKILL.md b/.claude/skills/releasing/SKILL.md index 082988f9e..7063b7517 100644 --- a/.claude/skills/releasing/SKILL.md +++ b/.claude/skills/releasing/SKILL.md @@ -39,6 +39,7 @@ Do not bump or publish anything until every check is green. Print each result. | Published vs local version | `npm view @metaobjectsdev/cli dist-tags.latest`; compare to `package.json` | you know current `latest` and the target bump | | **Target version is free** | `npm view @metaobjectsdev/cli@ version` (a 404 = free); `git tag -l v` | the target version is NOT already published or tagged (npm versions are permanent — a taken version can never be reused) | | CHANGELOG ready | `sed -n '1,20p' CHANGELOG.md` | an entry exists or you will add one (see Phase 8) | +| **`metamodelVersion` moved if the metamodel did** | `node scripts/check-metamodel-version.mjs` | exit 0. It diffs `expected-registry.json` against the last release tag and fails if the vocabulary changed without the version moving. Read its PROSE warning too — a rule can change with no machine-readable footprint (#210 changed only a `rules` string). Fix with `--set `; detail with `--explain`. | If the code-review check fails (no PR, or unmerged/dirty tree): **stop** — releases ship reviewed, merged `main`, never a working tree. @@ -51,6 +52,23 @@ in-workspace, so the test passes; only a clean external install fails), the stal you nothing about whether the *published artifact* installs. That is what the RC + external smoke test (Phases 4–5) exist to prove. Do not let urgency skip them. +### Two numbers, two contracts + +**The package version and `metamodelVersion` answer different questions, and a release +may move either, both, or neither** (ADR-0035 Amendment 2): + +- **package version** — did the SOFTWARE surface change? (exports, CLI flags, + generated-code shape). This is what you publish. +- **`metamodelVersion`** — did the METADATA contract change? (registered vocabulary, + canonical/interchange format, wire contract). A breaking metamodel change moves ITS + major and **does not** force a package major. + +Post-1.0 the caret rule no longer gates the metadata axis (`^1.0.0` accepts `1.1.0`), so +**a release that moves `metamodelVersion` must say so in the CHANGELOG** — that line is +the adopter's only signal. Bump with `node scripts/check-metamodel-version.mjs --set +`, which writes all five declaring sites at once; a port left behind fails +`registry-conformance`, but only in that port's lane. + ## Phase 1 — Decide scope + version The lockstep set = every package currently at the previous version (it is NOT a diff --git a/AGENTS.md b/AGENTS.md index 95053a144..78b856a18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -499,6 +499,21 @@ Preserve the following contracts exactly across all language ports: - TS: named constants in `packages/metadata/src/constants.ts`. Never inline metamodel strings as literals in code. - New type or subtype names: add to TS constants first; add the parallel in other language implementations. +**Two contracts, two numbers — `metamodelVersion` moves when the metamodel does (ADR-0035 Amendment 2).** +The package version promises the SOFTWARE surface (exports, CLI flags, generated-code shape); +**`metamodelVersion`** promises the METADATA contract (registered vocabulary, canonical/interchange +format, wire contract). A breaking metamodel change moves `metamodelVersion`'s major and does **not** +force a package major. So **any change to the registered vocabulary is also a version edit** — bump it +with `node scripts/check-metamodel-version.mjs --set `, which writes the manifest and all four +port constants at once (Kotlin emits through the JVM's; a partial edit only fails in the forgotten +port's lane). The gate `node scripts/check-metamodel-version.mjs` runs in `ci-local.sh`'s `gates` lane: +it diffs `expected-registry.json` against the last release tag, classifies, and fails if the version +did not move enough. **Pre-1.0 a breaking change moves the MINOR**, as the package line does at `0.x`. +Its one blind spot is stated, not hidden — a rule can change with no machine-readable footprint (#210's +only manifest edit was a `rules` prose string), so prose changes prompt a question rather than being +classified, and answering it is a human step. Post-1.0 the caret rule no longer gates the metadata +axis, so **a release that moves `metamodelVersion` must say so in the CHANGELOG.** + ## Design judgment (durable principles) These are the load-bearing principles that have emerged through implementation. Apply them every time. diff --git a/CHANGELOG.md b/CHANGELOG.md index c9fa241e5..6cb2ac89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Metamodel version — `metamodelVersion` moves to `0.10`, and is now gated + +**Metamodel version: `0.9` → `0.10`.** ADR-0035 Amendment 2 made `metamodelVersion` the +METADATA-compatibility axis — a breaking metamodel change moves ITS major, not the package +major. The ADR-0052 work below is exactly such a change (`@promptStyle` is retired from +`template.output`), so the number moves with it. + +It is the first time it ever has. `metamodelVersion` 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`. The +amendment handed the compatibility promise to a number nobody was maintaining. + +So it now has a gate. **`node 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 — against its content at the last release tag, classifies each +difference, and fails if the declared version did not move by at least as much. Removal +and narrowing are breaking; addition and relaxation are additive; **pre-1.0 a breaking +change moves the minor**, as the package line does at `0.x`. `--set ` writes the +manifest and all four port constants in one go; `--explain` prints the classified diff. + +**Its blind spot is stated rather than 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 prose changes (`description` / `rules` / +`whenToUse`) are reported as a warning asking *did the rule change, or only its wording?* +rather than classified, because 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. + +Adopter-facing: `metamodelVersion` tells you whether **your metadata** needs work; the +package version tells you whether **your build** does. A release may move either, both or +neither — so read the changelog for a metamodel move, not just the package number. + ### BREAKING — a template subtype's axis is DIRECTION (ADR-0052 / ADR-0053) `template.output` renders OUTBOUND — a document, an email, an export — and generates diff --git a/agent-context/templates/always-on.md.mustache b/agent-context/templates/always-on.md.mustache index c2c61a91d..e7d3bfae7 100644 --- a/agent-context/templates/always-on.md.mustache +++ b/agent-context/templates/always-on.md.mustache @@ -26,6 +26,8 @@ spine; generated code is the disposable artifact. Regenerate with `{{codegenComm ## Keep all MetaObjects ports in sync MetaObjects ships as separate packages per language on DIFFERENT version lines (npm/PyPI/NuGet `0.x`/`1.x`, Maven Central `7.x`/`8.x`). Because the numbers differ by ecosystem, a stale port is INVISIBLE — an old TS client next to a new Java backend *looks* fine. Ports are only truly in sync when every one implements the same **Metamodel spec version** (`metamodelVersion`, on the registry manifest). Upgrade ALL ports together and confirm they land on the same Metamodel version; a lagging port silently disagrees on vocabulary + wire behavior. +`metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. + ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the matching `metaobjects-*` skill — its body links the `references/.md` fragment diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 601f04ac5..134271e07 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -187,16 +187,46 @@ dragged npm to `2.0.0` and Maven to `9.0.0`, so the package majors became a runn of metamodel edits. Measured cadence at the time of the amendment: **19 minor lines in 87 days**. +**The gate: `node scripts/check-metamodel-version.mjs`** (runs in the `gates` lane, so +`scripts/ci-local.sh` and hosted CI both enforce it). It diffs +`expected-registry.json` — already the byte-exact bill of materials every port is gated +against — against its content at the **last release tag**, classifies every difference, +and fails if the declared version did not move by at least the amount the change +requires. Same shape as `buf breaking --against '.git#tag=…'` / `oasdiff`. + +| Change | Required move | +|---|---| +| a type/subtype removed; an attr removed; an attr made required, retyped or re-arrayed; an enum member removed or an open attr closed; a child rule removed, its `min` raised or its `max` lowered; a default subtype changed | **major** (pre-1.0: minor — see below) | +| a type/subtype added; an optional attr added; an enum member added; a child rule added or relaxed; a 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 while it is `0.x`: `0.y` makes no compatibility claim there is anything to +break. At `1.0` the major becomes real. + +**What the gate cannot see, and says so.** 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 a `rules` PROSE string. So prose +changes are reported as a WARNING with a direct question — *did the rule change, or only +its wording?* — rather than classified, because a typo fix and a semantics change are +indistinguishable here and failing on every wording edit trains people to ignore the +gate. **Answering that question is a human step in every release.** + **When you cut a release that moves `metamodelVersion`:** -1. Bump `METAMODEL_VERSION` in all five ports (it is byte-gated — `registry-conformance` - fails until every port agrees) and regenerate `expected-registry.json`. +1. `node scripts/check-metamodel-version.mjs --set ` — it writes the manifest + **and all four port constants** in one go (Kotlin emits through the JVM's). A partial + edit is caught by `registry-conformance`, but only in the lane for the port you + forgot, so do not hand-edit. Then re-run the corpus in every port. 2. The changelog entry MUST say the metamodel version moved, and to what. Post-1.0 the caret rule is no longer a gate (`^1.0.0` accepts `1.1.0`), so **the changelog is the adopter's only signal** until the deferred loader check exists. 3. Ship a migration guide under `docs/features/migrations/`, as every breaking metamodel change already does. +`--explain` prints the full classified diff and always exits 0; `--against ` picks a +different baseline. + Design + deferral triggers: [`docs/superpowers/specs/2026-08-20-two-contracts-versioning-design.md`](superpowers/specs/2026-08-20-two-contracts-versioning-design.md). diff --git a/docs/superpowers/specs/2026-08-20-two-contracts-versioning-design.md b/docs/superpowers/specs/2026-08-20-two-contracts-versioning-design.md index 9f5257acc..408ce8581 100644 --- a/docs/superpowers/specs/2026-08-20-two-contracts-versioning-design.md +++ b/docs/superpowers/specs/2026-08-20-two-contracts-versioning-design.md @@ -99,6 +99,59 @@ real work, gating a hazard that six reachable adopters do not have. | **Per-item `experimental` / `stable` markers** (the Kubernetes model) | a member needs to ship for feedback without entering the frozen set — today the reserved-not-registered treatment (ADR-0007 Am. 2, ADR-0040) already covers this | | **Editions** (the Rust model) — a per-model opt-in that pins old semantics | two metamodel majors coexist in one estate and pinning per model beats upgrading per repo | +## Enforcement — the number has to actually move + +**Added 2026-08-21, after measuring.** The decision above hands the compatibility promise +to `metamodelVersion`. It was worth checking whether that number had ever moved: it has +read `"0.9"` since it shipped in PR #145 on 2026-07-02 and stayed there across **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. Today it is a label, not a version. A promise carried by a number +nobody maintains is not a promise. + +So the amendment ships with the gate it implies: +**`scripts/check-metamodel-version.mjs`**, registered in `ci-local.sh`'s `gates` lane. + +- **Baseline: the last release tag.** 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. This is the `buf breaking --against '.git#tag=…'` / + `oasdiff` shape: compare the artifact to its released baseline, classify, require the + declared version to match. +- **Subject: `expected-registry.json`**, which is already the byte-exact bill of + materials every port is gated against. No new artifact. +- **Classification is structural** — types, attrs (`required` / `valueType` / `isArray` / + `allowedValues`), child rules (`min` / `max`), default subtypes. Removal and narrowing + are breaking; addition and relaxation are additive. +- **Pre-1.0 a breaking change moves the MINOR**, for the same reason the package line + does while it is `0.x`. +- **`--set ` writes all five declaring sites at once** (the manifest plus four + port constants; Kotlin emits through the JVM's). A partial edit is caught by + `registry-conformance` — verified by reverting one port's constant and watching it + fail — but only in that port's lane, so the ergonomic path avoids the hazard entirely. +- **A missing baseline FAILS.** This repository 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. + +### The blind spot, stated + +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 (`description` / `rules` / `whenToUse`) 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 that question is a human step in every +release, and the gate says so each time rather than pretending it covered it. + +### What it caught immediately + +Run against `v0.23.2` on the first commit after ADR-0052 merged, it failed: `@promptStyle` +removed from `template.output` (breaking), `@promptStyle` + `@responseFormat` added to +`template.prompt` (additive) — with `metamodelVersion` still `"0.9"`. The version moved to +`"0.10"` as part of adding the gate. + ## Prior art - **OpenTelemetry** — spec version (1.5x) is the coordinating contract; each language SDK diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md index 88d99ea6f..184618352 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md @@ -26,6 +26,8 @@ spine; generated code is the disposable artifact. Regenerate with `mvn metaobjec ## Keep all MetaObjects ports in sync MetaObjects ships as separate packages per language on DIFFERENT version lines (npm/PyPI/NuGet `0.x`/`1.x`, Maven Central `7.x`/`8.x`). Because the numbers differ by ecosystem, a stale port is INVISIBLE — an old TS client next to a new Java backend *looks* fine. Ports are only truly in sync when every one implements the same **Metamodel spec version** (`metamodelVersion`, on the registry manifest). Upgrade ALL ports together and confirm they land on the same Metamodel version; a lagging port silently disagrees on vocabulary + wire behavior. +`metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. + ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the matching `metaobjects-*` skill — its body links the `references/.md` fragment diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md index 88d99ea6f..184618352 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md @@ -26,6 +26,8 @@ spine; generated code is the disposable artifact. Regenerate with `mvn metaobjec ## Keep all MetaObjects ports in sync MetaObjects ships as separate packages per language on DIFFERENT version lines (npm/PyPI/NuGet `0.x`/`1.x`, Maven Central `7.x`/`8.x`). Because the numbers differ by ecosystem, a stale port is INVISIBLE — an old TS client next to a new Java backend *looks* fine. Ports are only truly in sync when every one implements the same **Metamodel spec version** (`metamodelVersion`, on the registry manifest). Upgrade ALL ports together and confirm they land on the same Metamodel version; a lagging port silently disagrees on vocabulary + wire behavior. +`metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. + ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the matching `metaobjects-*` skill — its body links the `references/.md` fragment diff --git a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md index b7c2afb7d..10040b20a 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md @@ -26,6 +26,8 @@ spine; generated code is the disposable artifact. Regenerate with `mvn metaobjec ## Keep all MetaObjects ports in sync MetaObjects ships as separate packages per language on DIFFERENT version lines (npm/PyPI/NuGet `0.x`/`1.x`, Maven Central `7.x`/`8.x`). Because the numbers differ by ecosystem, a stale port is INVISIBLE — an old TS client next to a new Java backend *looks* fine. Ports are only truly in sync when every one implements the same **Metamodel spec version** (`metamodelVersion`, on the registry manifest). Upgrade ALL ports together and confirm they land on the same Metamodel version; a lagging port silently disagrees on vocabulary + wire behavior. +`metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. + ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the matching `metaobjects-*` skill — its body links the `references/.md` fragment diff --git a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md index b7c2afb7d..10040b20a 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md @@ -26,6 +26,8 @@ spine; generated code is the disposable artifact. Regenerate with `mvn metaobjec ## Keep all MetaObjects ports in sync MetaObjects ships as separate packages per language on DIFFERENT version lines (npm/PyPI/NuGet `0.x`/`1.x`, Maven Central `7.x`/`8.x`). Because the numbers differ by ecosystem, a stale port is INVISIBLE — an old TS client next to a new Java backend *looks* fine. Ports are only truly in sync when every one implements the same **Metamodel spec version** (`metamodelVersion`, on the registry manifest). Upgrade ALL ports together and confirm they land on the same Metamodel version; a lagging port silently disagrees on vocabulary + wire behavior. +`metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. + ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the matching `metaobjects-*` skill — its body links the `references/.md` fragment diff --git a/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md index 2fcd9bd77..8e2f5559b 100644 --- a/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md @@ -26,6 +26,8 @@ spine; generated code is the disposable artifact. Regenerate with `metaobjects g ## Keep all MetaObjects ports in sync MetaObjects ships as separate packages per language on DIFFERENT version lines (npm/PyPI/NuGet `0.x`/`1.x`, Maven Central `7.x`/`8.x`). Because the numbers differ by ecosystem, a stale port is INVISIBLE — an old TS client next to a new Java backend *looks* fine. Ports are only truly in sync when every one implements the same **Metamodel spec version** (`metamodelVersion`, on the registry manifest). Upgrade ALL ports together and confirm they land on the same Metamodel version; a lagging port silently disagrees on vocabulary + wire behavior. +`metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. + ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the matching `metaobjects-*` skill — its body links the `references/.md` fragment diff --git a/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md index 2fcd9bd77..8e2f5559b 100644 --- a/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md @@ -26,6 +26,8 @@ spine; generated code is the disposable artifact. Regenerate with `metaobjects g ## Keep all MetaObjects ports in sync MetaObjects ships as separate packages per language on DIFFERENT version lines (npm/PyPI/NuGet `0.x`/`1.x`, Maven Central `7.x`/`8.x`). Because the numbers differ by ecosystem, a stale port is INVISIBLE — an old TS client next to a new Java backend *looks* fine. Ports are only truly in sync when every one implements the same **Metamodel spec version** (`metamodelVersion`, on the registry manifest). Upgrade ALL ports together and confirm they land on the same Metamodel version; a lagging port silently disagrees on vocabulary + wire behavior. +`metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. + ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the matching `metaobjects-*` skill — its body links the `references/.md` fragment diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md index 9503c3dba..a900f92b8 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md @@ -26,6 +26,8 @@ spine; generated code is the disposable artifact. Regenerate with `npx meta gen` ## Keep all MetaObjects ports in sync MetaObjects ships as separate packages per language on DIFFERENT version lines (npm/PyPI/NuGet `0.x`/`1.x`, Maven Central `7.x`/`8.x`). Because the numbers differ by ecosystem, a stale port is INVISIBLE — an old TS client next to a new Java backend *looks* fine. Ports are only truly in sync when every one implements the same **Metamodel spec version** (`metamodelVersion`, on the registry manifest). Upgrade ALL ports together and confirm they land on the same Metamodel version; a lagging port silently disagrees on vocabulary + wire behavior. +`metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. + ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the matching `metaobjects-*` skill — its body links the `references/.md` fragment diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md index 9503c3dba..a900f92b8 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md @@ -26,6 +26,8 @@ spine; generated code is the disposable artifact. Regenerate with `npx meta gen` ## Keep all MetaObjects ports in sync MetaObjects ships as separate packages per language on DIFFERENT version lines (npm/PyPI/NuGet `0.x`/`1.x`, Maven Central `7.x`/`8.x`). Because the numbers differ by ecosystem, a stale port is INVISIBLE — an old TS client next to a new Java backend *looks* fine. Ports are only truly in sync when every one implements the same **Metamodel spec version** (`metamodelVersion`, on the registry manifest). Upgrade ALL ports together and confirm they land on the same Metamodel version; a lagging port silently disagrees on vocabulary + wire behavior. +`metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. + ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the matching `metaobjects-*` skill — its body links the `references/.md` fragment diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/AGENTS.md index 69c0abf65..f387b287c 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/AGENTS.md @@ -26,6 +26,8 @@ spine; generated code is the disposable artifact. Regenerate with `npx meta gen` ## Keep all MetaObjects ports in sync MetaObjects ships as separate packages per language on DIFFERENT version lines (npm/PyPI/NuGet `0.x`/`1.x`, Maven Central `7.x`/`8.x`). Because the numbers differ by ecosystem, a stale port is INVISIBLE — an old TS client next to a new Java backend *looks* fine. Ports are only truly in sync when every one implements the same **Metamodel spec version** (`metamodelVersion`, on the registry manifest). Upgrade ALL ports together and confirm they land on the same Metamodel version; a lagging port silently disagrees on vocabulary + wire behavior. +`metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. + ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the matching `metaobjects-*` skill — its body links the `references/.md` fragment diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/CLAUDE.md index 69c0abf65..f387b287c 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/CLAUDE.md @@ -26,6 +26,8 @@ spine; generated code is the disposable artifact. Regenerate with `npx meta gen` ## Keep all MetaObjects ports in sync MetaObjects ships as separate packages per language on DIFFERENT version lines (npm/PyPI/NuGet `0.x`/`1.x`, Maven Central `7.x`/`8.x`). Because the numbers differ by ecosystem, a stale port is INVISIBLE — an old TS client next to a new Java backend *looks* fine. Ports are only truly in sync when every one implements the same **Metamodel spec version** (`metamodelVersion`, on the registry manifest). Upgrade ALL ports together and confirm they land on the same Metamodel version; a lagging port silently disagrees on vocabulary + wire behavior. +`metamodelVersion` is also the number that tells you whether YOUR METADATA needs work, separately from the package version, which tells you whether your BUILD does. A package major means imports/CLI/generated-code shape may need attention; a metamodel major means your model may. A release can move one without the other — so when you upgrade, read the changelog for a metamodel-version move, not just the package number. + ## Going deeper (Claude Code) For authoring, codegen, runtime/UI, prompts, verify, or adoption-audit work, use the matching `metaobjects-*` skill — its body links the `references/.md` fragment diff --git a/fixtures/registry-conformance/expected-registry.json b/fixtures/registry-conformance/expected-registry.json index a66190421..d5fedc96a 100644 --- a/fixtures/registry-conformance/expected-registry.json +++ b/fixtures/registry-conformance/expected-registry.json @@ -1,5 +1,5 @@ { - "metamodelVersion": "0.9", + "metamodelVersion": "0.10", "types": [ { "type": "attr", diff --git a/scripts/check-metamodel-version.mjs b/scripts/check-metamodel-version.mjs new file mode 100755 index 000000000..e4c67b844 --- /dev/null +++ b/scripts/check-metamodel-version.mjs @@ -0,0 +1,412 @@ +#!/usr/bin/env node +// scripts/check-metamodel-version.mjs +// +// GATE — `metamodelVersion` must move when the metamodel does. +// +// ADR-0035 Amendment 2 gives the project two contracts on two numbers: the package +// version promises the SOFTWARE surface, and `metamodelVersion` promises the METADATA +// contract (registered vocabulary + canonical/interchange format + wire contract). A +// breaking metamodel change moves `metamodelVersion`'s major and NOT the package major. +// +// That amendment made a number load-bearing that, measured, nobody had ever moved: +// `metamodelVersion` has read "0.9" since it shipped in PR #145 (2026-07-02) and stayed +// there across 57 releases — including `0.21.0`, which deliberately retired assembly +// origins from `object.value` and shrank `@role`. A promise carried by a number no one +// maintains is not a promise. This is the thing that would have caught that. +// +// WHAT IT DOES +// Diffs `fixtures/registry-conformance/expected-registry.json` — already the byte-exact +// bill of materials every port is gated against — against its content at the last +// published release tag, classifies each difference, and asserts the declared +// `metamodelVersion` moved by at least the amount the change requires. +// +// This is the `buf breaking --against '.git#tag=…'` / `oasdiff` shape: compare the +// artifact to its last released baseline, classify, then require the declared version +// to match the classification. The baseline is the last RELEASE TAG, not HEAD~1, +// because the version promises against what adopters actually have — and because a +// per-commit baseline would demand a bump from every PR in a release cycle rather than +// the first one. +// +// WHAT IT CANNOT SEE, stated because a gate that hides its blind spot is worse than none +// 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 ("…by assembly"). The loader enforced the +// new rule; the structured vocabulary was untouched. +// +// So prose changes (`description` / `rules` / `whenToUse`) are reported as a WARNING +// with a direct question rather than classified. A typo fix and a semantics change are +// indistinguishable here, and failing on every wording edit would train people to +// ignore the gate. +// +// USAGE +// node scripts/check-metamodel-version.mjs # gate (CI + ci-local `gates`) +// node scripts/check-metamodel-version.mjs --against v0.23.2 +// node scripts/check-metamodel-version.mjs --explain # classify + print, always exit 0 +// node scripts/check-metamodel-version.mjs --set 1.0 # write the version to all 6 sites +// +// See docs/RELEASING.md → "The two-contracts rule". + +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const MANIFEST = "fixtures/registry-conformance/expected-registry.json"; + +/** + * Every site that declares the metamodel version. The manifest is the one the + * conformance corpus byte-matches; the five port constants are what each port EMITS + * into its own manifest, so a partial edit is caught by `registry-conformance` — but + * only in the lane for the port you forgot. `--set` writes all of them at once so that + * failure mode does not need catching. (Kotlin has no constant of its own: it emits + * through the JVM `RegistryManifest`.) + */ +const SITES = [ + { path: MANIFEST, re: /("metamodelVersion":\s*")([^"]+)(")/ }, + { + path: "server/typescript/packages/metadata/src/registry-manifest.ts", + re: /(export const METAMODEL_VERSION = ")([^"]+)(")/, + }, + { + path: "server/python/src/metaobjects/registry_manifest.py", + re: /(^METAMODEL_VERSION = ")([^"]+)(")/m, + }, + { + path: "server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java", + re: /(public static final String METAMODEL_VERSION = ")([^"]+)(")/, + }, + { + path: "server/csharp/MetaObjects/RegistryManifest.cs", + re: /(public const string MetamodelVersion = ")([^"]+)(")/, + }, +]; + +// --------------------------------------------------------------------------- +// Version arithmetic. `metamodelVersion` is `major.minor` ("0.9", "1.0") — the +// `Metamodel N.M` form ADR-0035 §2 names. No patch component: a metamodel has no +// bug-fix axis, only "what is declarable" and "what it means". +// --------------------------------------------------------------------------- + +export function parseVersion(raw, where) { + const m = /^(\d+)\.(\d+)$/.exec(String(raw ?? "").trim()); + if (!m) { + fail(`${where}: metamodelVersion must be "." (got ${JSON.stringify(raw)})`); + } + return { major: Number(m[1]), minor: Number(m[2]), raw: String(raw) }; +} + +/** Pre-1.0 the number itself carries no promise, so a BREAKING change moves the MINOR — + * the same rule the package line follows while it is `0.x`, and the same reason: + * `0.y` makes no compatibility claim to break. At 1.0 the major becomes real. */ +const isPre1 = (v) => v.major === 0; + +export function requiredBump(severity, base) { + if (severity === "none") return "none"; + if (severity === "additive") return "minor"; + return isPre1(base) ? "minor" : "major"; // breaking +} + +export function satisfies(bump, base, cur) { + if (bump === "none") return true; + if (bump === "minor") return cur.major > base.major || cur.minor > base.minor; + return cur.major > base.major; // major +} + +// --------------------------------------------------------------------------- +// Classification. +// +// BREAKING — metadata that used to load may now fail, or mean something else. +// ADDITIVE — every previously-valid document still loads, identically. +// PROSE — human-readable only; the classifier declines to guess (see the header). +// --------------------------------------------------------------------------- + +const PROSE_KEYS = ["description", "rules", "whenToUse"]; + +const typeKey = (t) => `${t.type}.${t.subType}`; +const childKey = (c) => `${c.childType}.${c.childSubType}/${c.childName}`; +const byKey = (arr, key) => new Map((arr ?? []).map((x) => [key(x), x])); + +export function classify(base, cur) { + const breaking = []; + const additive = []; + const prose = []; + + // ---- types (a `type.subType` pair is a declarable vocabulary member) ---- + const bT = byKey(base.types, typeKey); + const cT = byKey(cur.types, typeKey); + for (const k of bT.keys()) { + if (!cT.has(k)) breaking.push(`type removed: ${k}`); + } + for (const k of cT.keys()) { + if (!bT.has(k)) additive.push(`type added: ${k}`); + } + + for (const [k, c] of cT) { + const b = bT.get(k); + if (!b) continue; + diffProse(b, c, k, prose); + diffAttrs(b.attrs, c.attrs, k, breaking, additive, prose); + diffChildren(b.children, c.children, k, breaking, additive); + } + + // ---- commonAttrs (registered on every node) ---- + diffAttrs(base.commonAttrs, cur.commonAttrs, "commonAttrs", breaking, additive, prose); + + // ---- defaultSubTypes (decides what an unqualified declaration MEANS) ---- + const bD = base.defaultSubTypes ?? {}; + const cD = cur.defaultSubTypes ?? {}; + for (const [t, sub] of Object.entries(bD)) { + if (!(t in cD)) breaking.push(`default subtype removed: ${t} (was ${sub})`); + else if (cD[t] !== sub) breaking.push(`default subtype changed: ${t} ${sub} → ${cD[t]}`); + } + for (const t of Object.keys(cD)) { + if (!(t in bD)) additive.push(`default subtype added: ${t} = ${cD[t]}`); + } + + return { breaking, additive, prose }; +} + +function diffProse(b, c, where, prose) { + for (const k of PROSE_KEYS) { + if ((b[k] ?? null) !== (c[k] ?? null)) prose.push(`${where}: ${k}`); + } +} + +function diffAttrs(baseAttrs, curAttrs, where, breaking, additive, prose) { + const b = byKey(baseAttrs, (a) => a.name); + const c = byKey(curAttrs, (a) => a.name); + + for (const name of b.keys()) { + if (!c.has(name)) breaking.push(`attr removed: ${where} @${name}`); + } + for (const [name, ca] of c) { + if (!b.has(name)) { + // A REQUIRED attr appearing where there was none convicts every existing + // document that omits it — additive in the registry, breaking in practice. + if (ca.required) breaking.push(`required attr added: ${where} @${name}`); + else additive.push(`attr added: ${where} @${name}`); + continue; + } + const ba = b.get(name); + const at = `${where} @${name}`; + + if (!ba.required && ca.required) breaking.push(`attr became required: ${at}`); + if (ba.required && !ca.required) additive.push(`attr became optional: ${at}`); + if (ba.valueType !== ca.valueType) { + breaking.push(`attr valueType changed: ${at} ${ba.valueType} → ${ca.valueType}`); + } + if (Boolean(ba.isArray) !== Boolean(ca.isArray)) { + breaking.push(`attr isArray changed: ${at} ${Boolean(ba.isArray)} → ${Boolean(ca.isArray)}`); + } + + // allowedValues: a closed set. Removing a member, or closing a previously-open + // attr, rejects a value that used to load. Adding one only permits more. + const bv = ba.allowedValues ?? null; + const cv = ca.allowedValues ?? null; + if (bv === null && cv !== null) breaking.push(`attr became a closed enum: ${at} [${cv}]`); + else if (bv !== null && cv === null) additive.push(`attr enum opened: ${at}`); + else if (bv && cv) { + const gone = bv.filter((v) => !cv.includes(v)); + const added = cv.filter((v) => !bv.includes(v)); + if (gone.length) breaking.push(`enum member removed: ${at} [${gone.join(", ")}]`); + if (added.length) additive.push(`enum member added: ${at} [${added.join(", ")}]`); + } + + diffProse(ba, ca, at, prose); + } +} + +function diffChildren(baseKids, curKids, where, breaking, additive) { + const b = byKey(baseKids, childKey); + const c = byKey(curKids, childKey); + + for (const k of b.keys()) { + if (!c.has(k)) breaking.push(`child rule removed: ${where} ← ${k}`); + } + for (const [k, ck] of c) { + if (!b.has(k)) { + // A child that must be present convicts every existing parent that lacks it. + if ((ck.min ?? 0) > 0) breaking.push(`required child rule added: ${where} ← ${k}`); + else additive.push(`child rule added: ${where} ← ${k}`); + continue; + } + const bk = b.get(k); + const at = `${where} ← ${k}`; + if ((ck.min ?? 0) > (bk.min ?? 0)) breaking.push(`child min raised: ${at}`); + if ((bk.min ?? 0) > (ck.min ?? 0)) additive.push(`child min lowered: ${at}`); + + // max === null means unbounded; lowering a bound (or introducing one) rejects a + // parent that already declares more children than the new cap. + const bMax = bk.max ?? Infinity; + const cMax = ck.max ?? Infinity; + if (cMax < bMax) breaking.push(`child max lowered: ${at} ${bk.max ?? "∞"} → ${ck.max ?? "∞"}`); + if (cMax > bMax) additive.push(`child max raised: ${at}`); + } +} + +// --------------------------------------------------------------------------- +// Git plumbing. +// --------------------------------------------------------------------------- + +const git = (...args) => + execFileSync("git", args, { cwd: REPO, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + +/** The highest published release tag on the npm/PyPI/NuGet line (`v0.*` / `v1.*` …). + * The four registries cut together, so any one line dates the metamodel baseline. */ +function lastReleaseTag() { + const tags = git("tag", "--list", "v[0-6].*", "--sort=-version:refname") + .split("\n") + .map((t) => t.trim()) + .filter(Boolean); + return tags[0] ?? null; +} + +function manifestAt(ref) { + try { + return JSON.parse(git("show", `${ref}:${MANIFEST}`)); + } catch { + return null; + } +} + +function fail(msg) { + process.stderr.write(`\n✗ metamodel-version: ${msg}\n\n`); + process.exit(1); +} + +const bullets = (list, cap = 12) => + list + .slice(0, cap) + .map((x) => ` - ${x}`) + .concat(list.length > cap ? [` … and ${list.length - cap} more`] : []) + .join("\n"); + +// --------------------------------------------------------------------------- +// --set: write the version to every declaring site at once. +// --------------------------------------------------------------------------- + +function setVersion(next) { + parseVersion(next, "--set"); + for (const site of SITES) { + const abs = resolve(REPO, site.path); + const src = readFileSync(abs, "utf8"); + if (!site.re.test(src)) fail(`--set: no metamodelVersion declaration found in ${site.path}`); + writeFileSync(abs, src.replace(site.re, `$1${next}$3`), "utf8"); + process.stdout.write(` updated ${site.path}\n`); + } + process.stdout.write( + `\n✓ metamodelVersion set to "${next}" in ${SITES.length} sites.\n` + + ` Re-run the registry-conformance corpus in every port — it byte-matches the\n` + + ` manifest, so a port left behind fails there.\n\n`, + ); +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +function main() { + const argv = process.argv.slice(2); + const arg = (flag) => { + const i = argv.indexOf(flag); + return i === -1 ? null : argv[i + 1]; + }; + + if (argv.includes("--set")) { + setVersion(arg("--set")); + process.exit(0); + } + + const explain = argv.includes("--explain"); + const against = arg("--against") ?? lastReleaseTag(); + + if (!against && argv.includes("--allow-no-baseline")) { + process.stdout.write(" metamodel-version: no release tag, --allow-no-baseline given — skipped.\n"); + process.exit(0); + } + + if (!against) { + // Fail LOUD, not open. This repository 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 of this gate passes unconditionally — a green tick that checked nothing. + fail( + "no release tag matched — the baseline is missing, so this gate would pass\n" + + " without comparing anything.\n\n" + + " Almost always a shallow checkout: use `fetch-depth: 0` (which fetches tags),\n" + + " or pass an explicit baseline with --against .\n" + + " A genuinely tagless checkout can opt out with --allow-no-baseline.", + ); + } + + const current = JSON.parse(readFileSync(resolve(REPO, MANIFEST), "utf8")); + const baseline = manifestAt(against); + + if (!baseline) { + process.stdout.write( + ` metamodel-version: ${MANIFEST} does not exist at ${against} — skipping (pre-marker tag).\n`, + ); + process.exit(0); + } + + const baseVer = parseVersion(baseline.metamodelVersion, against); + const curVer = parseVersion(current.metamodelVersion, "working tree"); + + const { breaking, additive, prose } = classify(baseline, current); + const severity = breaking.length ? "breaking" : additive.length ? "additive" : "none"; + const bump = requiredBump(severity, baseVer); + + if (explain) { + process.stdout.write(`metamodel-version: ${against} (${baseVer.raw}) → working tree (${curVer.raw})\n\n`); + if (breaking.length) process.stdout.write(` BREAKING (${breaking.length}):\n${bullets(breaking, 100)}\n\n`); + if (additive.length) process.stdout.write(` ADDITIVE (${additive.length}):\n${bullets(additive, 100)}\n\n`); + if (prose.length) process.stdout.write(` PROSE (${prose.length}, not classified):\n${bullets(prose, 100)}\n\n`); + if (!breaking.length && !additive.length && !prose.length) process.stdout.write(" no differences.\n\n"); + process.stdout.write(` required bump: ${bump}\n`); + process.exit(0); + } + + // ---- the prompt: prose the classifier declines to judge ---- + if (prose.length) { + process.stdout.write( + `\n ⚠ metamodel-version: ${prose.length} prose field(s) changed since ${against}:\n` + + `${bullets(prose)}\n\n` + + ` Did the RULE change, or only its wording? A rule can change with no\n` + + ` machine-readable footprint — #210 retired assembly origins from object.value\n` + + ` and its only manifest edit was a \`rules\` string. If the rule changed, this is\n` + + ` a metamodel ${isPre1(baseVer) ? "MINOR" : "MAJOR"}: run\n` + + ` node scripts/check-metamodel-version.mjs --set \n\n`, + ); + } + + if (satisfies(bump, baseVer, curVer)) { + const moved = curVer.raw !== baseVer.raw ? ` (${baseVer.raw} → ${curVer.raw})` : ""; + process.stdout.write( + ` metamodel-version: ${severity} change since ${against}; declared ${curVer.raw}${moved} — ok.\n`, + ); + process.exit(0); + } + + fail( + `the metamodel changed since ${against}, but metamodelVersion did not move.\n\n` + + (breaking.length ? ` BREAKING (${breaking.length}):\n${bullets(breaking)}\n\n` : "") + + (additive.length ? ` ADDITIVE (${additive.length}):\n${bullets(additive)}\n\n` : "") + + ` declared: ${curVer.raw} (unchanged since ${against})\n` + + ` required: a ${bump.toUpperCase()} bump` + + (bump === "minor" && isPre1(baseVer) + ? ` — pre-1.0 the metamodel major carries no promise, so a BREAKING change moves the minor,\n` + + ` exactly as the package line does while it is 0.x` + : "") + + `\n\n Fix: node scripts/check-metamodel-version.mjs --set ${ + bump === "major" ? `${baseVer.major + 1}.0` : `${baseVer.major}.${baseVer.minor + 1}` + }\n` + + ` Then say so in the CHANGELOG — post-1.0 the caret rule no longer gates the\n` + + ` metadata axis, so the changelog is the adopter's only signal (ADR-0035 Am. 2).\n` + + ` Detail: node scripts/check-metamodel-version.mjs --explain`, + ); + +} + +// Run only as a script — scripts/test-metamodel-version.mjs imports the pure +// classifier above and drives each rule against synthetic manifests. +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index c2bee4c90..7e9c9b5c7 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -161,6 +161,17 @@ gate_publish_set() { node scripts/publish-set.mjs --check && node scripts/test-p # pre-release version silently shrinks the set the next real release publishes. gate_no_prerelease_versions() { scripts/check-no-prerelease-versions.sh; } +# ── metamodelVersion must move when the metamodel does ──────────────────────── +# ADR-0035 Amendment 2 made `metamodelVersion` the METADATA-compatibility axis: a +# breaking metamodel change moves ITS major, not the package major. That handed the +# compat promise to a number nobody had ever moved — it read "0.9" from PR #145 +# (2026-07-02) through 57 releases, including 0.21.0, which deliberately retired +# assembly origins from object.value. This diffs expected-registry.json (already the +# byte-exact bill of materials every port is gated against) against its content at the +# last release tag, classifies each difference, and requires the declared version to +# have moved by at least that much. Offline; git + one JSON file. +gate_metamodel_version() { node scripts/check-metamodel-version.mjs && node scripts/test-metamodel-version.mjs; } + # ── peer ranges must have a finite upper bound ──────────────────────────────── # An open `>=` peer silently accepts a future breaking major. `@tanstack/react-table: # ">=8.20.0"` accepted v9 — a rewrite that deleted useReactTable/getCoreRowModel, both @@ -390,6 +401,7 @@ if want gates; then step "bun-version parity" gate_bun_version; if want gates; then step "publish-intent parity" gate_publish_intent; fi if want gates; then step "publish-set parity" gate_publish_set; fi if want gates; then step "no committed pre-release version" gate_no_prerelease_versions; fi +if want gates; then step "metamodel-version bump" gate_metamodel_version; fi if want gates; then step_if bun "peer-range bounds" gate_peer_ranges; fi if want gates; then step_if bun "fixture-lint" gate_fixture_lint; fi # The ts port is split into two lanes so CI can run them as separate jobs (see diff --git a/scripts/test-metamodel-version.mjs b/scripts/test-metamodel-version.mjs new file mode 100755 index 000000000..84b18966e --- /dev/null +++ b/scripts/test-metamodel-version.mjs @@ -0,0 +1,306 @@ +#!/usr/bin/env node +// Tests for scripts/check-metamodel-version.mjs — the classifier the metamodel-version +// gate reads. +// +// The gate itself can only ever prove the happy path against the real tree, because the +// real tree is (by design) always compliant once the gate is green. These synthetic cases +// drive EVERY classification rule in both directions, so a rule that is silently backwards +// — a `max` comparison inverted, an `allowedValues` narrowing read as a widening — shows +// up here instead of shipping as a gate that passes a breaking change. +// +// node scripts/test-metamodel-version.mjs + +import { classify, requiredBump, satisfies, parseVersion } from "./check-metamodel-version.mjs"; + +let fails = 0; +const ok = (m) => console.log(`ok: ${m}`); +const bad = (m) => { + console.error(`FAIL: ${m}`); + fails++; +}; + +// --------------------------------------------------------------------------- +// Manifest builders — the smallest shape the classifier reads. +// --------------------------------------------------------------------------- + +const attr = (name, over = {}) => ({ + name, + valueType: "string", + isArray: false, + required: false, + description: "d", + ...over, +}); + +const child = (childType, over = {}) => ({ + childType, + childSubType: "*", + childName: "*", + min: 0, + max: null, + ...over, +}); + +const type = (t, sub, over = {}) => ({ + type: t, + subType: sub, + description: "d", + attrs: [], + children: [], + ...over, +}); + +const manifest = (over = {}) => ({ + metamodelVersion: "1.0", + types: [type("field", "string")], + commonAttrs: [], + defaultSubTypes: { object: "entity" }, + ...over, +}); + +/** Assert that base→cur classifies into exactly the expected buckets. */ +function check(label, base, cur, expect) { + const got = classify(base, cur); + for (const bucket of ["breaking", "additive", "prose"]) { + const want = expect[bucket] ?? 0; + if (got[bucket].length !== want) { + bad( + `${label}: expected ${want} ${bucket}, got ${got[bucket].length}` + + (got[bucket].length ? ` → ${JSON.stringify(got[bucket])}` : ""), + ); + return; + } + } + ok(label); +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +check("no change classifies as nothing", manifest(), manifest(), {}); + +check( + "a removed subtype is BREAKING", + manifest({ types: [type("field", "string"), type("field", "uri")] }), + manifest({ types: [type("field", "string")] }), + { breaking: 1 }, +); + +check( + "an added subtype is ADDITIVE", + manifest({ types: [type("field", "string")] }), + manifest({ types: [type("field", "string"), type("field", "uri")] }), + { additive: 1 }, +); + +// --------------------------------------------------------------------------- +// Attributes +// --------------------------------------------------------------------------- + +const withAttrs = (...attrs) => manifest({ types: [type("field", "string", { attrs })] }); + +check("a removed attr is BREAKING", withAttrs(attr("a")), withAttrs(), { breaking: 1 }); +check("an added OPTIONAL attr is ADDITIVE", withAttrs(), withAttrs(attr("a")), { additive: 1 }); + +// An attr that arrives already-required convicts every document that omits it — the +// registry grew, but existing metadata stopped loading. +check( + "an added REQUIRED attr is BREAKING", + withAttrs(), + withAttrs(attr("a", { required: true })), + { breaking: 1 }, +); + +check( + "optional → required is BREAKING", + withAttrs(attr("a")), + withAttrs(attr("a", { required: true })), + { breaking: 1 }, +); +check( + "required → optional is ADDITIVE", + withAttrs(attr("a", { required: true })), + withAttrs(attr("a")), + { additive: 1 }, +); +check( + "a changed valueType is BREAKING", + withAttrs(attr("a")), + withAttrs(attr("a", { valueType: "int" })), + { breaking: 1 }, +); +check( + "a changed isArray is BREAKING", + withAttrs(attr("a")), + withAttrs(attr("a", { isArray: true })), + { breaking: 1 }, +); + +// --------------------------------------------------------------------------- +// allowedValues — the direction that is easiest to get backwards. +// --------------------------------------------------------------------------- + +check( + "an enum member REMOVED is BREAKING", + withAttrs(attr("a", { allowedValues: ["x", "y"] })), + withAttrs(attr("a", { allowedValues: ["x"] })), + { breaking: 1 }, +); +check( + "an enum member ADDED is ADDITIVE", + withAttrs(attr("a", { allowedValues: ["x"] })), + withAttrs(attr("a", { allowedValues: ["x", "y"] })), + { additive: 1 }, +); +check( + "an OPEN attr becoming a closed enum is BREAKING", + withAttrs(attr("a")), + withAttrs(attr("a", { allowedValues: ["x"] })), + { breaking: 1 }, +); +check( + "a closed enum OPENING is ADDITIVE", + withAttrs(attr("a", { allowedValues: ["x"] })), + withAttrs(attr("a")), + { additive: 1 }, +); + +// --------------------------------------------------------------------------- +// Child rules +// --------------------------------------------------------------------------- + +const withKids = (...children) => manifest({ types: [type("object", "entity", { children })] }); + +check("a removed child rule is BREAKING", withKids(child("field")), withKids(), { breaking: 1 }); +check("an added OPTIONAL child rule is ADDITIVE", withKids(), withKids(child("field")), { + additive: 1, +}); +check( + "an added MANDATORY child rule is BREAKING", + withKids(), + withKids(child("field", { min: 1 })), + { breaking: 1 }, +); +check( + "raising min is BREAKING", + withKids(child("field")), + withKids(child("field", { min: 1 })), + { breaking: 1 }, +); +check( + "lowering min is ADDITIVE", + withKids(child("field", { min: 1 })), + withKids(child("field")), + { additive: 1 }, +); +// max: null means unbounded, so introducing ANY cap narrows. +check( + "capping an unbounded max is BREAKING", + withKids(child("field")), + withKids(child("field", { max: 3 })), + { breaking: 1 }, +); +check( + "lifting a cap is ADDITIVE", + withKids(child("field", { max: 3 })), + withKids(child("field")), + { additive: 1 }, +); +check( + "lowering a finite max is BREAKING", + withKids(child("field", { max: 3 })), + withKids(child("field", { max: 2 })), + { breaking: 1 }, +); + +// --------------------------------------------------------------------------- +// commonAttrs + defaultSubTypes +// --------------------------------------------------------------------------- + +check( + "a removed commonAttr is BREAKING", + manifest({ commonAttrs: [attr("notes")] }), + manifest({ commonAttrs: [] }), + { breaking: 1 }, +); +check( + "a changed default subtype is BREAKING (it changes what an unqualified declaration MEANS)", + manifest({ defaultSubTypes: { object: "entity" } }), + manifest({ defaultSubTypes: { object: "value" } }), + { breaking: 1 }, +); +check( + "a removed default subtype is BREAKING", + manifest({ defaultSubTypes: { object: "entity" } }), + manifest({ defaultSubTypes: {} }), + { breaking: 1 }, +); +check( + "an added default subtype is ADDITIVE", + manifest({ defaultSubTypes: {} }), + manifest({ defaultSubTypes: { object: "entity" } }), + { additive: 1 }, +); + +// --------------------------------------------------------------------------- +// Prose — reported, never classified. This is the gate's stated blind spot. +// --------------------------------------------------------------------------- + +check( + "a type description change is PROSE ONLY", + manifest({ types: [type("field", "string", { description: "old" })] }), + manifest({ types: [type("field", "string", { description: "new" })] }), + { prose: 1 }, +); +check( + "a `rules` change is PROSE ONLY — this is exactly #210, and why prose only warns", + manifest({ types: [type("object", "value", { rules: "populated by assembly" })] }), + manifest({ types: [type("object", "value", { rules: "constructed, never assembled" })] }), + { prose: 1 }, +); +check( + "an attr description change is PROSE ONLY", + withAttrs(attr("a", { description: "old" })), + withAttrs(attr("a", { description: "new" })), + { prose: 1 }, +); + +// --------------------------------------------------------------------------- +// Required bump + satisfaction +// --------------------------------------------------------------------------- + +const v = (s) => parseVersion(s, "test"); + +const bump = (sev, base) => requiredBump(sev, v(base)); +if (bump("none", "1.0") !== "none") bad("no change requires no bump"); +else ok("no change requires no bump"); +if (bump("additive", "1.0") !== "minor") bad("additive requires a minor"); +else ok("additive requires a minor"); +if (bump("breaking", "1.0") !== "major") bad("post-1.0 breaking requires a major"); +else ok("post-1.0 breaking requires a major"); +// Pre-1.0 the major carries no promise, so a break moves the minor — the same rule the +// package line follows at 0.x, and the reason `0.21.0` was a legitimate breaking slot. +if (bump("breaking", "0.9") !== "minor") bad("pre-1.0 breaking requires a minor, not a major"); +else ok("pre-1.0 breaking requires a minor, not a major"); + +const sat = (b, base, cur) => satisfies(b, v(base), v(cur)); +if (sat("minor", "1.0", "1.0")) bad("an unmoved version must NOT satisfy a minor"); +else ok("an unmoved version must NOT satisfy a minor"); +if (!sat("minor", "1.0", "1.1")) bad("1.0 → 1.1 satisfies a minor"); +else ok("1.0 → 1.1 satisfies a minor"); +if (!sat("minor", "1.0", "2.0")) bad("a major over-satisfies a minor"); +else ok("a major over-satisfies a minor"); +if (sat("major", "1.0", "1.1")) bad("a minor must NOT satisfy a major"); +else ok("a minor must NOT satisfy a major"); +if (!sat("major", "1.0", "2.0")) bad("1.0 → 2.0 satisfies a major"); +else ok("1.0 → 2.0 satisfies a major"); +// "0.10" is numerically after "0.9" even though it sorts before it as a string — the +// classic two-digit-minor trap, and the exact step this gate first demanded. +if (!sat("minor", "0.9", "0.10")) bad("0.9 → 0.10 satisfies a minor (numeric, not lexical)"); +else ok("0.9 → 0.10 satisfies a minor (numeric, not lexical)"); +if (sat("minor", "0.10", "0.9")) bad("0.10 → 0.9 is a REGRESSION and must not satisfy"); +else ok("0.10 → 0.9 is a REGRESSION and must not satisfy"); + +console.log(fails === 0 ? "\nmetamodel-version classifier: all checks passed" : `\n${fails} failure(s)`); +process.exit(fails === 0 ? 0 : 1); diff --git a/server/csharp/MetaObjects/RegistryManifest.cs b/server/csharp/MetaObjects/RegistryManifest.cs index 5191eb7d6..cd88a105f 100644 --- a/server/csharp/MetaObjects/RegistryManifest.cs +++ b/server/csharp/MetaObjects/RegistryManifest.cs @@ -374,7 +374,7 @@ private static ManifestType ToManifestType(TypeDefinition def) => /// unstable (semver major-0). The 1.0 cut flips this to "1.0". /// Emitted as the FIRST top-level key in the registry manifest (C4). /// - public const string MetamodelVersion = "0.9"; + public const string MetamodelVersion = "0.10"; /// /// Emit the canonical registry manifest as a byte-stable JSON string. diff --git a/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java b/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java index 37c6a8602..075962c12 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java +++ b/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java @@ -274,7 +274,7 @@ public static List metamodelProviders() { * the 1.0 cut will flip it to {@code "1.0"}. Mirrors the TS reference's * {@code METAMODEL_VERSION} constant. */ - public static final String METAMODEL_VERSION = "0.9"; + public static final String METAMODEL_VERSION = "0.10"; // ------------------------------------------------------------------ // SP-G Phase1 Units2-3 — manifest emitter exclusions (documented, uniform diff --git a/server/python/src/metaobjects/registry_manifest.py b/server/python/src/metaobjects/registry_manifest.py index ea105c33b..d4e347dfe 100644 --- a/server/python/src/metaobjects/registry_manifest.py +++ b/server/python/src/metaobjects/registry_manifest.py @@ -255,7 +255,7 @@ def _sorted_children(definition: TypeDefinition) -> list[dict[str, object]]: return children -METAMODEL_VERSION = "0.9" +METAMODEL_VERSION = "0.10" """Rolled-up spec-version for the cross-port registry manifest. ``"0"`` = pre-1.0 / unstable (semver major-0). Flips to ``"1.0"`` at the diff --git a/server/typescript/packages/metadata/src/registry-manifest.ts b/server/typescript/packages/metadata/src/registry-manifest.ts index 4c81a7ceb..46bd833b2 100644 --- a/server/typescript/packages/metadata/src/registry-manifest.ts +++ b/server/typescript/packages/metadata/src/registry-manifest.ts @@ -107,7 +107,7 @@ interface ManifestType { * freezes it to `"1.0"`. Decoupled from every package line — it is the spec version, * not a package version. */ -export const METAMODEL_VERSION = "0.9"; +export const METAMODEL_VERSION = "0.10"; /** The full canonical manifest. All collections are sorted for byte-stability. */ interface RegistryManifest { diff --git a/spec/decisions/ADR-0035-one-zero-stability-commitment-and-version-unification.md b/spec/decisions/ADR-0035-one-zero-stability-commitment-and-version-unification.md index 5e909da5b..7b3fe6228 100644 --- a/spec/decisions/ADR-0035-one-zero-stability-commitment-and-version-unification.md +++ b/spec/decisions/ADR-0035-one-zero-stability-commitment-and-version-unification.md @@ -263,6 +263,16 @@ deferred with written triggers in the design doc. **Cadence is a separate lever and is free.** Nothing forces one release per merged change; batching removes most of the number pressure without any policy change at all. +**Enforced, because it was not before.** `metamodelVersion` read `"0.9"` from PR #145 +through 57 releases — including `0.21.0`, which deliberately broke the metamodel. This +amendment hands it the compat promise, so it ships with +`scripts/check-metamodel-version.mjs` (the `gates` lane): a diff of +`expected-registry.json` against the last release tag, classified, requiring the declared +version to have moved by at least that much. Its one blind spot is stated rather than +hidden — a rule can change with no machine-readable footprint (#210's only manifest edit +was a `rules` prose string), so prose changes prompt a question instead of being +classified. Operational detail: `docs/RELEASING.md` → "The two-contracts rule". + ## Consequences - After 1.0, a breaking change to the CLI surface or the scaffold-and-own contract From 948b51419687b65d8f3e9b4912d39cebe3371331 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 20 Aug 2026 22:21:36 -0400 Subject: [PATCH 2/2] fix(gates): the metamodel-version gate failed open on any baseline error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "." (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 Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15 --- docs/RELEASING.md | 2 +- ...6-08-20-two-contracts-versioning-design.md | 2 +- scripts/check-metamodel-version.mjs | 96 +++++++++++++++---- scripts/test-metamodel-version.mjs | 9 ++ .../metadata/src/registry-manifest.ts | 11 ++- ...lity-commitment-and-version-unification.md | 2 +- 6 files changed, 97 insertions(+), 25 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 134271e07..8458cd94a 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -177,7 +177,7 @@ contracts on two numbers: - **Package version** (npm/PyPI/NuGet `1.x`, Maven `8.x`) promises the SOFTWARE surface — exports, CLI flags, generated-code shape, runtime helpers. A break here is `2.0.0` / `9.0.0`. -- **`metamodelVersion`** (`"0.9"` today, `"1.0"` at the cut; the first key of the +- **`metamodelVersion`** (`"1.0"` at the cut; the current value is the first key of the byte-gated `expected-registry.json`) promises the METADATA contract — registered vocabulary, canonical/interchange format, wire contract. A break here moves ITS major, and **does not force a package major.** diff --git a/docs/superpowers/specs/2026-08-20-two-contracts-versioning-design.md b/docs/superpowers/specs/2026-08-20-two-contracts-versioning-design.md index 408ce8581..efaa8ff49 100644 --- a/docs/superpowers/specs/2026-08-20-two-contracts-versioning-design.md +++ b/docs/superpowers/specs/2026-08-20-two-contracts-versioning-design.md @@ -39,7 +39,7 @@ its next housekeeping edit. | Number | Promises | Moves when | |---|---|---| | **Package version** (npm/PyPI/NuGet `1.x`, Maven `8.x`) | the SOFTWARE surface: exports, CLI flags, generated-code shape, runtime helpers | that surface changes — full SemVer, `2.0.0` / `9.0.0` for a break | -| **`metamodelVersion`** (`"0.9"` today, `"1.0"` at the cut) | the METADATA contract: registered vocabulary, canonical authoring + interchange format, wire/normalization contract | that contract changes — its own major for a break | +| **`metamodelVersion`** (`"1.0"` at the cut) | the METADATA contract: registered vocabulary, canonical authoring + interchange format, wire/normalization contract | that contract changes — its own major for a break | **A metamodel-vocabulary break moves `metamodelVersion`, and does NOT force a package major.** That clause of ADR-0035 §1 is severed. diff --git a/scripts/check-metamodel-version.mjs b/scripts/check-metamodel-version.mjs index e4c67b844..54c80e621 100755 --- a/scripts/check-metamodel-version.mjs +++ b/scripts/check-metamodel-version.mjs @@ -42,7 +42,7 @@ // node scripts/check-metamodel-version.mjs # gate (CI + ci-local `gates`) // node scripts/check-metamodel-version.mjs --against v0.23.2 // node scripts/check-metamodel-version.mjs --explain # classify + print, always exit 0 -// node scripts/check-metamodel-version.mjs --set 1.0 # write the version to all 6 sites +// node scripts/check-metamodel-version.mjs --set 1.0 # write the version to all 5 sites // // See docs/RELEASING.md → "The two-contracts rule". @@ -56,7 +56,7 @@ const MANIFEST = "fixtures/registry-conformance/expected-registry.json"; /** * Every site that declares the metamodel version. The manifest is the one the - * conformance corpus byte-matches; the five port constants are what each port EMITS + * conformance corpus byte-matches; the four port constants are what each port EMITS * into its own manifest, so a partial edit is caught by `registry-conformance` — but * only in the lane for the port you forgot. `--set` writes all of them at once so that * failure mode does not need catching. (Kotlin has no constant of its own: it emits @@ -109,8 +109,15 @@ export function requiredBump(severity, base) { export function satisfies(bump, base, cur) { if (bump === "none") return true; - if (bump === "minor") return cur.major > base.major || cur.minor > base.minor; - return cur.major > base.major; // major + if (bump === "major") return cur.major > base.major; + // MINOR — compare the (major, minor) TUPLE, never the components independently. + // `cur.major > base.major || cur.minor > base.minor` reads as "moved somehow" and + // accepts a REGRESSION: 1.0 → 0.11 passes it (0 > 1 is false, but 11 > 0 is true), as + // does 2.0 → 1.9. That is not hypothetical — post-1.0, `--set 0.11` typed 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. + return cur.major > base.major || (cur.major === base.major && cur.minor > base.minor); } // --------------------------------------------------------------------------- @@ -196,9 +203,9 @@ function diffAttrs(baseAttrs, curAttrs, where, breaking, additive, prose) { if (ba.valueType !== ca.valueType) { breaking.push(`attr valueType changed: ${at} ${ba.valueType} → ${ca.valueType}`); } - if (Boolean(ba.isArray) !== Boolean(ca.isArray)) { - breaking.push(`attr isArray changed: ${at} ${Boolean(ba.isArray)} → ${Boolean(ca.isArray)}`); - } + const bArr = Boolean(ba.isArray); + const cArr = Boolean(ca.isArray); + if (bArr !== cArr) breaking.push(`attr isArray changed: ${at} ${bArr} → ${cArr}`); // allowedValues: a closed set. Removing a member, or closing a previously-open // attr, rejects a value that used to load. Adding one only permits more. @@ -233,8 +240,10 @@ function diffChildren(baseKids, curKids, where, breaking, additive) { } const bk = b.get(k); const at = `${where} ← ${k}`; - if ((ck.min ?? 0) > (bk.min ?? 0)) breaking.push(`child min raised: ${at}`); - if ((bk.min ?? 0) > (ck.min ?? 0)) additive.push(`child min lowered: ${at}`); + const bMin = bk.min ?? 0; + const cMin = ck.min ?? 0; + if (cMin > bMin) breaking.push(`child min raised: ${at}`); + if (bMin > cMin) additive.push(`child min lowered: ${at}`); // max === null means unbounded; lowering a bound (or introducing one) rejects a // parent that already declares more children than the new cap. @@ -262,12 +271,55 @@ function lastReleaseTag() { return tags[0] ?? null; } -function manifestAt(ref) { +/** + * Read the baseline manifest, distinguishing "genuinely not there yet" (skip) from + * "something went wrong" (fail). The previous form swallowed EVERY error into `null`, + * which `main()` reported as "pre-marker tag — skipping" and exited 0 — so a typo'd ref, + * a partial clone whose objects were never fetched, a renamed path or malformed JSON all + * produced a green tick having compared nothing. That is the exact failure the + * no-baseline branch already refuses by name; it does not get a side door. + * + * Returns `{ manifest }` or `{ skip }`; anything else calls `fail()`. + */ +function readManifestAt(ref) { try { - return JSON.parse(git("show", `${ref}:${MANIFEST}`)); + git("rev-parse", "--verify", "--quiet", `${ref}^{commit}`); } catch { - return null; + fail(`--against ${JSON.stringify(ref)} is not a resolvable git ref.`); + } + + // Ask the TREE whether the path exists, rather than inferring absence from a failed + // `git show`. In a blobless/partial clone the ref and its trees resolve while the blob + // does not, and `git show` failing there means "not fetched", not "not present". + const listed = git("ls-tree", "-r", "--name-only", ref, "--", MANIFEST).trim(); + if (!listed) return { skip: `${MANIFEST} does not exist at ${ref}` }; + + let raw; + try { + raw = git("show", `${ref}:${MANIFEST}`); + } catch (e) { + fail( + `${MANIFEST} is present in the tree at ${ref} but could not be read.\n` + + ` Usually a partial/blobless clone — fetch the objects, or pass a different\n` + + ` --against ref. Underlying error: ${e.message.split("\n")[0]}`, + ); + } + + let json; + try { + json = JSON.parse(raw); + } catch (e) { + fail(`${MANIFEST} at ${ref} is not valid JSON: ${e.message}`); } + + // The marker itself post-dates the manifest (added in PR #145), so a baseline older + // than that carries the file WITHOUT the key. Keying the skip on the file's existence + // instead made `--against v0.15.0` die with `metamodelVersion must be "." + // (got undefined)` — a confusing failure for a documented flag. + if (json.metamodelVersion === undefined) { + return { skip: `${MANIFEST} at ${ref} predates the metamodelVersion marker (PR #145)` }; + } + return { manifest: json }; } function fail(msg) { @@ -310,7 +362,12 @@ function main() { const argv = process.argv.slice(2); const arg = (flag) => { const i = argv.indexOf(flag); - return i === -1 ? null : argv[i + 1]; + if (i === -1) return null; + const value = argv[i + 1]; + // Blindly taking the next token turns `--against --explain` into a ref named + // "--explain", which then resolves to nothing and (before the fix above) passed. + if (value === undefined || value.startsWith("--")) fail(`${flag} needs a value`); + return value; }; if (argv.includes("--set")) { @@ -340,20 +397,21 @@ function main() { } const current = JSON.parse(readFileSync(resolve(REPO, MANIFEST), "utf8")); - const baseline = manifestAt(against); + const read = readManifestAt(against); - if (!baseline) { - process.stdout.write( - ` metamodel-version: ${MANIFEST} does not exist at ${against} — skipping (pre-marker tag).\n`, - ); + if (read.skip) { + process.stdout.write(` metamodel-version: ${read.skip} — skipping.\n`); process.exit(0); } + const baseline = read.manifest; const baseVer = parseVersion(baseline.metamodelVersion, against); const curVer = parseVersion(current.metamodelVersion, "working tree"); const { breaking, additive, prose } = classify(baseline, current); - const severity = breaking.length ? "breaking" : additive.length ? "additive" : "none"; + let severity = "none"; + if (breaking.length) severity = "breaking"; + else if (additive.length) severity = "additive"; const bump = requiredBump(severity, baseVer); if (explain) { diff --git a/scripts/test-metamodel-version.mjs b/scripts/test-metamodel-version.mjs index 84b18966e..965229a78 100755 --- a/scripts/test-metamodel-version.mjs +++ b/scripts/test-metamodel-version.mjs @@ -301,6 +301,15 @@ if (!sat("minor", "0.9", "0.10")) bad("0.9 → 0.10 satisfies a minor (numeric, else ok("0.9 → 0.10 satisfies a minor (numeric, not lexical)"); if (sat("minor", "0.10", "0.9")) bad("0.10 → 0.9 is a REGRESSION and must not satisfy"); else ok("0.10 → 0.9 is a REGRESSION and must not satisfy"); +// CROSS-MAJOR regressions. `cur.major > base.major || cur.minor > base.minor` reads as +// "moved somehow" and accepts both of these — 0 > 1 is false, but 11 > 0 is true. The +// same-major case above passed under that bug, which is what made it look covered. +if (sat("minor", "1.0", "0.11")) bad("1.0 → 0.11 is a REGRESSION across majors and must not satisfy"); +else ok("1.0 → 0.11 is a REGRESSION across majors and must not satisfy"); +if (sat("minor", "2.0", "1.9")) bad("2.0 → 1.9 is a REGRESSION across majors and must not satisfy"); +else ok("2.0 → 1.9 is a REGRESSION across majors and must not satisfy"); +if (!sat("minor", "1.9", "2.0")) bad("1.9 → 2.0 moves forward across a major and satisfies a minor"); +else ok("1.9 → 2.0 moves forward across a major and satisfies a minor"); console.log(fails === 0 ? "\nmetamodel-version classifier: all checks passed" : `\n${fails} failure(s)`); process.exit(fails === 0 ? 0 : 1); diff --git a/server/typescript/packages/metadata/src/registry-manifest.ts b/server/typescript/packages/metadata/src/registry-manifest.ts index 46bd833b2..145a76c79 100644 --- a/server/typescript/packages/metadata/src/registry-manifest.ts +++ b/server/typescript/packages/metadata/src/registry-manifest.ts @@ -103,9 +103,14 @@ interface ManifestType { * manifest (the byte-exact bill of materials), NOT a per-provider or per-file marker: * every port emits the same string, asserted by registry-conformance. Pre-1.0 the * vocabulary is still `0.x` (semver = unstable, may change) but largely settled going - * into the quiet period, so this is `"0.9"` (near-final); the 1.0 cut (readiness G1) - * freezes it to `"1.0"`. Decoupled from every package line — it is the spec version, - * not a package version. + * into the quiet period; the 1.0 cut (readiness G1) freezes it to `"1.0"`. Decoupled + * from every package line — it is the spec version, not a package version. + * + * The current value is the declaration below and nothing else. Do not restate it in + * prose: `scripts/check-metamodel-version.mjs --set` rewrites the declaration, so any + * copy in a comment goes stale on the very next bump (it said `"0.9"` here while the + * constant read `"0.10"`). Bump with that script — never by hand — so the manifest and + * all four port constants move together. */ export const METAMODEL_VERSION = "0.10"; diff --git a/spec/decisions/ADR-0035-one-zero-stability-commitment-and-version-unification.md b/spec/decisions/ADR-0035-one-zero-stability-commitment-and-version-unification.md index 7b3fe6228..0471265e7 100644 --- a/spec/decisions/ADR-0035-one-zero-stability-commitment-and-version-unification.md +++ b/spec/decisions/ADR-0035-one-zero-stability-commitment-and-version-unification.md @@ -235,7 +235,7 @@ After this amendment: - **Package version** (npm/PyPI/NuGet `1.x`, Maven `8.x`) promises the software surface. Full SemVer; a break is `2.0.0` / `9.0.0`. -- **`metamodelVersion`** (`"0.9"` today, `"1.0"` at the cut; the first key of the +- **`metamodelVersion`** (`"1.0"` at the cut; the current value is the first key of the byte-gated `expected-registry.json`, shipped in all five ports since #145) promises the metadata contract. A break moves ITS major. - §1's covered set is unchanged in *content*. What changes is which number carries it: