Skip to content
Open
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
12 changes: 12 additions & 0 deletions .changeset/33031-protocol-refusal-below-migration-floor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/metadata-core': patch
---

fix(metadata-core): the protocol refusal for a package declared below the migration floor names the remedies that apply, never a `migrate meta --from` the chain refuses (#33031)

Clause-②: no
Metadata-protocol: none

`OS_PROTOCOL_INCOMPATIBLE` (`checkProtocolCompat`, answered `422` by the package install doors) prescribed `objectstack migrate meta --from N` for every declared major behind the runtime, and that command refuses every `N` under the chain's support floor (`MIGRATION_SUPPORT_FLOOR`, 16, ADR-0087 D3). A manifest declaring `engines.protocol: '^15'` (or `'^0'`) was told to run `--from 15`, which then failed with "the chain's support floor is 16".

Below the floor the refusal now does what it already did for a package ahead of the runtime: `diagnostic.migrateCommand` (and so `error.details.migrateCommand` on the HTTP answer) is absent, and the message names the two remedies that apply instead: install a build of the package for this runtime's protocol, or bring its metadata to protocol 16 by another path first and then run `objectstack migrate meta --from 16`. `^16` and every major from the floor up to the runtime's keep their `Run: objectstack migrate meta --from N` exactly as before, and the status, the code and the other four `details` members are unchanged.
148 changes: 117 additions & 31 deletions packages/metadata-core/src/protocol-handshake.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it, vi } from 'vitest';
import { MIGRATION_SUPPORT_FLOOR } from '@objectstack/spec/migrations';
import {
assertProtocolCompat,
checkProtocolCompat,
Expand All @@ -11,6 +12,10 @@ import {
} from './protocol-handshake.js';

const RT = '11.0.0'; // runtime protocol version used across these tests
// A runtime whose majors one behind it sit at or above `migrate meta`'s support
// floor (16), for the tests that pin a `--from N` prescription: under `RT`,
// every behind major is below that floor and gets none (#33031).
const RT_ABOVE_FLOOR = '18.0.0';

describe('rangeAdmitsMajor', () => {
it('caret pins the major', () => {
Expand Down Expand Up @@ -135,35 +140,35 @@ describe('checkProtocolCompat', () => {
});

it('incompatible with a structured diagnostic when it does not', () => {
const r = checkProtocolCompat({ id: 'com.acme.crm', engines: { protocol: '^10' } }, RT);
const r = checkProtocolCompat({ id: 'com.acme.crm', engines: { protocol: '^17' } }, RT_ABOVE_FLOOR);
expect(r.status).toBe('incompatible');
if (r.status !== 'incompatible') return;
expect(r.diagnostic.code).toBe('OS_PROTOCOL_INCOMPATIBLE');
expect(r.diagnostic.packageId).toBe('com.acme.crm');
expect(r.diagnostic.requiredRange).toBe('^10');
expect(r.diagnostic.requiredRange).toBe('^17');
expect(r.diagnostic.rangeSource).toBe('engines.protocol');
// 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(r.diagnostic.protocolVersion).toBe(RT_ABOVE_FLOOR);
expect(Object.keys(r.diagnostic)).not.toContain('runtimeVersion');
expect(r.diagnostic.targetMajor).toBe(10);
expect(r.diagnostic.migrateCommand).toBe('objectstack migrate meta --from 10');
expect(r.diagnostic.targetMajor).toBe(17);
expect(r.diagnostic.migrateCommand).toBe('objectstack migrate meta --from 17');
// The message names both versions and the command — the whole point of D1.
expect(r.diagnostic.message).toContain('^10');
expect(r.diagnostic.message).toContain('11.0.0');
expect(r.diagnostic.message).toContain('migrate meta --from 10');
expect(r.diagnostic.message).toContain('^17');
expect(r.diagnostic.message).toContain('18.0.0');
expect(r.diagnostic.message).toContain('migrate meta --from 17');
});

it('the diagnostic is identical in shape one major behind or five', () => {
const near = checkProtocolCompat({ id: 'p', engines: { protocol: '^10' } }, RT);
const far = checkProtocolCompat({ id: 'p', engines: { protocol: '^6' } }, RT);
it('the diagnostic is identical in shape one major behind or five, while both are at or above the floor', () => {
const near = checkProtocolCompat({ id: 'p', engines: { protocol: '^20' } }, '21.0.0');
const far = checkProtocolCompat({ id: 'p', engines: { protocol: '^16' } }, '21.0.0');
expect(near.status).toBe('incompatible');
expect(far.status).toBe('incompatible');
if (near.status !== 'incompatible' || far.status !== 'incompatible') return;
expect(Object.keys(near.diagnostic).sort()).toEqual(Object.keys(far.diagnostic).sort());
expect(far.diagnostic.migrateCommand).toBe('objectstack migrate meta --from 6');
expect(far.diagnostic.migrateCommand).toBe('objectstack migrate meta --from 16');
});

it('protocol-first precedence over platform and legacy engine', () => {
Expand Down Expand Up @@ -201,14 +206,14 @@ describe('checkProtocolCompat', () => {
describe('assertProtocolCompat', () => {
it('throws ProtocolIncompatibleError on a positive mismatch', () => {
expect(() =>
assertProtocolCompat({ id: 'p', engines: { protocol: '^10' } }, RT, () => {}),
assertProtocolCompat({ id: 'p', engines: { protocol: '^17' } }, RT_ABOVE_FLOOR, () => {}),
).toThrow(ProtocolIncompatibleError);
try {
assertProtocolCompat({ id: 'p', engines: { protocol: '^10' } }, RT, () => {});
assertProtocolCompat({ id: 'p', engines: { protocol: '^17' } }, RT_ABOVE_FLOOR, () => {});
} catch (e) {
expect(e).toBeInstanceOf(ProtocolIncompatibleError);
expect((e as ProtocolIncompatibleError).code).toBe('OS_PROTOCOL_INCOMPATIBLE');
expect((e as ProtocolIncompatibleError).diagnostic.migrateCommand).toContain('--from 10');
expect((e as ProtocolIncompatibleError).diagnostic.migrateCommand).toContain('--from 17');
}
});

Expand Down Expand Up @@ -270,16 +275,16 @@ describe('assertProtocolCompat', () => {
describe('[#21762] protocolIncompatibleAnswer: the one answer every HTTP door gives the refusal', () => {
function refusalFor(range: string): ProtocolIncompatibleError {
try {
assertProtocolCompat({ id: 'com.acme.crm', engines: { protocol: range } }, RT, () => {});
assertProtocolCompat({ id: 'com.acme.crm', engines: { protocol: range } }, RT_ABOVE_FLOOR, () => {});
} catch (e) {
if (isProtocolIncompatibleError(e)) return e;
throw e;
}
throw new Error(`'${range}' was admitted under ${RT}`);
throw new Error(`'${range}' was admitted under ${RT_ABOVE_FLOOR}`);
}

it('carries the declared status, the code and the error\'s own message', () => {
const err = refusalFor('^10');
const err = refusalFor('^17');
const answer = protocolIncompatibleAnswer(err);
expect(answer.status).toBe(422);
expect(answer.status).toBe(err.status);
Expand All @@ -289,21 +294,21 @@ describe('[#21762] protocolIncompatibleAnswer: the one answer every HTTP door gi
});

it('details are exactly the five diagnostic members, valued from the diagnostic', () => {
const answer = protocolIncompatibleAnswer(refusalFor('^10'));
const answer = protocolIncompatibleAnswer(refusalFor('^17'));
expect(Object.keys(answer.details).sort()).toEqual(
['migrateCommand', 'protocolVersion', 'rangeSource', 'requiredRange', 'targetMajor'],
);
expect(answer.details).toEqual({
requiredRange: '^10',
requiredRange: '^17',
rangeSource: 'engines.protocol',
protocolVersion: RT,
targetMajor: 10,
migrateCommand: 'objectstack migrate meta --from 10',
protocolVersion: RT_ABOVE_FLOOR,
targetMajor: 17,
migrateCommand: 'objectstack migrate meta --from 17',
});
});

it('is a CLOSED shape, not a spread: a member the diagnostic gains does not reach details', () => {
const err = refusalFor('^10');
const err = refusalFor('^17');
const widened = new ProtocolIncompatibleError({
...err.diagnostic,
...({ internalNote: 'must not reach the wire' } as object),
Expand Down Expand Up @@ -448,11 +453,16 @@ describe('[#26480] a package AHEAD of this runtime is told to upgrade, never to
});

it('CONTROL: a package behind this runtime keeps its --from prescription', () => {
const d = refuse('^10');
expect(d.migrateCommand).toBe('objectstack migrate meta --from 10');
expect(d.message.endsWith('Run: objectstack migrate meta --from 10')).toBe(true);
const behind = (range: string) => {
const r = checkProtocolCompat({ id: 'com.acme.past', engines: { protocol: range } }, RT_ABOVE_FLOOR);
if (r.status !== 'incompatible') throw new Error(`'${range}' was not refused under ${RT_ABOVE_FLOOR}`);
return r.diagnostic;
};
const d = behind('^17');
expect(d.migrateCommand).toBe('objectstack migrate meta --from 17');
expect(d.message.endsWith('Run: objectstack migrate meta --from 17')).toBe(true);
// A union that reaches back below this runtime still has metadata to move.
expect(refuse('^10 || ^12').migrateCommand).toBe('objectstack migrate meta --from 10');
expect(behind('^17 || ^19').migrateCommand).toBe('objectstack migrate meta --from 17');
});
});

Expand Down Expand Up @@ -506,9 +516,9 @@ describe('[#26546] the refusal reads the comparator direction, so an exclusive b
});

it('a bounded range targets its floor, whichever order its comparators are written in', () => {
expectBehind('>=15 <18', 15);
expectBehind('<18 >=15', 15);
expectBehind('>15 <18', 16);
expectBehind('>=16 <18', 16);
expectBehind('<18 >=16', 16);
expectBehind('>16 <18', 17);
});

it('a union still targets its lowest alternative', () => {
Expand Down Expand Up @@ -565,3 +575,79 @@ describe('[#30326] assertProtocolCompat answers the verdict it admitted the pack
expect(admitted).toEqual(checkProtocolCompat(manifest, RT));
});
});

describe('[#33031] a package BELOW the migration floor is never told to run a `--from` the chain refuses', () => {
// `objectstack migrate meta --from N` refuses every N under
// `MIGRATION_SUPPORT_FLOOR` (ADR-0087 D3). Below it the refusal names the
// remedies that apply, the way the AHEAD arm does, and carries no command.
const refuse = (range: string, runtime = RT_ABOVE_FLOOR) => {
const r = checkProtocolCompat({ id: 'com.acme.legacy', engines: { protocol: range } }, runtime);
expect(r.status).toBe('incompatible');
if (r.status !== 'incompatible') throw new Error(`'${range}' was not refused under ${runtime}`);
return r.diagnostic;
};

it('the floor this module reads is the spec\'s: a --from is prescribed exactly at and above MIGRATION_SUPPORT_FLOOR', () => {
const runtimeMajor = MIGRATION_SUPPORT_FLOOR + 3;
const prescribed: number[] = [];
const withheld: number[] = [];
for (let major = 0; major < runtimeMajor; major++) {
const d = refuse(`^${major}`, `${runtimeMajor}.0.0`);
expect(d.targetMajor).toBe(major);
(d.migrateCommand === undefined ? withheld : prescribed).push(major);
}
expect(withheld).toEqual(Array.from({ length: MIGRATION_SUPPORT_FLOOR }, (_, i) => i));
expect(prescribed).toEqual([MIGRATION_SUPPORT_FLOOR, MIGRATION_SUPPORT_FLOOR + 1, MIGRATION_SUPPORT_FLOOR + 2]);
});

it.each([15, 0])('^%i carries no migrateCommand, and names a build for this protocol and the floor', (major) => {
const d = refuse(`^${major}`);
expect(d.code).toBe('OS_PROTOCOL_INCOMPATIBLE');
expect(d.targetMajor).toBe(major);
expect(d).not.toHaveProperty('migrateCommand');
// The command the chain refuses is never offered, nor a `Run:` line for one.
expect(d.message).not.toContain(`--from ${major}`);
expect(d.message).not.toContain('Run:');
expect(d.message).toContain("a build of 'com.acme.legacy' for protocol 18");
expect(d.message).toContain(`protocol ${MIGRATION_SUPPORT_FLOOR}`);
});

it('a union whose lowest alternative is below the floor targets it, and is withheld too', () => {
const d = refuse('^15 || ^17');
expect(d.targetMajor).toBe(15);
expect(d).not.toHaveProperty('migrateCommand');
});

it('AT the floor: ^16 keeps prescribing --from 16', () => {
const d = refuse('^16');
expect(d.targetMajor).toBe(MIGRATION_SUPPORT_FLOOR);
expect(d.migrateCommand).toBe('objectstack migrate meta --from 16');
expect(d.message.endsWith('Run: objectstack migrate meta --from 16')).toBe(true);
});

it('CONTROL: a major between the floor and the runtime keeps its --from, and an undeterminable target its bare command', () => {
const d = refuse('^17');
expect(d.migrateCommand).toBe('objectstack migrate meta --from 17');
expect(d.message.endsWith('Run: objectstack migrate meta --from 17')).toBe(true);
expect(refuse('<0').migrateCommand).toBe('objectstack migrate meta');
});

it('the HTTP answer carries the four members that apply, and still declares 422', () => {
let thrown: unknown;
try {
assertProtocolCompat({ id: 'com.acme.legacy', engines: { protocol: '^15' } }, RT_ABOVE_FLOOR, () => {});
} catch (e) {
thrown = e;
}
expect(isProtocolIncompatibleError(thrown)).toBe(true);
const answer = protocolIncompatibleAnswer(thrown as ProtocolIncompatibleError);
expect(answer.status).toBe(422);
expect(answer.code).toBe('OS_PROTOCOL_INCOMPATIBLE');
expect(answer.details).toEqual({
requiredRange: '^15',
rangeSource: 'engines.protocol',
protocolVersion: RT_ABOVE_FLOOR,
targetMajor: 15,
});
});
});
50 changes: 42 additions & 8 deletions packages/metadata-core/src/protocol-handshake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@
import { PROTOCOL_VERSION } from '@objectstack/spec/kernel';
import { MetadataError } from './errors.js';

/**
* The oldest protocol major `objectstack migrate meta --from N` replays from:
* the value of `MIGRATION_SUPPORT_FLOOR` (`@objectstack/spec/migrations`,
* ADR-0087 D3), below which `--from N` refuses. {@link checkProtocolCompat}
* prescribes no `--from N` under it (#33031).
*
* Spelled here, not imported: that entry carries the whole migration registry
* (about 2.9 MB built, kept off the spec root for the same reason), and every
* install door and browser bundle that reaches this module would load it to
* read one number. `protocol-handshake.test.ts` holds the two equal, so a
* release-policy ruling that moves the spec's floor reds this package's tests
* until this copy moves with it.
*/
const MIGRATE_META_SUPPORT_FLOOR = 16;

/** The manifest slice the handshake reads. All fields optional. */
export interface ProtocolHandshakeManifest {
id?: string;
Expand Down Expand Up @@ -101,12 +116,17 @@ export interface ProtocolIncompatibleDiagnostic {
targetMajor: number | null;
/**
* The command that resolves the refusal (wired end-to-end by ADR-0087 P2),
* present only when there is metadata to move FORWARD: `targetMajor` is at
* or below the runtime's, or is not determinable. A package whose
* `targetMajor` is ABOVE the runtime's is ahead of it, and
* `migrate meta` cannot help (#26480): the member is absent and `message`
* names the two remedies that apply, upgrading the runtime or installing a
* build of the package for this runtime's protocol.
* present only when there is metadata to move FORWARD that the chain can
* replay: `targetMajor` is at or below the runtime's and at or above the
* chain's support floor (`MIGRATION_SUPPORT_FLOOR`, ADR-0087 D3), or is not
* determinable. A package whose `targetMajor` is ABOVE the runtime's is ahead
* of it, and `migrate meta` cannot help (#26480): the member is absent and
* `message` names the two remedies that apply, upgrading the runtime or
* installing a build of the package for this runtime's protocol. A package
* whose `targetMajor` is BELOW the floor is one `migrate meta --from` refuses
* (#33031): the member is absent and `message` names the two remedies that
* apply, installing a build for this runtime's protocol or bringing the
* metadata up to the floor by another path before `--from <floor>`.
*/
migrateCommand?: string;
message: string;
Expand Down Expand Up @@ -505,15 +525,29 @@ export function checkProtocolCompat(
// target above this runtime's major is a package built for a newer protocol:
// nothing here can migrate it, so the refusal names what can apply instead.
const ahead = targetMajor !== null && targetMajor > runtimeMajor;
const migrateCommand = ahead
// [#33031] A target below the chain's support floor is one `migrate meta
// --from N` refuses (`MigrationFloorError`), so prescribing it hands the
// author a command known to fail. `--from <floor>` is no machine-actionable
// substitute either: it starts at the floor, so the hops below it are never
// replayed, yet `--write` still moves the declared range (the CLI's
// `planProtocolRange` presumes the floor was reached by another path). The
// refusal names what can apply instead, in prose, with no `migrateCommand`.
const belowFloor = targetMajor !== null && targetMajor < MIGRATE_META_SUPPORT_FLOOR;
const migrateCommand = ahead || belowFloor
? undefined
: targetMajor !== null
? `objectstack migrate meta --from ${targetMajor}`
: `objectstack migrate meta`;
const remedy = ahead
? `The package is built for a newer protocol than this runtime implements. ` +
`Upgrade the runtime to protocol ${targetMajor}, or install a build of '${packageId}' for protocol ${runtimeMajor}`
: `Run: ${migrateCommand}`;
: belowFloor
? `The package is built for protocol ${targetMajor}, older than the oldest protocol ` +
`objectstack migrate meta replays from (${MIGRATE_META_SUPPORT_FLOOR}, ADR-0087 D3). ` +
`Install a build of '${packageId}' for protocol ${runtimeMajor}, or bring its metadata to ` +
`protocol ${MIGRATE_META_SUPPORT_FLOOR} by another path first and then migrate it with ` +
`objectstack migrate meta --from ${MIGRATE_META_SUPPORT_FLOOR}`
: `Run: ${migrateCommand}`;
const message =
`package '${packageId}' targets protocol ${declared.range} ` +
`(${declared.source}) but this runtime is protocol ${protocolVersion}. ` +
Expand Down
5 changes: 4 additions & 1 deletion packages/runtime/src/app-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { AppPlugin } from './app-plugin';
import { PluginContext } from '@objectstack/core';
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
import { ProtocolIncompatibleError, isProtocolIncompatibleError } from '@objectstack/metadata-core';
// Paid at collection, not inside a clocked case: app-plugin.ts loads it lazily
// (check-test-source-alias).
import '@objectstack/metadata';

describe('AppPlugin', () => {
let mockContext: PluginContext;
Expand Down Expand Up @@ -494,7 +497,7 @@ describe('AppPlugin', () => {
code: 'OS_PROTOCOL_INCOMPATIBLE',
diagnostic: expect.objectContaining({
packageId: 'com.test.stale',
migrateCommand: `objectstack migrate meta --from ${PROTOCOL_MAJOR - 5}`,
targetMajor: PROTOCOL_MAJOR - 5,
}),
});
expect(mockManifest.register).not.toHaveBeenCalled();
Expand Down
Loading
Loading