From 255c274321d3fb69ea46779f6a894c9ac92fc580 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 00:04:43 +0000 Subject: [PATCH] feat(cli,metadata-core)!: emit the protocol version under `protocolVersion`, not a runtime-shaped name `PROTOCOL_VERSION` is the protocol major padded to a semver ('17.0.0') and never tracks the installed package version. Emitted under the key `runtime`, a machine consumer read it as the runtime's own version with no prose to disambiguate -- the half of #15585 that the human-line repair (#16058) could not reach. - `os migrate meta --json` emits `protocolVersion`; `runtime` is removed outright, with no alias and no dual-key window. - `packages/metadata-core/src/protocol-handshake.ts` moves the same class of field in the same change: the `checkProtocolCompat` / `assertProtocolCompat` parameter and the `OS_PROTOCOL_INCOMPATIBLE` diagnostic member rename off `runtimeVersion` to the protocol spelling. `runtimeMajor` is deliberately unchanged -- an integer major carries no version-position ambiguity. - `PROTOCOL_VERSION` itself does not move; it is correct as a protocol version. - The existing e2e pin at the emit site is RE-POINTED at the new key rather than deleted, and now asserts both halves: the new key carries the value AND the old spelling is absent. Fixes #15585 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude --- .../migrate-meta-protocol-version-key.md | 65 +++++++++++++++++++ packages/cli/src/commands/migrate/meta.ts | 17 +++-- packages/cli/test/migrate-meta.e2e.test.ts | 20 ++++-- .../src/protocol-handshake.test.ts | 7 +- .../metadata-core/src/protocol-handshake.ts | 28 +++++--- 5 files changed, 114 insertions(+), 23 deletions(-) create mode 100644 .changeset/migrate-meta-protocol-version-key.md diff --git a/.changeset/migrate-meta-protocol-version-key.md b/.changeset/migrate-meta-protocol-version-key.md new file mode 100644 index 0000000000..a2fa561249 --- /dev/null +++ b/.changeset/migrate-meta-protocol-version-key.md @@ -0,0 +1,65 @@ +--- +"@objectstack/cli": minor +"@objectstack/metadata-core": minor +--- + + + +feat(cli,metadata-core)!: the protocol version is emitted under `protocolVersion`, never under a `runtime`-shaped name (#15585) + +**BREAKING** — two published machine surfaces change a key name. There is **no alias +and no dual-key transition window**: one axis, one name. + +| Surface | Was | Now | +|:--|:--|:--| +| `os migrate meta --json` payload | `runtime` | `protocolVersion` | +| `OS_PROTOCOL_INCOMPATIBLE` diagnostic (`ProtocolIncompatibleError.diagnostic`) | `runtimeVersion` | `protocolVersion` | +| `checkProtocolCompat()` / `assertProtocolCompat()` 2nd parameter | `runtimeVersion` | `protocolVersion` | + +The **value** is unchanged on every one of them: it is `PROTOCOL_VERSION`, the protocol +major padded to a semver (`'17.0.0'`), exactly as before. Nothing else on either payload +moves — no other key is added, removed or reshaped, and both text faces are byte-identical. +The parameter rename is positional, so no call site changes. + +## Why the name had to move + +`PROTOCOL_VERSION` is the protocol major padded to a semver and never tracks the installed +`@objectstack/cli` or runtime package version. Printed or emitted under the word *runtime* +it read as one: on a 17.3.0 install `runtime: "17.0.0"` reads as an apparent downgrade or +a stale install, next to the real package versions of the same upgrade session. + +The human line was repaired first and now reads +`Chain: protocol 17 → 17 (this runtime implements protocol 17)`. The machine face is the +worse half and was left standing, because a key on a published payload is a contract +change: an agent scripting an upgrade has no prose to disambiguate at all, and the +diagnostic's own `message` — which *is* unambiguous — is the one part a machine consumer +does not parse. + +## What a consumer should do + +Read the new key. The old one is absent, so a consumer that does not move reads +`undefined` rather than a wrong value. + +```diff +- const v = payload.runtime; // os migrate meta --json ++ const v = payload.protocolVersion; + +- const v = err.diagnostic.runtimeVersion; // OS_PROTOCOL_INCOMPATIBLE ++ const v = err.diagnostic.protocolVersion; +``` + +The diagnostic surfaces through every package that re-emits it — `@objectstack/runtime` +spreads it into `ArtifactReferenceError.detail`, `@objectstack/metadata-protocol` throws it +from the package install boundary, and `@objectstack/services-package` reads it during +hydration — so a consumer reading it from any of those reads the new name too. + +`runtimeMajor` on the same diagnostic is deliberately **unchanged**: it is an integer +protocol major, not a semver in a version position, and it does not carry the ambiguity +this rename closes. + +The breaking surface was measured before the rename and is closed inside this repository: +the only reader of the `--json` key was this repo's own e2e pin and the only reader of the +diagnostic member was `metadata-core`'s own unit test, both of which move in this same +change; the published `skills/objectstack-upgrade/SKILL.md` documents `--json` without ever +naming the field. **Zero external consumers were found.** Graded `minor` rather than +`major` for the launch window; the banner above carries the breaking-ness the level cannot. diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts index 1ff44b256b..823ea01127 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -328,12 +328,17 @@ export default class MigrateMeta extends Command { await emitJson({ from: result.fromMajor, to: result.toMajor, - // Deliberately NOT relabelled alongside the human line below: - // this is a machine-readable key on a published payload, so - // moving it is a contract change owing a reader census and a - // deprecation window of its own (#15585, option C). The value is - // the protocol major padded to a semver, not a package version. - runtime: PROTOCOL_VERSION, + // The key names what the value IS. `PROTOCOL_VERSION` is the + // protocol major padded to a semver ('17.0.0') and is never the + // installed package version -- emitted under the key `runtime`, + // as it was until this release, a machine consumer read it as + // the runtime's own version with no prose to disambiguate, which + // is the half of #15585 that the human-line repair could not + // reach. `runtime` is gone outright: no alias, no dual-key + // window. The pin in `test/migrate-meta.e2e.test.ts` asserts BOTH + // halves -- the new key carries the value AND the old spelling is + // absent -- so a future silent rename reddens instead of passing. + protocolVersion: PROTOCOL_VERSION, applied: result.applied, todos: result.todos, hops: flags.step diff --git a/packages/cli/test/migrate-meta.e2e.test.ts b/packages/cli/test/migrate-meta.e2e.test.ts index e1bd444973..8c25744e43 100644 --- a/packages/cli/test/migrate-meta.e2e.test.ts +++ b/packages/cli/test/migrate-meta.e2e.test.ts @@ -482,10 +482,14 @@ export default defineStack({ * this build's major it is the only place the operator is told where the * runtime actually stands, which is why the third case drives exactly that. * - * The `--json` `runtime` key is pinned UNCHANGED here on purpose. It is a - * machine-readable key on a published payload, so moving it is a contract - * change owing a reader census and a deprecation window of its own. This pin is - * what makes that move loud instead of silent. + * The `--json` half is pinned by the last case, and that pin was RE-POINTED + * rather than deleted when the key moved: it used to hold `runtime` unchanged, + * and it now holds `protocolVersion` carrying the value AND `runtime` being + * absent. Both halves are asserted for the same reason the human line asserts + * two: a pin that only checked the new key would stay green if the old spelling + * were quietly re-added alongside, which is precisely the dual-key state this + * rename was ruled against. Keeping the pin pointed at the live key is what + * makes the NEXT rename of this published payload loud instead of silent. */ describe('os migrate meta — the chain line names the protocol, not a package version', () => { const LABEL_CONFIG = ` @@ -527,8 +531,12 @@ export default { expect(stdout).not.toContain(PROTOCOL_VERSION); }, 120_000); - it('leaves the --json `runtime` key exactly as published', async () => { + it('emits the protocol version under `protocolVersion`, with no `runtime` key left', async () => { const parsed = JSON.parse(await runMeta(['--from', String(PROTOCOL_MAJOR), '--json'], labelDir)); - expect(parsed.runtime).toBe(PROTOCOL_VERSION); + expect(parsed.protocolVersion).toBe(PROTOCOL_VERSION); + // Removed OUTRIGHT -- no alias, no dual-key grace window. `in` rather than + // a truthiness check: an explicit `runtime: undefined` would satisfy the + // latter while still shipping the key through `JSON.stringify`'s omission. + expect(Object.keys(parsed)).not.toContain('runtime'); }, 120_000); }); diff --git a/packages/metadata-core/src/protocol-handshake.test.ts b/packages/metadata-core/src/protocol-handshake.test.ts index 4afe818d0f..f9870d6c17 100644 --- a/packages/metadata-core/src/protocol-handshake.test.ts +++ b/packages/metadata-core/src/protocol-handshake.test.ts @@ -140,7 +140,12 @@ describe('checkProtocolCompat', () => { expect(r.diagnostic.packageId).toBe('com.acme.crm'); expect(r.diagnostic.requiredRange).toBe('^10'); expect(r.diagnostic.rangeSource).toBe('engines.protocol'); - expect(r.diagnostic.runtimeVersion).toBe(RT); + // The protocol version the manifest was judged against. Spelled + // `runtimeVersion` until this release, where the machine face read as the + // installed package version; removed OUTRIGHT, so the absence is pinned + // beside the new key rather than only the new key being pinned. + expect(r.diagnostic.protocolVersion).toBe(RT); + expect(Object.keys(r.diagnostic)).not.toContain('runtimeVersion'); expect(r.diagnostic.targetMajor).toBe(10); expect(r.diagnostic.migrateCommand).toBe('objectstack migrate meta --from 10'); // The message names both versions and the command — the whole point of D1. diff --git a/packages/metadata-core/src/protocol-handshake.ts b/packages/metadata-core/src/protocol-handshake.ts index 3dd5cbc3a5..96146adab2 100644 --- a/packages/metadata-core/src/protocol-handshake.ts +++ b/packages/metadata-core/src/protocol-handshake.ts @@ -41,7 +41,7 @@ export type ProtocolCompatResult = | { status: 'incompatible'; runtimeMajor: number; - runtimeVersion: string; + protocolVersion: string; requiredRange: string; source: RangeSource; /** Stable, machine-readable diagnostic (also the shape emitted as JSON). */ @@ -55,7 +55,15 @@ export interface ProtocolIncompatibleDiagnostic { packageId: string; requiredRange: string; rangeSource: RangeSource; - runtimeVersion: string; + /** + * The protocol version the manifest was judged against -- `PROTOCOL_VERSION`, + * the protocol major padded to a semver ('17.0.0'), never the installed + * package version of the runtime. It was spelled `runtimeVersion` until this + * release, where a machine consumer read it as a package version with no + * prose to disambiguate; the prose in `message` was always unambiguous, the + * machine field was not. + */ + protocolVersion: string; runtimeMajor: number; /** The declared major the package targets, when a single major is determinable. */ targetMajor: number | null; @@ -222,9 +230,9 @@ function comparatorAdmitsMajor(comparator: string, runtimeMajor: number): boolea */ export function checkProtocolCompat( manifest: ProtocolHandshakeManifest, - runtimeVersion: string = PROTOCOL_VERSION, + protocolVersion: string = PROTOCOL_VERSION, ): ProtocolCompatResult { - const runtimeMajor = leadingMajor(runtimeVersion) ?? 0; + const runtimeMajor = leadingMajor(protocolVersion) ?? 0; const declared = resolveDeclaredRange(manifest); if (!declared) return { status: 'no-range', runtimeMajor }; @@ -245,13 +253,13 @@ export function checkProtocolCompat( : `objectstack migrate meta`; const message = `package '${packageId}' targets protocol ${declared.range} ` + - `(${declared.source}) but this runtime is protocol ${runtimeVersion}. ` + + `(${declared.source}) but this runtime is protocol ${protocolVersion}. ` + `This is a major-version break. Run: ${migrateCommand}`; return { status: 'incompatible', runtimeMajor, - runtimeVersion, + protocolVersion, requiredRange: declared.range, source: declared.source, diagnostic: { @@ -259,7 +267,7 @@ export function checkProtocolCompat( packageId, requiredRange: declared.range, rangeSource: declared.source, - runtimeVersion, + protocolVersion, runtimeMajor, targetMajor, migrateCommand, @@ -282,10 +290,10 @@ export type WarnFn = (message: string) => void; */ export function assertProtocolCompat( manifest: ProtocolHandshakeManifest, - runtimeVersion: string = PROTOCOL_VERSION, + protocolVersion: string = PROTOCOL_VERSION, warn: WarnFn = (m) => console.warn(m), ): void { - const result = checkProtocolCompat(manifest, runtimeVersion); + const result = checkProtocolCompat(manifest, protocolVersion); const pkg = manifest.id ?? ''; switch (result.status) { case 'ok': @@ -293,7 +301,7 @@ export function assertProtocolCompat( case 'no-range': warn( `[protocol] package '${pkg}' declares no engines.protocol range; ` + - `loading under protocol ${runtimeVersion} without a compatibility check (ADR-0087).`, + `loading under protocol ${protocolVersion} without a compatibility check (ADR-0087).`, ); return; case 'unparsed-range':