Skip to content

Commit 255c274

Browse files
committed
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 <noreply@anthropic.com>
1 parent ae19f5e commit 255c274

5 files changed

Lines changed: 114 additions & 23 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
"@objectstack/cli": minor
3+
"@objectstack/metadata-core": minor
4+
---
5+
6+
<!-- adr-0087: not-required (no-migration-prescription) both renamed members are RUNTIME OUTPUT, not authored metadata: a CLI `--json` key emitted from an inline object literal, and a member of a TypeScript diagnostic object built at throw time. Neither has a Zod schema, a `packages/spec` declaration or a stored representation, so `objectstack migrate meta` has nothing to reach and a ledger entry would project into `spec-changes.json` and the upgrade guide as an instruction no metadata upgrader can act on. The prescription in this body addresses a SOURCE-CODE and stdout-reading consumer, whose delivery channel is the compiler and this changelog (ADR-0087 D8) -- the same disposition and the same argument as the `specVersionGap` to `protocolVersionGap` rename that shipped from this repo. -->
7+
8+
feat(cli,metadata-core)!: the protocol version is emitted under `protocolVersion`, never under a `runtime`-shaped name (#15585)
9+
10+
**BREAKING** — two published machine surfaces change a key name. There is **no alias
11+
and no dual-key transition window**: one axis, one name.
12+
13+
| Surface | Was | Now |
14+
|:--|:--|:--|
15+
| `os migrate meta --json` payload | `runtime` | `protocolVersion` |
16+
| `OS_PROTOCOL_INCOMPATIBLE` diagnostic (`ProtocolIncompatibleError.diagnostic`) | `runtimeVersion` | `protocolVersion` |
17+
| `checkProtocolCompat()` / `assertProtocolCompat()` 2nd parameter | `runtimeVersion` | `protocolVersion` |
18+
19+
The **value** is unchanged on every one of them: it is `PROTOCOL_VERSION`, the protocol
20+
major padded to a semver (`'17.0.0'`), exactly as before. Nothing else on either payload
21+
moves — no other key is added, removed or reshaped, and both text faces are byte-identical.
22+
The parameter rename is positional, so no call site changes.
23+
24+
## Why the name had to move
25+
26+
`PROTOCOL_VERSION` is the protocol major padded to a semver and never tracks the installed
27+
`@objectstack/cli` or runtime package version. Printed or emitted under the word *runtime*
28+
it read as one: on a 17.3.0 install `runtime: "17.0.0"` reads as an apparent downgrade or
29+
a stale install, next to the real package versions of the same upgrade session.
30+
31+
The human line was repaired first and now reads
32+
`Chain: protocol 17 → 17 (this runtime implements protocol 17)`. The machine face is the
33+
worse half and was left standing, because a key on a published payload is a contract
34+
change: an agent scripting an upgrade has no prose to disambiguate at all, and the
35+
diagnostic's own `message` — which *is* unambiguous — is the one part a machine consumer
36+
does not parse.
37+
38+
## What a consumer should do
39+
40+
Read the new key. The old one is absent, so a consumer that does not move reads
41+
`undefined` rather than a wrong value.
42+
43+
```diff
44+
- const v = payload.runtime; // os migrate meta --json
45+
+ const v = payload.protocolVersion;
46+
47+
- const v = err.diagnostic.runtimeVersion; // OS_PROTOCOL_INCOMPATIBLE
48+
+ const v = err.diagnostic.protocolVersion;
49+
```
50+
51+
The diagnostic surfaces through every package that re-emits it — `@objectstack/runtime`
52+
spreads it into `ArtifactReferenceError.detail`, `@objectstack/metadata-protocol` throws it
53+
from the package install boundary, and `@objectstack/services-package` reads it during
54+
hydration — so a consumer reading it from any of those reads the new name too.
55+
56+
`runtimeMajor` on the same diagnostic is deliberately **unchanged**: it is an integer
57+
protocol major, not a semver in a version position, and it does not carry the ambiguity
58+
this rename closes.
59+
60+
The breaking surface was measured before the rename and is closed inside this repository:
61+
the only reader of the `--json` key was this repo's own e2e pin and the only reader of the
62+
diagnostic member was `metadata-core`'s own unit test, both of which move in this same
63+
change; the published `skills/objectstack-upgrade/SKILL.md` documents `--json` without ever
64+
naming the field. **Zero external consumers were found.** Graded `minor` rather than
65+
`major` for the launch window; the banner above carries the breaking-ness the level cannot.

‎packages/cli/src/commands/migrate/meta.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -328,12 +328,17 @@ export default class MigrateMeta extends Command {
328328
await emitJson({
329329
from: result.fromMajor,
330330
to: result.toMajor,
331-
// Deliberately NOT relabelled alongside the human line below:
332-
// this is a machine-readable key on a published payload, so
333-
// moving it is a contract change owing a reader census and a
334-
// deprecation window of its own (#15585, option C). The value is
335-
// the protocol major padded to a semver, not a package version.
336-
runtime: PROTOCOL_VERSION,
331+
// The key names what the value IS. `PROTOCOL_VERSION` is the
332+
// protocol major padded to a semver ('17.0.0') and is never the
333+
// installed package version -- emitted under the key `runtime`,
334+
// as it was until this release, a machine consumer read it as
335+
// the runtime's own version with no prose to disambiguate, which
336+
// is the half of #15585 that the human-line repair could not
337+
// reach. `runtime` is gone outright: no alias, no dual-key
338+
// window. The pin in `test/migrate-meta.e2e.test.ts` asserts BOTH
339+
// halves -- the new key carries the value AND the old spelling is
340+
// absent -- so a future silent rename reddens instead of passing.
341+
protocolVersion: PROTOCOL_VERSION,
337342
applied: result.applied,
338343
todos: result.todos,
339344
hops: flags.step

‎packages/cli/test/migrate-meta.e2e.test.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -482,10 +482,14 @@ export default defineStack({
482482
* this build's major it is the only place the operator is told where the
483483
* runtime actually stands, which is why the third case drives exactly that.
484484
*
485-
* The `--json` `runtime` key is pinned UNCHANGED here on purpose. It is a
486-
* machine-readable key on a published payload, so moving it is a contract
487-
* change owing a reader census and a deprecation window of its own. This pin is
488-
* what makes that move loud instead of silent.
485+
* The `--json` half is pinned by the last case, and that pin was RE-POINTED
486+
* rather than deleted when the key moved: it used to hold `runtime` unchanged,
487+
* and it now holds `protocolVersion` carrying the value AND `runtime` being
488+
* absent. Both halves are asserted for the same reason the human line asserts
489+
* two: a pin that only checked the new key would stay green if the old spelling
490+
* were quietly re-added alongside, which is precisely the dual-key state this
491+
* rename was ruled against. Keeping the pin pointed at the live key is what
492+
* makes the NEXT rename of this published payload loud instead of silent.
489493
*/
490494
describe('os migrate meta — the chain line names the protocol, not a package version', () => {
491495
const LABEL_CONFIG = `
@@ -527,8 +531,12 @@ export default {
527531
expect(stdout).not.toContain(PROTOCOL_VERSION);
528532
}, 120_000);
529533

530-
it('leaves the --json `runtime` key exactly as published', async () => {
534+
it('emits the protocol version under `protocolVersion`, with no `runtime` key left', async () => {
531535
const parsed = JSON.parse(await runMeta(['--from', String(PROTOCOL_MAJOR), '--json'], labelDir));
532-
expect(parsed.runtime).toBe(PROTOCOL_VERSION);
536+
expect(parsed.protocolVersion).toBe(PROTOCOL_VERSION);
537+
// Removed OUTRIGHT -- no alias, no dual-key grace window. `in` rather than
538+
// a truthiness check: an explicit `runtime: undefined` would satisfy the
539+
// latter while still shipping the key through `JSON.stringify`'s omission.
540+
expect(Object.keys(parsed)).not.toContain('runtime');
533541
}, 120_000);
534542
});

‎packages/metadata-core/src/protocol-handshake.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,12 @@ describe('checkProtocolCompat', () => {
140140
expect(r.diagnostic.packageId).toBe('com.acme.crm');
141141
expect(r.diagnostic.requiredRange).toBe('^10');
142142
expect(r.diagnostic.rangeSource).toBe('engines.protocol');
143-
expect(r.diagnostic.runtimeVersion).toBe(RT);
143+
// The protocol version the manifest was judged against. Spelled
144+
// `runtimeVersion` until this release, where the machine face read as the
145+
// installed package version; removed OUTRIGHT, so the absence is pinned
146+
// beside the new key rather than only the new key being pinned.
147+
expect(r.diagnostic.protocolVersion).toBe(RT);
148+
expect(Object.keys(r.diagnostic)).not.toContain('runtimeVersion');
144149
expect(r.diagnostic.targetMajor).toBe(10);
145150
expect(r.diagnostic.migrateCommand).toBe('objectstack migrate meta --from 10');
146151
// The message names both versions and the command — the whole point of D1.

‎packages/metadata-core/src/protocol-handshake.ts‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export type ProtocolCompatResult =
4141
| {
4242
status: 'incompatible';
4343
runtimeMajor: number;
44-
runtimeVersion: string;
44+
protocolVersion: string;
4545
requiredRange: string;
4646
source: RangeSource;
4747
/** Stable, machine-readable diagnostic (also the shape emitted as JSON). */
@@ -55,7 +55,15 @@ export interface ProtocolIncompatibleDiagnostic {
5555
packageId: string;
5656
requiredRange: string;
5757
rangeSource: RangeSource;
58-
runtimeVersion: string;
58+
/**
59+
* The protocol version the manifest was judged against -- `PROTOCOL_VERSION`,
60+
* the protocol major padded to a semver ('17.0.0'), never the installed
61+
* package version of the runtime. It was spelled `runtimeVersion` until this
62+
* release, where a machine consumer read it as a package version with no
63+
* prose to disambiguate; the prose in `message` was always unambiguous, the
64+
* machine field was not.
65+
*/
66+
protocolVersion: string;
5967
runtimeMajor: number;
6068
/** The declared major the package targets, when a single major is determinable. */
6169
targetMajor: number | null;
@@ -222,9 +230,9 @@ function comparatorAdmitsMajor(comparator: string, runtimeMajor: number): boolea
222230
*/
223231
export function checkProtocolCompat(
224232
manifest: ProtocolHandshakeManifest,
225-
runtimeVersion: string = PROTOCOL_VERSION,
233+
protocolVersion: string = PROTOCOL_VERSION,
226234
): ProtocolCompatResult {
227-
const runtimeMajor = leadingMajor(runtimeVersion) ?? 0;
235+
const runtimeMajor = leadingMajor(protocolVersion) ?? 0;
228236
const declared = resolveDeclaredRange(manifest);
229237

230238
if (!declared) return { status: 'no-range', runtimeMajor };
@@ -245,21 +253,21 @@ export function checkProtocolCompat(
245253
: `objectstack migrate meta`;
246254
const message =
247255
`package '${packageId}' targets protocol ${declared.range} ` +
248-
`(${declared.source}) but this runtime is protocol ${runtimeVersion}. ` +
256+
`(${declared.source}) but this runtime is protocol ${protocolVersion}. ` +
249257
`This is a major-version break. Run: ${migrateCommand}`;
250258

251259
return {
252260
status: 'incompatible',
253261
runtimeMajor,
254-
runtimeVersion,
262+
protocolVersion,
255263
requiredRange: declared.range,
256264
source: declared.source,
257265
diagnostic: {
258266
code: 'OS_PROTOCOL_INCOMPATIBLE',
259267
packageId,
260268
requiredRange: declared.range,
261269
rangeSource: declared.source,
262-
runtimeVersion,
270+
protocolVersion,
263271
runtimeMajor,
264272
targetMajor,
265273
migrateCommand,
@@ -282,18 +290,18 @@ export type WarnFn = (message: string) => void;
282290
*/
283291
export function assertProtocolCompat(
284292
manifest: ProtocolHandshakeManifest,
285-
runtimeVersion: string = PROTOCOL_VERSION,
293+
protocolVersion: string = PROTOCOL_VERSION,
286294
warn: WarnFn = (m) => console.warn(m),
287295
): void {
288-
const result = checkProtocolCompat(manifest, runtimeVersion);
296+
const result = checkProtocolCompat(manifest, protocolVersion);
289297
const pkg = manifest.id ?? '<unknown>';
290298
switch (result.status) {
291299
case 'ok':
292300
return;
293301
case 'no-range':
294302
warn(
295303
`[protocol] package '${pkg}' declares no engines.protocol range; ` +
296-
`loading under protocol ${runtimeVersion} without a compatibility check (ADR-0087).`,
304+
`loading under protocol ${protocolVersion} without a compatibility check (ADR-0087).`,
297305
);
298306
return;
299307
case 'unparsed-range':

0 commit comments

Comments
 (0)