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..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.** @@ -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..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. @@ -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..54c80e621 --- /dev/null +++ b/scripts/check-metamodel-version.mjs @@ -0,0 +1,470 @@ +#!/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 5 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 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 + * 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 === "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); +} + +// --------------------------------------------------------------------------- +// 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}`); + } + 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. + 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}`; + 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. + 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; +} + +/** + * 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 { + git("rev-parse", "--verify", "--quiet", `${ref}^{commit}`); + } catch { + 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) { + 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); + 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")) { + 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 read = readManifestAt(against); + + 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); + let severity = "none"; + if (breaking.length) severity = "breaking"; + else if (additive.length) severity = "additive"; + 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..965229a78 --- /dev/null +++ b/scripts/test-metamodel-version.mjs @@ -0,0 +1,315 @@ +#!/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"); +// 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/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..145a76c79 100644 --- a/server/typescript/packages/metadata/src/registry-manifest.ts +++ b/server/typescript/packages/metadata/src/registry-manifest.ts @@ -103,11 +103,16 @@ 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.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..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: @@ -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