Skip to content

Commit e83c9f6

Browse files
fix(packages): a protocol-incompatible install answers 422 with its structured diagnostic (#21760)
Fixes #21727 Clause-②: yes ## Contract statement - **Code:** `OS_PROTOCOL_INCOMPATIBLE`. It stays registered under `@objectstack/metadata-core`, and its ledger row now states 422. - **Status:** `422`, declared by `ProtocolIncompatibleError` as both `status` and `statusCode`. It used to be the `500` fallback. - **Door:** `POST /api/v1/packages`, in the install branch of `packages/runtime/src/domains/packages.ts`. Both arms answer: the composed protocol primitive and the no-protocol-service fallback. Measured, this is the only HTTP door that reaches `assertProtocolCompat` (H3 on the card). - **Wire:** `error.code` is `'OS_PROTOCOL_INCOMPATIBLE'`, `error.httpStatus` is `422`, and `error.message` is unchanged. `error.details` is exactly `{ requiredRange, rangeSource, protocolVersion, targetMajor, migrateCommand }`. The door hands `deps.error` six members, `code` plus those five, which is the ruling's six. The shared envelope builder (`error-envelope.ts`) promotes `code` into `error.code`, so `details` carries five on the wire. ADR-0112 D5 retires `error.details.code` as a code carrier, and the `FlowActionRefusal` exit in `domains/actions.ts` behaves the same way. - **Unchanged:** `resolveThrownHttpError` and `@objectstack/types`, whose closed `details` list is the maintainer's #9585 ruling; the range check itself; what the composed door accepts. ## Why 422 The manifest is well-formed, but it declares a range this runtime can never satisfy, and the remedy is a body change (`objectstack migrate meta --from N`). `SchemaValidationError` (`METADATA_SCHEMA_INVALID`) in the same package maps to 422. The ledger keeps 409 for refusals that come from environment state (its `FLOW_CONVERSION_CONFLICT` row), where resubmitting the same body could succeed later. Triage ruled 422 in 5980718772. ## Mechanism - **`@objectstack/metadata-core`:** `ProtocolIncompatibleError` declares `status` and `statusCode` 422, and redeclares `code` as the literal `'OS_PROTOCOL_INCOMPATIBLE'` (always equal to `diagnostic.code`). It is stamped with a `Symbol.for` brand, and `isProtocolIncompatibleError(e)` is exported. The brand holds across the ESM and CJS builds and across `src` and `dist`, where `instanceof` would not. A lookalike that copies the field names is not recognised. This is the #9585 `FlowActionRefusal` idiom. - **The door:** the install branch asks `isProtocolIncompatibleError` ahead of its generic catch, and answers through `protocolIncompatibleAnswer`: the error's declared status, its own message, and the five named fields. ⛔ It is not a spread of the diagnostic, so `packageId`, `runtimeMajor` and any member added later stay off the wire. Every other throw rethrows to the unchanged generic catch. - **`@objectstack/spec`:** a comment on the ledger row states 422 and the door. The comment ships in `dist/` and `src/**/*.zod.ts` (measured), so the changeset carries a `minor` for spec. ## Riding this PR, per triage 5980718772 - **The no-protocol-service fallback runs the same handshake.** With no `protocol` service composed, the door used to call `registry.installPackage(manifest, settings)` with no check, which installed a `^16` manifest that the composed door refuses. It now calls `assertProtocolCompat(manifest)` first, with the same default warn hook as the primitive, and the same recognition answers it. A pin covers it. - **`check:error-status-conformance` now sees this producer.** Before this PR the class's code arrived only through `super()`, and the gate's R1 rule reads a `readonly code` literal. Measured: injecting `readonly status = 422` alone left the gate output byte-identical. With the literal code, the scope line moves from 403 to 404 producer sites and from 85 to 86 derived ledger codes, and `--report` names `OS_PROTOCOL_INCOMPATIBLE` among the codes derived but not reconciled (no scanned docs page publishes a status for it). The gate script is unchanged; only the producer moved. ## Pins (ADR-0112: each refusal asserts `code` and `status`) The protocol primitive and the registry are real (`ObjectStackProtocolImplementation` over a `SchemaRegistry`) behind `HttpDispatcher`, in `packages/runtime/src/domains/packages-install-protocol-incompatible.test.ts`: 1. The install answers `422 OS_PROTOCOL_INCOMPATIBLE`. `error.details` holds exactly the five keys, each value equals what the prose states (`targets protocol RANGE (SOURCE)`, `this runtime is protocol VERSION.`, and the message ends with `Run: MIGRATE_COMMAND`), and `migrateCommand` ends `--from TARGET_MAJOR`. The envelope parses against `ApiErrorSchema`. 2. The package is still not installed: `GET /api/v1/packages/com.example.qaold` answers `404 RESOURCE_NOT_FOUND`, and the registry has no row. 3. (Replaced, per triage.) `AppPlugin.init` throws the same `ProtocolIncompatibleError`, recognised by the brand and carrying `status` and `statusCode` 422 (`packages/runtime/src/app-plugin.test.ts`). Also pinned: the fallback refusal (same 422 and details, nothing written), lit controls on both arms (`^17` installs, then 201 and a read-back of 200), and a lookalike throw that is served by the generic catch (500, no `details`). `protocol-handshake.test.ts` pins the declared status, the literal code and the brand predicate. ## Reverse verification From committed `b5bdd1be86`, under a `trap` restore, both mutations went through `scripts/ablation-replace.mjs`. Each anchor hit once, and the blob changed (door `2fc34268` → `838e7074`, error `b15e059f` → `edaaa53b`). - The door recognition was neutered, and the error's `readonly status` / `readonly statusCode` lines were deleted. - `metadata-core` was rebuilt, and `ablation-dist-preflight` confirmed `this.statusCode = 422;` absent from all 12 built files. - Result: `Tests 3 failed | 39 passed (42)`. The failing set was exactly pin 1, the fallback refusal and pin 3, which failed on `expected 500 to be 422` and the missing 422 on the thrown value. Pin 2, the controls and the lookalike stayed green. - The restore used `git checkout HEAD --` on both absolute paths. Both blobs equal HEAD (`2fc34268`, `b15e059f`), and `git diff HEAD` is empty. After a rebuild, preflight confirmed the marker present again and the tree clean, and the rerun was `Tests 42 passed (42)`. ## Measured, not fixed `marketplace-install-local` runs no protocol handshake. Through the plugin's real `POST /api/v1/marketplace/install-local` handler, a manifest with `engines.protocol: "^16"` answered `200 success: true`. It was registered (`manifest.register`), written to the ledger (`com.example.qaold.json`) and schema-synced, with no protocol warning, the same as the `^17` control. This is reported in the os-dev report on the card as an out-of-scope finding. ⛔ It is not fixed here. ## Tests and gates, at `2de914e50e` (after merging `origin/main` at `ea7ff394b6`) - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2`: Test Files 324 passed, Tests 4617 passed and 19 skipped. - `pnpm --filter @objectstack/metadata-core run test`: 17 files, 328 tests passed. - `pnpm --filter @objectstack/spec exec vitest run src/api/error-code-ledger.test.ts`: 21 passed (run at `b5bdd1be86`). - `typecheck`: `@objectstack/runtime` (`tsc --noEmit` plus `check:test-typecheck` OK) and `@objectstack/metadata-core`, both exit 0. - `pnpm --filter @objectstack/spec check:generated`: all 15 generated artifacts up to date. - `node scripts/pm/dispatch-gates.mjs --ran` reports `89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN`, every row with its exit code recorded. - Also run: the 52 artifact-roster commands, and the four symbol-anchor sweeps (`check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors`, `check:adr-anchors`), all exit 0. - Three roster gates need a pull request's context (`check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths`). They are run against this PR after it opens. - `check:dual-build-cjs-loads` and `check:published-readme-exports` first answered PREREQUISITE NOT MET. After a full `turbo run build`, they were rerun green: 106 require entry points across 66 packages load, and 101 published documents check out. - ESLint (a declared narrowing; `pnpm lint` is CI's run): - Command: `eslint --no-inline-config --format json` on the six changed TS files, which gave files 6, errors 0, warnings 0. - Population: every changed file is under the config's `**/*.{ts,…}` block. - Invariance: `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, stated at its own `:326`-`:328`), so this diff cannot move any untouched file's verdict. ## Acceptance notes - **Changeset:** `.changeset/21727-protocol-incompatible-422.md` bumps `@objectstack/metadata-core`, `@objectstack/runtime` and `@objectstack/spec` by `minor`. It carries `Clause-②: yes` and an `adr-0087` `not-required (no-migration-prescription)` marker. The gate reads the changeset as non-breaking. - **The fallback:** the no-protocol-service arm now refuses a manifest it used to install. I read that as restoring ADR-0087 D1's installer contract, which the composed door already kept, not as narrowing a published accept set, so the line stays `Clause-②: yes` as triage ruled. If the review reads it as a narrowing, the line becomes `yes (narrowing)`, and the marker above already answers the disposition. - **Status spelling:** `SchemaValidationError` states its 422 only in a docblock ("Maps to HTTP 422") and declares no `status` member. This class matches it in value, and its declared spelling follows the two-spelling convention `resolveThrownHttpError` reads. - **Gate reach:** `check:error-status-conformance` still skips, silently, any class that declares a status but whose code arrives only through `super()`. Here the producer was made visible. Making the gate report that shape as unresolved would be its own change. - **Consumers (H5):** none branch on `500` for this code. Zero hits in the CLI, the SDK, MCP and objectui main `b508ac5`. The SDK surfaces `error.details` generically, so `err.details.migrateCommand` reaches SDK callers with no client change. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8256a4b commit e83c9f6

7 files changed

Lines changed: 420 additions & 5 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/metadata-core': minor
3+
'@objectstack/runtime': minor
4+
'@objectstack/spec': minor
5+
---
6+
7+
A package install refused by the ADR-0087 D1 protocol handshake now answers `422 OS_PROTOCOL_INCOMPATIBLE`, with its structured diagnostic in `error.details`. It used to answer the `500` server-fault fallback, with the diagnostic only inside the message.
8+
9+
Clause-②: yes
10+
11+
- **`POST /api/v1/packages`:** a manifest whose declared range (`engines.protocol`, then `engines.platform`, then `engine.objectstack`) excludes this runtime's protocol major answers `422`, with `error.code: 'OS_PROTOCOL_INCOMPATIBLE'` and `error.details: { requiredRange, rangeSource, protocolVersion, targetMajor, migrateCommand }`. `error.message` is unchanged, and the command after its `Run:` equals `migrateCommand`. Nothing is installed, so a later `GET /api/v1/packages/{id}` still answers `404`.
12+
- **The no-protocol-service fallback:** in a composition without a `protocol` service, `POST /api/v1/packages` used to install such a manifest. It now runs the same handshake and answers the same `422`.
13+
- **`@objectstack/metadata-core`:** `ProtocolIncompatibleError` declares `status` and `statusCode` `422`, with `code` as the literal `'OS_PROTOCOL_INCOMPATIBLE'`. It also carries a `Symbol.for` brand, and the new `isProtocolIncompatibleError(e)` recognises it across module instances, where `instanceof` would not. Any other caller that resolves the error (the boot-time `AppPlugin` load included) reads `422` rather than the `500` fallback.
14+
- **`@objectstack/spec`:** the error-code ledger's `OS_PROTOCOL_INCOMPATIBLE` row states its status (422) and the door that carries the diagnostic. The vocabulary does not change.
15+
16+
A client that branched on `500` for this code should branch on `422`, or on `error.code`. None was found in this repository, the SDK or the console.
17+
18+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, spelling, export or stored shape is removed or renamed, so `objectstack migrate meta` has nothing to rewrite. The refusal's HTTP status and envelope change and one export is added, but the protocol range check itself is unchanged, and the no-protocol-service fallback now refuses what the composed door already refused. The other categories are closed on facts: the three packages publish (not unpublished); no ADR-0087 id covers this refusal and this diff adds none (not registered or already-registered); and the change moves a wire answer, not only a TS declaration (not runtime-interface-only or type-surface-only). -->

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest';
44
import {
55
assertProtocolCompat,
66
checkProtocolCompat,
7+
isProtocolIncompatibleError,
78
ProtocolIncompatibleError,
89
rangeAdmitsMajor,
910
} from './protocol-handshake.js';
@@ -210,6 +211,40 @@ describe('assertProtocolCompat', () => {
210211
}
211212
});
212213

214+
it('[#21727] the refusal declares 422 in both spellings, and its literal code matches the diagnostic', () => {
215+
let thrown: unknown;
216+
try {
217+
assertProtocolCompat({ id: 'p', engines: { protocol: '^10' } }, RT, () => {});
218+
} catch (e) {
219+
thrown = e;
220+
}
221+
const err = thrown as ProtocolIncompatibleError;
222+
expect(err.status).toBe(422);
223+
expect(err.statusCode).toBe(422);
224+
expect(err.code).toBe('OS_PROTOCOL_INCOMPATIBLE');
225+
expect(err.code).toBe(err.diagnostic.code);
226+
});
227+
228+
it('[#21727] isProtocolIncompatibleError recognises the brand, never a lookalike', () => {
229+
let thrown: unknown;
230+
try {
231+
assertProtocolCompat({ id: 'p', engines: { protocol: '^10' } }, RT, () => {});
232+
} catch (e) {
233+
thrown = e;
234+
}
235+
expect(isProtocolIncompatibleError(thrown)).toBe(true);
236+
// Copying the names is not enough to reach the door's structured channel.
237+
const lookalike = Object.assign(new Error('x'), {
238+
name: 'ProtocolIncompatibleError',
239+
code: 'OS_PROTOCOL_INCOMPATIBLE',
240+
status: 422,
241+
diagnostic: { code: 'OS_PROTOCOL_INCOMPATIBLE' },
242+
});
243+
expect(isProtocolIncompatibleError(lookalike)).toBe(false);
244+
expect(isProtocolIncompatibleError(null)).toBe(false);
245+
expect(isProtocolIncompatibleError('OS_PROTOCOL_INCOMPATIBLE')).toBe(false);
246+
});
247+
213248
it('returns silently on ok', () => {
214249
const warn = vi.fn();
215250
expect(() => assertProtocolCompat({ id: 'p', engines: { protocol: '^11' } }, RT, warn)).not.toThrow();

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,18 +72,71 @@ export interface ProtocolIncompatibleDiagnostic {
7272
message: string;
7373
}
7474

75+
/**
76+
* Brand for {@link ProtocolIncompatibleError}. It uses `Symbol.for`, so
77+
* recognition keeps working when two module instances of this package coexist
78+
* (the ESM and CJS builds, or `src` beside `dist`), where an `instanceof` would
79+
* silently answer false. The runtime's `FlowActionRefusal` uses the same idiom.
80+
*/
81+
const PROTOCOL_INCOMPATIBLE_BRAND = Symbol.for('objectstack.metadata-core.protocolIncompatibleError');
82+
7583
/**
7684
* Structured error thrown at the install/load boundary for an incompatible
7785
* package. Extends `MetadataError` so callers can `instanceof` across package
7886
* boundaries and read `.code`; `.diagnostic` carries the JSON-serializable
7987
* detail for `--json` output and the MCP surface (ADR-0087 D4).
88+
*
89+
* ## The status it declares: 422 (#21727)
90+
*
91+
* The manifest is well-formed, but it declares a range this runtime can never
92+
* satisfy, and the remedy is to change the body
93+
* (`objectstack migrate meta --from N`). That is 422, the status
94+
* {@link SchemaValidationError} maps to in this same package. It is NOT 409:
95+
* the error-code ledger keeps 409 for refusals that come from environment
96+
* state, where resubmitting the same body could succeed later. With no
97+
* declared status, every HTTP door answered the 500 fallback for a manifest
98+
* the caller wrote. Both spellings are declared because doors read either one
99+
* (`resolveThrownHttpError` reads `status`, then `statusCode`).
100+
*
101+
* `code` is redeclared as a literal, always equal to `diagnostic.code`, so that
102+
* `check:error-status-conformance` derives this class as a producer of
103+
* `OS_PROTOCOL_INCOMPATIBLE` at 422. A code that arrives only through `super()`
104+
* is invisible to that gate.
105+
*
106+
* ## The structured diagnostic on the wire
107+
*
108+
* The shared resolver (`resolveThrownHttpError` in `@objectstack/types`)
109+
* builds `details` from a closed list that deliberately drops a thrown
110+
* `.diagnostic` (#8016, #9106, #9585). So the one HTTP door that reaches this
111+
* throw (the install branch of `POST /api/v1/packages`) recognises the error
112+
* with {@link isProtocolIncompatibleError} ahead of its generic catch. It
113+
* answers the declared status and puts the diagnostic's five fields in
114+
* `error.details`. A caller that does not recognise the error still answers
115+
* the right status, without `details`.
80116
*/
81117
export class ProtocolIncompatibleError extends MetadataError {
118+
readonly code = 'OS_PROTOCOL_INCOMPATIBLE' as const;
119+
readonly status = 422;
120+
readonly statusCode = 422;
121+
82122
constructor(public readonly diagnostic: ProtocolIncompatibleDiagnostic) {
83123
super(diagnostic.code, diagnostic.message);
124+
(this as Record<PropertyKey, unknown>)[PROTOCOL_INCOMPATIBLE_BRAND] = true;
84125
}
85126
}
86127

128+
/**
129+
* Recognition predicate for {@link ProtocolIncompatibleError}. A door asks it
130+
* BEFORE its generic catch. It checks the brand rather than `instanceof` (see
131+
* {@link PROTOCOL_INCOMPATIBLE_BRAND}). A foreign object that merely copies the
132+
* field names is not recognised, so no thrower can impersonate the protocol
133+
* refusal's structured channel with a lookalike.
134+
*/
135+
export function isProtocolIncompatibleError(e: unknown): e is ProtocolIncompatibleError {
136+
return typeof e === 'object' && e !== null
137+
&& (e as Record<PropertyKey, unknown>)[PROTOCOL_INCOMPATIBLE_BRAND] === true;
138+
}
139+
87140
/**
88141
* First declared range, protocol-first (ADR-0025 §3.10 #3).
89142
*

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import { AppPlugin } from './app-plugin';
33
import { PluginContext } from '@objectstack/core';
44
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
5+
import { ProtocolIncompatibleError, isProtocolIncompatibleError } from '@objectstack/metadata-core';
56

67
describe('AppPlugin', () => {
78
let mockContext: PluginContext;
@@ -499,6 +500,30 @@ describe('AppPlugin', () => {
499500
expect(mockManifest.register).not.toHaveBeenCalled();
500501
});
501502

503+
it('[#21727] the boot seam throws the same ProtocolIncompatibleError, declaring 422', async () => {
504+
// The one other caller of `assertProtocolCompat` has no HTTP
505+
// answer, so its THROWN VALUE is the boundary. It must be the
506+
// class the packages door recognises (by brand) and must carry the
507+
// declared 422, so any caller that resolves it answers the
508+
// caller-fault status rather than the 500 fallback.
509+
const plugin = new AppPlugin({
510+
manifest: {
511+
id: 'com.test.stale422',
512+
version: '1.0.0',
513+
engines: { protocol: `^${PROTOCOL_MAJOR - 1}` },
514+
},
515+
objects: [],
516+
});
517+
const thrown = await plugin.init(mockContext).then(
518+
() => { throw new Error('expected AppPlugin.init to refuse'); },
519+
(e: unknown) => e,
520+
);
521+
expect(thrown).toBeInstanceOf(ProtocolIncompatibleError);
522+
expect(isProtocolIncompatibleError(thrown)).toBe(true);
523+
expect(thrown).toMatchObject({ code: 'OS_PROTOCOL_INCOMPATIBLE', status: 422, statusCode: 422 });
524+
expect(mockManifest.register).not.toHaveBeenCalled();
525+
});
526+
502527
it('loads a compatible range without warnings', async () => {
503528
const plugin = new AppPlugin({
504529
manifest: {
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21727] `POST /api/v1/packages` answers ADR-0087 D1's protocol refusal as a
5+
* 422 carrying its structured diagnostic, and still installs nothing.
6+
*
7+
* ## The defect this file pins shut
8+
*
9+
* A manifest whose `engines.protocol` range excludes this runtime's major was
10+
* refused, but the refusal reached the caller as
11+
* `500 OS_PROTOCOL_INCOMPATIBLE`, with `requiredRange`, `rangeSource`,
12+
* `protocolVersion`, `targetMajor` and `migrateCommand` present only inside the
13+
* prose. That broke two promises. A manifest the caller wrote was reported as
14+
* a server fault. And the machine-readable diagnostic ADR-0087 D1 promises
15+
* ("a structured diagnostic … a stable error code, the two versions … the exact
16+
* replay command") was not machine-readable.
17+
*
18+
* ## Why the door, and not the shared resolver
19+
*
20+
* `resolveThrownHttpError` (`@objectstack/types`) builds `details` from a
21+
* closed list (`.code` when it is not a string, `.issues`, a validation's
22+
* `.fields`) and drops a thrown `.diagnostic`. The list is closed by the
23+
* maintainer's #9585 ruling, so the install branch recognises the typed
24+
* refusal ahead of its generic catch, which is the `FlowActionRefusal` idiom.
25+
* This is the only HTTP door that reaches `assertProtocolCompat`. The other
26+
* caller, `AppPlugin.init`, is a boot seam, and `app-plugin.test.ts` pins its
27+
* thrown value.
28+
*
29+
* ## The harness
30+
*
31+
* The protocol primitive and the registry are REAL
32+
* (`ObjectStackProtocolImplementation` over a `SchemaRegistry`), so the
33+
* refusal under test is the one production throws, and "not installed" is read
34+
* back through the same door's `GET`. Each refusal asserts the ADR-0112
35+
* envelope (`code` and `status`), never a bare throw. `OS_HOME` is redirected
36+
* because the lit controls really install, and the door persists enable state
37+
* under it.
38+
*/
39+
40+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
41+
import { mkdtempSync, rmSync } from 'node:fs';
42+
import { tmpdir } from 'node:os';
43+
import { join } from 'node:path';
44+
import { SchemaRegistry } from '@objectstack/objectql';
45+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
46+
import { ApiErrorSchema } from '@objectstack/spec/api';
47+
import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel';
48+
import { HttpDispatcher } from '../http-dispatcher.js';
49+
50+
let home: string;
51+
let priorHome: string | undefined;
52+
53+
beforeAll(() => {
54+
priorHome = process.env.OS_HOME;
55+
home = mkdtempSync(join(tmpdir(), 'os-install-protocol-'));
56+
process.env.OS_HOME = home;
57+
});
58+
59+
afterAll(() => {
60+
if (priorHome === undefined) delete process.env.OS_HOME;
61+
else process.env.OS_HOME = priorHome;
62+
rmSync(home, { recursive: true, force: true });
63+
});
64+
65+
const ADMIN = () => ({
66+
request: {},
67+
environmentId: 'pkg-install-protocol-test',
68+
executionContext: {
69+
userId: 'u_pkg_admin',
70+
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
71+
},
72+
}) as any;
73+
74+
/** A major this runtime no longer accepts, derived from the runtime, never a literal. */
75+
const OLD_MAJOR = PROTOCOL_MAJOR - 1;
76+
const OLD_RANGE = `^${OLD_MAJOR}`;
77+
78+
/** The card's reproduction, with the range derived from the running protocol. */
79+
const INCOMPATIBLE = {
80+
id: 'com.example.qaold', name: 'Old', version: '1.0.0', scope: 'project', type: 'app',
81+
engines: { protocol: OLD_RANGE },
82+
};
83+
/** The lit control: the same manifest under a range this runtime admits. */
84+
const COMPATIBLE = { ...INCOMPATIBLE, id: 'com.example.qacurrent', engines: { protocol: `^${PROTOCOL_MAJOR}` } };
85+
86+
/** The five diagnostic members the wire carries: a CLOSED set. */
87+
const DETAIL_KEYS = ['migrateCommand', 'protocolVersion', 'rangeSource', 'requiredRange', 'targetMajor'];
88+
89+
function silentRegistry(): SchemaRegistry {
90+
const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
91+
(registry as unknown as { logLevel: string }).logLevel = 'silent';
92+
return registry;
93+
}
94+
95+
/**
96+
* The composed door: the real protocol primitive over a real registry. Pass
97+
* `protocol: null` for the composition with no `protocol` service, which takes
98+
* the door's fallback arm.
99+
*/
100+
function door(options: { protocol?: unknown } = {}) {
101+
const registry = silentRegistry();
102+
const engine: any = { registry, find: async () => [], manifests: new Map() };
103+
const protocol = options.protocol === undefined
104+
? new ObjectStackProtocolImplementation(engine, () => new Map())
105+
: options.protocol;
106+
const ql: any = { registry, manifests: new Map() };
107+
const kernel: any = {
108+
getService: (name: string) => {
109+
if (name === 'protocol') return protocol ? Promise.resolve(protocol) : null;
110+
if (name === 'objectql') return Promise.resolve(ql);
111+
return null;
112+
},
113+
context: { getService: (name: string) => (name === 'objectql' ? ql : null) },
114+
};
115+
return { dispatcher: new HttpDispatcher(kernel), registry };
116+
}
117+
118+
const install = (d: ReturnType<typeof door>, manifest: unknown) =>
119+
d.dispatcher.handlePackages('', 'POST', { manifest }, {}, ADMIN());
120+
const read = (d: ReturnType<typeof door>, id: string) =>
121+
d.dispatcher.handlePackages(`/${id}`, 'GET', undefined, {}, ADMIN());
122+
123+
/** Pin 1's assertions, shared by both arms of the door. */
124+
function expectStructuredRefusal(r: any) {
125+
expect(r.handled).toBe(true);
126+
expect(r.response.status).toBe(422);
127+
const error = r.response.body.error;
128+
expect(r.response.body.success).toBe(false);
129+
expect(error.code).toBe('OS_PROTOCOL_INCOMPATIBLE');
130+
expect(error.httpStatus).toBe(422);
131+
expect(ApiErrorSchema.safeParse(error).success).toBe(true);
132+
133+
// Exactly the five members, so `code` is in `error.code` (ADR-0112 D5),
134+
// never in `error.details.code`, and nothing beyond the ruled set leaks.
135+
expect(Object.keys(error.details).sort()).toEqual(DETAIL_KEYS);
136+
expect(error.details).toEqual({
137+
requiredRange: OLD_RANGE,
138+
rangeSource: 'engines.protocol',
139+
protocolVersion: PROTOCOL_VERSION,
140+
targetMajor: OLD_MAJOR,
141+
migrateCommand: `objectstack migrate meta --from ${OLD_MAJOR}`,
142+
});
143+
144+
// Each member equals what the prose states, so the two channels cannot drift.
145+
const { requiredRange, rangeSource, protocolVersion, targetMajor, migrateCommand } = error.details;
146+
expect(error.message).toContain(`targets protocol ${requiredRange} (${rangeSource})`);
147+
expect(error.message).toContain(`this runtime is protocol ${protocolVersion}.`);
148+
expect(error.message.endsWith(`Run: ${migrateCommand}`)).toBe(true);
149+
expect(migrateCommand.endsWith(`--from ${targetMajor}`)).toBe(true);
150+
}
151+
152+
describe('POST /api/v1/packages: the protocol refusal is a 422 with its diagnostic (composed door)', () => {
153+
it('pin 1: answers 422 OS_PROTOCOL_INCOMPATIBLE with all five diagnostic fields in error.details', async () => {
154+
expectStructuredRefusal(await install(door(), INCOMPATIBLE));
155+
});
156+
157+
it('pin 2: the refused package is still not installed (GET → 404)', async () => {
158+
const d = door();
159+
await install(d, INCOMPATIBLE);
160+
const r: any = await read(d, INCOMPATIBLE.id);
161+
expect(r.response.status).toBe(404);
162+
expect(r.response.body.error.code).toBe('RESOURCE_NOT_FOUND');
163+
expect(d.registry.getPackage(INCOMPATIBLE.id)).toBeUndefined();
164+
});
165+
166+
it('lit control: a compatible range installs and reads back', async () => {
167+
const d = door();
168+
const r: any = await install(d, COMPATIBLE);
169+
expect(r.response.status).toBe(201);
170+
expect((await read(d, COMPATIBLE.id) as any).response.status).toBe(200);
171+
});
172+
173+
it('recognises the BRAND, not the field names: a lookalike throw is served by the generic catch', async () => {
174+
// A plain Error that copies the code and the diagnostic is not the
175+
// refusal. It must not reach the structured channel; it gets the
176+
// shared resolver's answer (the 500 fallback, no diagnostic).
177+
const lookalike = Object.assign(new Error('lookalike refusal'), {
178+
code: 'OS_PROTOCOL_INCOMPATIBLE',
179+
diagnostic: { requiredRange: OLD_RANGE, migrateCommand: 'x' },
180+
});
181+
const installPackage = vi.fn(async () => { throw lookalike; });
182+
const r: any = await install(door({ protocol: { installPackage } }), INCOMPATIBLE);
183+
expect(installPackage).toHaveBeenCalledTimes(1);
184+
expect(r.response.status).toBe(500);
185+
expect(r.response.body.error.code).toBe('OS_PROTOCOL_INCOMPATIBLE');
186+
expect(r.response.body.error.details).toBeUndefined();
187+
});
188+
});
189+
190+
describe('POST /api/v1/packages: the no-protocol-service fallback runs the same handshake', () => {
191+
it('refuses an incompatible range with the same 422 and diagnostic, and writes nothing', async () => {
192+
const d = door({ protocol: null });
193+
expectStructuredRefusal(await install(d, INCOMPATIBLE));
194+
expect(d.registry.getPackage(INCOMPATIBLE.id)).toBeUndefined();
195+
expect((await read(d, INCOMPATIBLE.id) as any).response.status).toBe(404);
196+
});
197+
198+
it('lit control: a compatible range still installs through the fallback', async () => {
199+
const d = door({ protocol: null });
200+
const r: any = await install(d, COMPATIBLE);
201+
expect(r.response.status).toBe(201);
202+
expect(d.registry.getPackage(COMPATIBLE.id)).toBeDefined();
203+
});
204+
});

0 commit comments

Comments
 (0)