From e702dfad69a846be9774dcafa192cdbe6bef2ed4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 21:57:38 +0000 Subject: [PATCH] fix(cli): `os migrate meta` names the protocol, not a package version The chain line ended `(runtime 17.0.0)`. That value is `PROTOCOL_VERSION` -- the protocol major padded to a semver -- and it never tracks the installed package version. Printed as a bare semver under the word "runtime", beside the real package versions of the same upgrade session, it read as "your runtime is 17.0.0": an apparent downgrade on a 17.3.0 install. The value was never wrong; the label and the semver form were. The line now states the fact in the protocol's own units -- `(this runtime implements protocol 17)` -- relabelled rather than dropped, because with `--to` stopping below this build's major it is the only place the operator learns where the runtime actually stands. The `--json` `runtime` key is deliberately left as published: a machine-readable key on a shipped payload owes a reader census and a deprecation window before it moves. A new e2e pin drives the real CLI over both halves and asserts that key's current value, so the contract move cannot happen silently. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .../migrate-meta-chain-line-protocol-label.md | 17 +++++ packages/cli/src/commands/migrate/meta.ts | 15 +++- packages/cli/test/migrate-meta.e2e.test.ts | 69 +++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 .changeset/migrate-meta-chain-line-protocol-label.md diff --git a/.changeset/migrate-meta-chain-line-protocol-label.md b/.changeset/migrate-meta-chain-line-protocol-label.md new file mode 100644 index 0000000000..ea6d28af3d --- /dev/null +++ b/.changeset/migrate-meta-chain-line-protocol-label.md @@ -0,0 +1,17 @@ +--- +"@objectstack/cli": patch +--- + +`os migrate meta` no longer prints the protocol version under the word "runtime", where it read as the installed package version. + +The chain line used to end `(runtime 17.0.0)`. That number is `PROTOCOL_VERSION` — the protocol major padded to a semver — and it is not, and never tracks, the version of the installed `@objectstack/cli` or `@objectstack/spec`. On a 17.3.0 install the line appeared beside the real package versions of the same upgrade session (`npm view`, the changelog), so it read as "your runtime is 17.0.0": an apparent downgrade or a stale install, neither of which was true. + +The value was never wrong — the label and the semver form were. The line now states the fact in the protocol's own units: + +``` +Chain: protocol 17 → 17 (this runtime implements protocol 17) +``` + +The parenthetical is relabelled rather than dropped, because it carries a fact nothing else on screen does: when `--to` stops below this build's major, it is the only place the operator is told where the runtime actually stands (`Chain: protocol 16 → 16 (this runtime implements protocol 17)`). + +The `--json` payload is deliberately untouched: its `runtime` key still carries the same padded protocol semver. Renaming a machine-readable key is a contract change owing a reader census and a deprecation window of its own, and it is tracked separately — an e2e pin now asserts the key's current value so that move cannot happen silently. diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts index 4510e5e949..058a5bb796 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -327,6 +327,11 @@ 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, applied: result.applied, todos: result.todos, @@ -350,7 +355,15 @@ export default class MigrateMeta extends Command { } printInfo(`Config: ${chalk.white(absolutePath)}`); - printInfo(`Chain: protocol ${fromMajor} → ${toMajor} (runtime ${PROTOCOL_VERSION})`); + // State this build's protocol major in the protocol's own units. + // `PROTOCOL_VERSION` is that major padded to a semver ('17.0.0'), never + // the installed package version -- printed as a bare semver under the + // word "runtime" it read as one, so on a 17.3.0 install the operator saw + // an apparent downgrade next to the real package versions of the same + // upgrade session. The fact itself is worth keeping: with `--to` below + // this build's major it is the only line saying where the runtime + // actually stands. So it is relabelled and de-padded, not dropped. + printInfo(`Chain: protocol ${fromMajor} → ${toMajor} (this runtime implements protocol ${PROTOCOL_MAJOR})`); console.log(''); if (result.applied.length === 0 && result.todos.length === 0) { diff --git a/packages/cli/test/migrate-meta.e2e.test.ts b/packages/cli/test/migrate-meta.e2e.test.ts index b9aa8ed302..d667bc5e18 100644 --- a/packages/cli/test/migrate-meta.e2e.test.ts +++ b/packages/cli/test/migrate-meta.e2e.test.ts @@ -23,6 +23,7 @@ import { dirname, join, resolve } from 'node:path'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { ObjectStackDefinitionSchema } from '@objectstack/spec'; +import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel'; import { childEnv } from './helpers/serve-process.js'; const execFileP = promisify(execFile); @@ -443,3 +444,71 @@ export default defineStack({ expect(refused, '`os validate` must still reject a retired key').toBe(true); }, 180_000); }); + +/** + * The chain line states this build's protocol in the protocol's own units. + * + * `PROTOCOL_VERSION` is the protocol major padded to a semver ('17.0.0') and is + * never the installed package version. Printed here as a bare semver under the + * word "runtime", it read as one: on a 17.3.0 install the operator saw + * "runtime 17.0.0" beside the real package versions of the same upgrade + * session, which reads as an apparent downgrade or a stale install. Nothing + * about the VALUE was wrong; the label and the semver FORM were. + * + * Both halves are asserted, because either one alone is satisfiable the wrong + * way. A line that merely stopped saying "runtime" could still print the padded + * semver in a version position; and a line that dropped the parenthetical + * altogether would lose the one fact it carries -- with `--to` stopping below + * 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. + */ +describe('os migrate meta — the chain line names the protocol, not a package version', () => { + const LABEL_CONFIG = ` +export default { + manifest: { id: 'chain_label_e2e', name: 'Chain Label E2E', version: '1.0.0', type: 'app' }, + objects: [{ name: 'label_ticket', label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } } }], +}; +`; + let labelDir: string; + + beforeAll(() => { + labelDir = mkdtempSync(join(tmpdir(), 'os-migrate-meta-label-')); + writeFileSync(join(labelDir, 'objectstack.config.ts'), LABEL_CONFIG); + }); + + afterAll(() => { + try { rmSync(labelDir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it("names this build's protocol major, in majors", async () => { + const stdout = await runMeta(['--from', String(PROTOCOL_MAJOR)], labelDir); + expect(stdout).toContain( + `Chain: protocol ${PROTOCOL_MAJOR} → ${PROTOCOL_MAJOR} (this runtime implements protocol ${PROTOCOL_MAJOR})`, + ); + }, 120_000); + + it('prints no padded protocol semver in the human output, under any label', async () => { + const stdout = await runMeta(['--from', String(PROTOCOL_MAJOR)], labelDir); + expect(stdout).not.toContain(PROTOCOL_VERSION); + expect(stdout).not.toMatch(/runtime \d+\.\d+\.\d+/); + }, 120_000); + + it('still says where the runtime stands when --to stops below this build\'s major', async () => { + const below = PROTOCOL_MAJOR - 1; + const stdout = await runMeta(['--from', String(below), '--to', String(below)], labelDir); + expect(stdout).toContain( + `Chain: protocol ${below} → ${below} (this runtime implements protocol ${PROTOCOL_MAJOR})`, + ); + expect(stdout).not.toContain(PROTOCOL_VERSION); + }, 120_000); + + it('leaves the --json `runtime` key exactly as published', async () => { + const parsed = JSON.parse(await runMeta(['--from', String(PROTOCOL_MAJOR), '--json'], labelDir)); + expect(parsed.runtime).toBe(PROTOCOL_VERSION); + }, 120_000); +});