Skip to content
Draft
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
65 changes: 65 additions & 0 deletions .changeset/migrate-meta-protocol-version-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
"@objectstack/cli": minor
"@objectstack/metadata-core": minor
---

<!-- 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. -->

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.
17 changes: 11 additions & 6 deletions packages/cli/src/commands/migrate/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 14 additions & 6 deletions packages/cli/test/migrate-meta.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand Down Expand Up @@ -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);
});
7 changes: 6 additions & 1 deletion packages/metadata-core/src/protocol-handshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 18 additions & 10 deletions packages/metadata-core/src/protocol-handshake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
Expand All @@ -245,21 +253,21 @@ 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: {
code: 'OS_PROTOCOL_INCOMPATIBLE',
packageId,
requiredRange: declared.range,
rangeSource: declared.source,
runtimeVersion,
protocolVersion,
runtimeMajor,
targetMajor,
migrateCommand,
Expand All @@ -282,18 +290,18 @@ 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 ?? '<unknown>';
switch (result.status) {
case 'ok':
return;
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':
Expand Down
Loading