Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .claude/skills/releasing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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> version` (a 404 = free); `git tag -l v<version>` | 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 <version>`; 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.
Expand All @@ -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
<version>`, 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
Expand Down
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <version>`, 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.
Expand Down
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <version>` 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
Expand Down
2 changes: 2 additions & 0 deletions agent-context/templates/always-on.md.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -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/<lang>.md` fragment
Expand Down
36 changes: 33 additions & 3 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand All @@ -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 <version>` — 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 <ref>` 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).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <version>` 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/<lang>.md` fragment
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/<lang>.md` fragment
Expand Down
Loading
Loading