Skip to content

Commit 7dd5a2f

Browse files
fix(metadata-core)!: a package ahead of the runtime is told to upgrade, not to run migrate meta (#26530)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X5mffbTLuxZU5VwREBF7frY)_ Fixes #26480 Clause-②: no (narrowing) Metadata-protocol: none ## 维护者速读 - **改了什么**:安装一个协议主版本比运行时新的包(如在 18 上装 `^19`)时,`OS_PROTOCOL_INCOMPATIBLE` 拒绝不再建议 `migrate meta --from 19`,改为提示升级运行时或安装适配本主版本的包;`details` 里不再带 `migrateCommand`。 - **为什么改**:`migrate meta` 只能把旧主版本的元数据往前迁,对“包比运行时新”的情况是死路,运维会被误导。 - **风险与回滚**:低,但按规则记为对外类型收窄(BREAKING):`ProtocolIncompatibleDiagnostic.migrateCommand` 从必有变为可选,仓外若有 TypeScript 代码直接取用它会在编译时报错。拒绝的 code 与 422 状态不变,旧主版本(如 `^17`)的提示完全不变。回滚即 revert 本 PR。 - **先看**:`packages/metadata-core/src/protocol-handshake.ts`(`checkProtocolCompat` 里 `ahead` 分支);`packages/runtime/src/domains/packages-install-protocol-incompatible.test.ts`(HTTP 门的新用例)。 - **两分钟验证**:`pnpm --filter @objectstack/metadata-core exec vitest run src/protocol-handshake.test.ts`,预期 37/37 通过,其中 `[#26480]` 四条。 - **需要你做的**:无,席位落地。 ## What changes `checkProtocolCompat` (`@objectstack/metadata-core`) now compares the declared floor major (`declaredFloorMajor`) with the runtime's protocol major before it writes the prescription: - **Floor major above the runtime's** (the package is ahead of the runtime): the diagnostic carries no `migrateCommand`, and the message ends `The package is built for a newer protocol than this runtime implements. Upgrade the runtime to protocol 19, or install a build of 'ID' for protocol 18`. - **Floor major at or below the runtime's, or not determinable**: unchanged, `Run: objectstack migrate meta --from N` and `migrateCommand` as before. `protocolIncompatibleAnswer` leaves `migrateCommand` out of `details` when the diagnostic has none (never `undefined`), so both HTTP doors, `POST /api/v1/packages` and `POST /api/v1/marketplace/install-local`, answer four members for the ahead case. `ProtocolIncompatibleDiagnostic.migrateCommand` becomes optional in the exported type. The refusal's code and status (422 `OS_PROTOCOL_INCOMPATIBLE`) do not change. Two comments that said the diagnostic always names `migrate meta` (`runtime/src/app-plugin.ts`, the rehydrate line in `cloud-connection/src/marketplace-install-local-plugin.ts`) now name both remedies. Changeset: `@objectstack/metadata-core` patch, with the BREAKING banner and the ADR-0087 disposition `not-required (runtime-interface-only packages/metadata-core/src/protocol-handshake.ts#ProtocolIncompatibleDiagnostic)`. **BREAKING** public-surface narrowing, shipped as `patch` because it is not a metadata-protocol change: `ProtocolIncompatibleDiagnostic` is exported from `@objectstack/metadata-core`'s entry (`export * from './protocol-handshake.js'`), and its `migrateCommand` member goes from `string` to optional. A guaranteed output member that can now be absent narrows what the package exposes. pm-dispatch `execution-duties.md`: "收窄已发布接受集的卡是 `Clause-②: no`,不移车道;入队前欠一次契约复审档复核。" So the declaration is `Clause-②: no (narrowing)`, and the PR owes a contract-review pass before it is queued. No FROM → TO line, on purpose. AGENTS.md Post-Task Checklist step 3 asks for one when a change is "removing or renaming anything an author can write (a spec key, an export, a config field)"; this change removes and renames nothing, it makes one output member optional. The disposition it takes, `runtime-interface-only`, "ALSO inherits the `no-migration-prescription` refusal" (`scripts/check-adr-0087-registration.mjs` header), so a rewrite prescription in the changeset would refuse the category. What a reader does instead is stated in prose: treat an absent `migrateCommand` as "no command applies; read `message`"; a TypeScript reader that dereferences it is told by its compiler. ## Why The card's reproduction: on a protocol-18 runtime, `POST /api/v1/packages` with `engines.protocol: '^19'` answered `details.migrateCommand = objectstack migrate meta --from 19` and "Run: objectstack migrate meta --from 19". Migration only moves metadata forward from an older major, so the operator was sent to a command that cannot apply. Ruling: triage 6434187705. ## Tests Red first on `main` (`00874b9e09`), then green on the fix: | Suite | On `main` | On the fix | |---|---|---| | `metadata-core` `protocol-handshake.test.ts` | 3 failed, 34 passed | 37/37 | | `runtime` `packages-install-protocol-incompatible.test.ts` (both door arms) | 2 failed, 6 passed | 8/8 | | `cloud-connection` `marketplace-install-local-protocol-handshake.test.ts` (install door and rehydrate log line) | 2 failed, 11 passed | 13/13 | - The new `[#26480]` blocks assert the ADR-0112 envelope (`code` + `status` 422), `details` without `migrateCommand`, no `migrate meta` in the message, and the runtime upgrade named. The `^17`-style control (`^10` on an 11 runtime in the unit suite, and every existing older-major test) keeps `--from N`. - Ablation (committed fix, `scripts/ablation-replace.mjs`): `const ahead = targetMajor !== null && targetMajor > runtimeMajor;` replaced by `const ahead = false;` turns the 3 unit tests red (34 still pass, the control included); restored from the HEAD blob `de811bf3863`, `git diff HEAD` empty afterwards. Whole packages at `43b9bd0d9`: `metadata-core` test 827/827 + typecheck exit 0; `runtime` 8371 passed, 19 skipped + typecheck exit 0; `cloud-connection` 747/747 + typecheck exit 0. `eslint --no-inline-config` on the six touched `.ts` files: exit 0. After merging `origin/main` (`d2ca7079b`), the three targeted suites again at `a08584332`: 37/37, 8/8, 13/13. **Dogfood battery** (owed by the `(narrowing)` arm): `@objectstack/dogfood^...` closure built, then `pnpm --filter @objectstack/dogfood test` under the lock at `17bb14466`: VERDICT command-exit 0, Test Files 300 passed, 3 skipped (303); Tests 2494 passed, 18 skipped (2512); no failures. The final head `a08584332` differs from `17bb14466` only in the changeset's `Clause-②` line (`git diff --stat`: 1 file, 1 line). ## Acceptance notes - **Who reads `details.migrateCommand`.** No in-repo code outside tests reads it. The console's not-loaded marker reads only `code` and `requiredRange`; `os package install` prints the server's `error.message`; the artifact boot refusal (`runtime/src/artifact-reference.ts`) and the `service-package` rehydrate log embed `diagnostic.message`; the `cloud-connection` rehydrate line embeds the message and already says "install a version of the package built for protocol N". The CLI's `checkProtocolVersionGap` already stays silent when the declared major is at or above the installed one. None hard-codes the old text. - **Documented field.** The spec error-code ledger comment for `OS_PROTOCOL_INCOMPATIBLE` (`packages/spec/src/api/error-code-ledger.zod.ts`) lists `migrateCommand` among the `error.details` members. That stays true for older majors and is now incomplete for the ahead case; it is a comment under `packages/spec`, outside this lane, so it is left as is. - **Same-class edge not changed here.** A range whose floor major equals the runtime's but still refuses it keeps `--from N`: on an 18 runtime, `'>18'` (which means 19 and up, so the package is ahead) and `'<18'` both answer `objectstack migrate meta --from 18`. Measured on the built `dist/` of this branch. The ruling covers a floor ABOVE the runtime's only, so it is reported to the seat rather than fixed here. ## Verification <details><summary>Derived gate families (`dispatch-gates.mjs --commands`, run at 17bb144 after the main merge)</summary> 62 derived, 61 run with exit 0, 1 NOT MEASURED: `pnpm check:dual-build-cjs-loads` exit 3 (prerequisite not met, needs a full build; CI runs it). `dispatch-gates.mjs --ran`: "62 derived famil(ies) accounted for — 61 run, 1 NOT-MEASURED". The changeset families re-run on the final head `a08584332`: `check-adr-0087-registration --base origin/main` exit 0 ("1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", `runtime-interface-only` verified on `ProtocolIncompatibleDiagnostic`); `check-changeset-no-major` with this body as the event exit 0 (LEVEL AXIS: `no (narrowing)`, `none`, `patch`); `check-empty-changeset` exit 0. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (Scoped to the touched files.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5kXo3UyYbZ5BSu2Zge --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kXo3UyYbZ5BSu2Zge)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a3009cb commit 7dd5a2f

7 files changed

Lines changed: 180 additions & 13 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/metadata-core': patch
3+
---
4+
5+
fix(metadata-core)!: a package built for a NEWER protocol major than the runtime is told to upgrade the runtime, not to run `migrate meta`
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (runtime-interface-only packages/metadata-core/src/protocol-handshake.ts#ProtocolIncompatibleDiagnostic) The one narrowing is an output member of a published runtime TypeScript interface: `migrateCommand` on `ProtocolIncompatibleDiagnostic` becomes optional. No Zod schema, no spec declaration and no stored representation carries it, so there is nothing for the metadata migration to rewrite and no tombstone; a TypeScript reader that dereferences the member is told by its compiler. -->
11+
12+
**BREAKING** public-surface narrowing of `@objectstack/metadata-core`: `ProtocolIncompatibleDiagnostic.migrateCommand` changes from `string` to optional, and the `OS_PROTOCOL_INCOMPATIBLE` answer's `error.details` omits `migrateCommand` for a package ahead of the runtime. Shipped as `patch`: it is not a metadata-protocol change.
13+
14+
The `OS_PROTOCOL_INCOMPATIBLE` refusal (422) used to prescribe `objectstack migrate meta --from N` for every refused range, taking N from the range's floor major. For a package declaring `engines.protocol: '^19'` on a protocol-18 runtime that sent the operator to `migrate meta --from 19`, a command that cannot apply: migration only moves metadata forward from an older major.
15+
16+
Now the handshake compares the declared floor major with the runtime's protocol major:
17+
18+
- **Floor major above the runtime's** (the package is ahead): the message names the two remedies that can apply, upgrading the runtime to the package's protocol or installing a build of the package for this runtime's protocol. The diagnostic carries no `migrateCommand`, so `error.details` on `POST /api/v1/packages` and `POST /api/v1/marketplace/install-local` holds `requiredRange`, `rangeSource`, `protocolVersion` and `targetMajor` only.
19+
- **Floor major at or below the runtime's**: unchanged, and `details.migrateCommand` is present as before.
20+
21+
The refusal's code and status do not change. A reader of `migrateCommand` (TypeScript, or JSON over the wire) treats an absent member as "no command applies; read `message`". No in-repo code outside tests reads it.

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4147,7 +4147,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
41474147
* The GET listing still serves the entry, marked not loaded (#21822), to
41484148
* whoever reads it; this line is what the boot itself says. The line owes
41494149
* the consequence and the fix, and carries the handshake's own message,
4150-
* which names the replay command (`objectstack migrate meta`).
4150+
* which names the remedy: the replay command (`objectstack migrate meta`),
4151+
* or for a package ahead of this runtime, the runtime upgrade (#26480).
41514152
*/
41524153
private reportProtocolIncompatibleEntry = (
41534154
ctx: PluginContext,

‎packages/cloud-connection/src/marketplace-install-local-protocol-handshake.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,3 +355,39 @@ describe('kernel:ready rehydrate of an already-ledgered incompatible entry', ()
355355
expect(new LocalManifestSource(dir).read(OLD_ID).entry?.version).toBe('2.0.0');
356356
});
357357
});
358+
359+
describe('[#26480] a package AHEAD of this runtime is told to upgrade, never to migrate', () => {
360+
/** The card's reproduction: a floor major one above the running protocol. */
361+
const NEW_MAJOR = PROTOCOL_MAJOR + 1;
362+
const AHEAD_ID = 'com.example.qa-p-future';
363+
const AHEAD = { ...INCOMPATIBLE, id: AHEAD_ID, engines: { protocol: `^${NEW_MAJOR}` } };
364+
365+
it('POST install-local: 422 OS_PROTOCOL_INCOMPATIBLE with no migrateCommand, and nothing is installed', async () => {
366+
const h = await mount(dir);
367+
const res = await h.install({ manifest: AHEAD });
368+
expect(res.status, JSON.stringify(res.payload)).toBe(422);
369+
expect(ApiErrorSchema.safeParse(res.payload.error).success).toBe(true);
370+
const { error } = res.payload;
371+
expect(error.code).toBe('OS_PROTOCOL_INCOMPATIBLE');
372+
expect(error.details).toEqual({
373+
requiredRange: `^${NEW_MAJOR}`,
374+
rangeSource: 'engines.protocol',
375+
protocolVersion: PROTOCOL_VERSION,
376+
targetMajor: NEW_MAJOR,
377+
});
378+
expect(error.message).not.toContain('migrate meta');
379+
expect(error.message).toMatch(new RegExp(`upgrade the runtime to protocol ${NEW_MAJOR}`, 'i'));
380+
expectNothingWritten(h);
381+
});
382+
383+
it('the rehydrate line for an already-ledgered entry names the upgrade, not a migration', async () => {
384+
seedEntry(AHEAD);
385+
const h = await mount(dir);
386+
expect(h.bootRegistrations().map((m) => m.id)).not.toContain(AHEAD_ID);
387+
const said = h.logger.error.mock.calls.map(([m]) => String(m));
388+
expect(said).toHaveLength(1);
389+
expect(said[0]).toContain('OS_PROTOCOL_INCOMPATIBLE');
390+
expect(said[0]).not.toContain('migrate meta');
391+
expect(said[0]).toMatch(new RegExp(`upgrade the runtime to protocol ${NEW_MAJOR}`, 'i'));
392+
});
393+
});

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,3 +397,61 @@ describe('[#24029] a `||` union admits a major when any alternative admits it',
397397
expect((thrown as ProtocolIncompatibleError).status).toBe(422);
398398
});
399399
});
400+
401+
describe('[#26480] a package AHEAD of this runtime is told to upgrade, never to migrate', () => {
402+
// `migrate meta --from N` replays metadata FORWARD from an older major. A
403+
// package whose declared floor major is above this runtime's has nothing to
404+
// migrate here, so the refusal names the two remedies that can apply.
405+
const refuse = (range: string) => {
406+
const r = checkProtocolCompat({ id: 'com.acme.future', engines: { protocol: range } }, RT);
407+
expect(r.status).toBe('incompatible');
408+
if (r.status !== 'incompatible') throw new Error(`'${range}' was not refused under ${RT}`);
409+
return r.diagnostic;
410+
};
411+
412+
it('carries no migrateCommand, and the message names the runtime upgrade and a build for this major', () => {
413+
const d = refuse('^12');
414+
expect(d.code).toBe('OS_PROTOCOL_INCOMPATIBLE');
415+
expect(d.targetMajor).toBe(12);
416+
expect(d).not.toHaveProperty('migrateCommand');
417+
expect(d.message).not.toContain('migrate meta');
418+
expect(d.message).toMatch(/upgrade the runtime to protocol 12/i);
419+
expect(d.message).toContain("a build of 'com.acme.future' for protocol 11");
420+
});
421+
422+
it('holds five majors ahead, and for a union whose every alternative is ahead', () => {
423+
for (const range of ['^16', '>=13', '^13 || ^12']) {
424+
const d = refuse(range);
425+
expect(d).not.toHaveProperty('migrateCommand');
426+
expect(d.message).not.toContain('migrate meta');
427+
}
428+
});
429+
430+
it('the HTTP answer carries the four members that apply, and still declares 422', () => {
431+
let thrown: unknown;
432+
try {
433+
assertProtocolCompat({ id: 'com.acme.future', engines: { protocol: '^12' } }, RT, () => {});
434+
} catch (e) {
435+
thrown = e;
436+
}
437+
expect(isProtocolIncompatibleError(thrown)).toBe(true);
438+
const answer = protocolIncompatibleAnswer(thrown as ProtocolIncompatibleError);
439+
expect(answer.status).toBe(422);
440+
expect(answer.code).toBe('OS_PROTOCOL_INCOMPATIBLE');
441+
expect(answer.details).toEqual({
442+
requiredRange: '^12',
443+
rangeSource: 'engines.protocol',
444+
protocolVersion: RT,
445+
targetMajor: 12,
446+
});
447+
expect(Object.keys(answer.details)).not.toContain('migrateCommand');
448+
});
449+
450+
it('CONTROL: a package behind this runtime keeps its --from prescription', () => {
451+
const d = refuse('^10');
452+
expect(d.migrateCommand).toBe('objectstack migrate meta --from 10');
453+
expect(d.message.endsWith('Run: objectstack migrate meta --from 10')).toBe(true);
454+
// A union that reaches back below this runtime still has metadata to move.
455+
expect(refuse('^10 || ^12').migrateCommand).toBe('objectstack migrate meta --from 10');
456+
});
457+
});

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

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,16 @@ export interface ProtocolIncompatibleDiagnostic {
8484
* lowest of its alternatives', whatever order they are written in.
8585
*/
8686
targetMajor: number | null;
87-
/** The command that resolves the refusal (wired end-to-end by ADR-0087 P2). */
88-
migrateCommand: string;
87+
/**
88+
* The command that resolves the refusal (wired end-to-end by ADR-0087 P2),
89+
* present only when there is metadata to move FORWARD: the declared floor
90+
* major is at or below the runtime's, or is not determinable. A package
91+
* whose floor major is ABOVE the runtime's is ahead of it, and
92+
* `migrate meta` cannot help (#26480): the member is absent and `message`
93+
* names the two remedies that apply, upgrading the runtime or installing a
94+
* build of the package for this runtime's protocol.
95+
*/
96+
migrateCommand?: string;
8997
message: string;
9098
}
9199

@@ -107,7 +115,8 @@ const PROTOCOL_INCOMPATIBLE_BRAND = Symbol.for('objectstack.metadata-core.protoc
107115
*
108116
* The manifest is well-formed, but it declares a range this runtime can never
109117
* satisfy, and the remedy is to change the body
110-
* (`objectstack migrate meta --from N`). That is 422, the status
118+
* (`objectstack migrate meta --from N`, or for a package ahead of the runtime,
119+
* a build of it for this protocol). That is 422, the status
111120
* {@link SchemaValidationError} maps to in this same package. It is NOT 409:
112121
* the error-code ledger keeps 409 for refusals that come from environment
113122
* state, where resubmitting the same body could succeed later. With no
@@ -163,7 +172,10 @@ export function isProtocolIncompatibleError(e: unknown): e is ProtocolIncompatib
163172
export interface ProtocolIncompatibleAnswer {
164173
status: ProtocolIncompatibleError['status'];
165174
code: ProtocolIncompatibleError['code'];
166-
/** The error's own prose, unchanged. Its `Run:` command is `details.migrateCommand`. */
175+
/**
176+
* The error's own prose, unchanged. Its `Run:` command is
177+
* `details.migrateCommand`, when that member is present.
178+
*/
167179
message: string;
168180
details: Pick<
169181
ProtocolIncompatibleDiagnostic,
@@ -182,7 +194,8 @@ export interface ProtocolIncompatibleAnswer {
182194
* shared resolver instead, the five fields survive only inside the prose.
183195
*
184196
* `details` is a CLOSED shape: exactly these five members of
185-
* {@link ProtocolIncompatibleDiagnostic}, named one by one. ⛔ It is not a
197+
* {@link ProtocolIncompatibleDiagnostic}, named one by one, with
198+
* `migrateCommand` left out, never `undefined`, when the diagnostic has none. ⛔ It is not a
186199
* spread of the diagnostic. `packageId` and `runtimeMajor` stay out (the first
187200
* is the id the caller sent, the second is derivable from `protocolVersion`),
188201
* `code` is the answer's own member (an envelope puts it in `error.code`, never
@@ -202,7 +215,7 @@ export function protocolIncompatibleAnswer(err: ProtocolIncompatibleError): Prot
202215
rangeSource: d.rangeSource,
203216
protocolVersion: d.protocolVersion,
204217
targetMajor: d.targetMajor,
205-
migrateCommand: d.migrateCommand,
218+
...(d.migrateCommand !== undefined ? { migrateCommand: d.migrateCommand } : {}),
206219
},
207220
};
208221
}
@@ -367,7 +380,8 @@ function alternativeAdmitsMajor(r: string, runtimeMajor: number): boolean | null
367380
* its floor as the upgrade guidance reads it (`'^10'` → 10, `'>=15 <18'` → 15).
368381
* A `||` union targets its LOWEST alternative's, in whatever order the
369382
* alternatives are written: `'^16 || ^17'` and `'^17 || ^16'` are one range and
370-
* get one `migrate meta --from`.
383+
* get one `migrate meta --from`. A floor above the runtime's major means the
384+
* package is ahead of the runtime, and gets no `migrate meta` at all.
371385
*/
372386
function declaredFloorMajor(range: string): number | null {
373387
let lowest: number | null = null;
@@ -438,14 +452,23 @@ export function checkProtocolCompat(
438452

439453
const packageId = manifest.id ?? '<unknown>';
440454
const targetMajor = declaredFloorMajor(declared.range);
441-
const migrateCommand =
442-
targetMajor !== null
455+
// [#26480] `migrate meta` replays metadata forward from an OLDER major. A
456+
// floor above this runtime's major is a package built for a newer protocol:
457+
// nothing here can migrate it, so the refusal names what can apply instead.
458+
const ahead = targetMajor !== null && targetMajor > runtimeMajor;
459+
const migrateCommand = ahead
460+
? undefined
461+
: targetMajor !== null
443462
? `objectstack migrate meta --from ${targetMajor}`
444463
: `objectstack migrate meta`;
464+
const remedy = ahead
465+
? `The package is built for a newer protocol than this runtime implements. ` +
466+
`Upgrade the runtime to protocol ${targetMajor}, or install a build of '${packageId}' for protocol ${runtimeMajor}`
467+
: `Run: ${migrateCommand}`;
445468
const message =
446469
`package '${packageId}' targets protocol ${declared.range} ` +
447470
`(${declared.source}) but this runtime is protocol ${protocolVersion}. ` +
448-
`This is a major-version break. Run: ${migrateCommand}`;
471+
`This is a major-version break. ${remedy}`;
449472

450473
return {
451474
status: 'incompatible',
@@ -461,7 +484,7 @@ export function checkProtocolCompat(
461484
protocolVersion,
462485
runtimeMajor,
463486
targetMajor,
464-
migrateCommand,
487+
...(migrateCommand !== undefined ? { migrateCommand } : {}),
465488
message,
466489
},
467490
};

‎packages/runtime/src/app-plugin.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,8 @@ export class AppPlugin implements Plugin {
572572
// BEFORE the manifest is decomposed into the registry. A bundle whose
573573
// declared `engines.protocol` excludes this runtime's major fails boot
574574
// fast with the structured OS_PROTOCOL_INCOMPATIBLE diagnostic (naming
575-
// the `migrate meta` command) instead of crashing later in a schema
575+
// the `migrate meta` command, or for a bundle built for a newer
576+
// protocol, the runtime upgrade) instead of crashing later in a schema
576577
// `.parse()` or renderer contract. Absent/unparsable ranges are admitted
577578
// with a warning (grandfathering; never a false rejection).
578579
assertProtocolCompat(sys, undefined, (m) => ctx.logger.warn(`[AppPlugin] ${m}`));

‎packages/runtime/src/domains/packages-install-protocol-incompatible.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,30 @@ describe('POST /api/v1/packages: the no-protocol-service fallback runs the same
204204
expect(d.registry.getPackage(COMPATIBLE.id)).toBeDefined();
205205
});
206206
});
207+
208+
describe('[#26480] POST /api/v1/packages: a package AHEAD of this runtime is told to upgrade, never to migrate', () => {
209+
/** The card's reproduction: a floor major one above the running protocol. */
210+
const NEW_MAJOR = PROTOCOL_MAJOR + 1;
211+
const AHEAD = { ...INCOMPATIBLE, id: 'com.example.qa-p-future', engines: { protocol: `^${NEW_MAJOR}` } };
212+
213+
for (const arm of ['protocol service', 'no-protocol-service fallback'] as const) {
214+
it(`${arm}: the same 422 OS_PROTOCOL_INCOMPATIBLE, with no migrateCommand and the runtime upgrade named`, async () => {
215+
const d = door(arm === 'protocol service' ? {} : { protocol: null });
216+
const r: any = await install(d, AHEAD);
217+
expect(r.response.status).toBe(422);
218+
const error = r.response.body.error;
219+
expect(error.code).toBe('OS_PROTOCOL_INCOMPATIBLE');
220+
expect(error.httpStatus).toBe(422);
221+
expect(ApiErrorSchema.safeParse(error).success).toBe(true);
222+
expect(error.details).toEqual({
223+
requiredRange: `^${NEW_MAJOR}`,
224+
rangeSource: 'engines.protocol',
225+
protocolVersion: PROTOCOL_VERSION,
226+
targetMajor: NEW_MAJOR,
227+
});
228+
expect(error.message).not.toContain('migrate meta');
229+
expect(error.message).toMatch(new RegExp(`upgrade the runtime to protocol ${NEW_MAJOR}`, 'i'));
230+
expect(d.registry.getPackage(AHEAD.id)).toBeUndefined();
231+
});
232+
}
233+
});

0 commit comments

Comments
 (0)