Skip to content

Commit 4cef6e2

Browse files
fix(runtime)!: POST /packages/:id/adopt-orphans refuses a read-only package with 422 WRITABLE_PACKAGE_REQUIRED (#24861)
Fixes #24813 Clause-②: no (narrowing) ## What changed `POST /api/v1/packages/:id/adopt-orphans` now asks `requireWritablePackage` (that is, `isWritablePackage`, the ADR-0070 D2 read-only rule) for the path's id, with the verb `adopt items into`. It asks after `requireManageMetadata` and the walled refusal, and before the protocol is resolved: the order disable, publish, the manifest `PATCH` and delete already use (manage, then walled, then writable, then the service that writes). Adopting into a package the deployment ships is refused `422 WRITABLE_PACKAGE_REQUIRED`: booted by code, installed from the marketplace, or a stored row whose manifest `scope` is `system` / `cloud`. The rule is not re-derived at this door. - `packages/runtime/src/domains/packages.ts`: the gate, one call plus its comment, on the adopt-orphans arm only. - `packages/runtime/src/domains/packages-adopt-orphans-readonly.test.ts`: the pins, 9 cases over a real store (better-sqlite3 `:memory:`, the real `sys_metadata*` objects, a real `ObjectStackProtocolImplementation` over a real `ObjectQL`). The orphans come from the real producer, a package-less `saveMetaItem`, and every refusal reads the rows back. - `.changeset/24813-adopt-orphans-readonly-package.md`: `@objectstack/runtime` `minor` under the launch-window convention for a breaking narrowing, with FROM / TO for an integrator, the `Clause-②` line, and the ADR-0087 disposition `not-required (no-migration-prescription)`. A `CONTRACT_REVIEW_TIER` review is owed before the queue (narrowing); the seat runs it. ## Contract Every input whose answer changes, old to new. A `manage_metadata` caller, not refused by the wall (the `single` posture, no `tenancy` service, the operator rung, or `isSystem`): | Request | Before | After | |---|---|---| | `POST /packages/ID/adopt-orphans` where ID is read-only (code-booted, marketplace-installed, `scope: system`, `scope: cloud`), and the `protocol` service has `reassignOrphanedMetadata` | `200` with `{ success, reassignedCount, reassigned, targetPackageId }`; every package-less `sys_metadata` row rebound to ID | `422`, `error.code: WRITABLE_PACKAGE_REQUIRED`, `error.details.packageId: ID`, `error.details.docs: docs/adr/0070-package-first-authoring.md`; nothing rebound, `reassignOrphanedMetadata` never called | | The same read-only ID where the `protocol` service is absent or has no `reassignOrphanedMetadata` | `501` `Orphan adoption not supported` | `422 WRITABLE_PACKAGE_REQUIRED` (the gate precedes the protocol) | Unchanged, each pinned or already pinned: | Request | Answer (before and after) | |---|---| | Anonymous caller, any ID | `401`, the domain's anonymous floor | | A caller without `manage_metadata`, read-only ID | `403`, the capability gate answers first (pinned here) | | Under `isolated` / `group`, a holder below the operator rung, read-only ID | `403 PERMISSION_DENIED` naming the posture, the walled refusal answers first (pinned here) | | A writable package (an org's own, `scope: project`) | `200`, every orphan bound to it (pinned here, rows read back) | | A writable package, no adopting protocol | `501` (pinned here) | | An ID nothing installed | `200`, the orphans bound to that ID: the predicate treats an unregistered id as writable (pinned here, rows read back) | | No ObjectQL registry | `503` | The response shape of the `200` arm and the request body (`actor`) are untouched. ## Measured **Premise.** `reassignOrphanedMetadata` (`packages/metadata-protocol/src/protocol.ts`, `origin/main` `41646a578`) refuses an organization-scoped request first and then rebinds every package-less row (`package_id` null, empty or the `sys_metadata` sentinel, minus artifact-backed overlays) to `request.targetPackageId`. It asks no writability question and no existence question. So the premise holds and the producer to fix is the door, as the card's Wanted says. **Reproduction at base `41646a578`** (a throwaway probe, not committed; same rig as the pins): adopting into each read-only id answered `200 {"success":true,"reassignedCount":2,...}` and both orphans were bound to it: `[["loose_grid","com.acme.codepkg"],["loose_board","com.acme.codepkg"]]`, and the same for the marketplace, `system` and `cloud` ids. With a protocol that cannot adopt, a read-only id answered `501 NOT_IMPLEMENTED`. **The pins at `9c77155aa`** (committed ahead of the fix, against the unchanged arm): `Tests 5 failed | 4 passed (9)`. The four read-only rows and the gate-precedes-protocol row are red (`expected 200 to be 422`, `expected 501 to be 422`); the two order rows and the two CONTROL rows are green. **At `6d9e82dc4`** (the fix): the new pin file with the seven suites that exercise the adopt-orphans arm or the sibling gates (`packages-capability-gate`, `packages-walled-write-refusal`, `packages-vetted-org-source`, `packages-protocol-handle-typing`, `packages-readonly-gate`, `packages-manifest-patch-readonly`, `package-duplicate-adopt-org-scope.integration`): `Test Files 8 passed (8)`, `Tests 295 passed (295)`. **Ablation**, on the committed fix (`6d9e82dc4`). `scripts/ablation-replace.mjs` (WRAP mode) replaced the gate call with `const readOnly = null;` plus a marker: anchor 1 to 0, marker 0 to 1, blob `3bc96afefdd0` to `4908b0439001`. The test reaches `packages.ts` through `http-dispatcher.ts`'s relative import, so `src` is what runs and no rebuild was needed. Result: `Tests 5 failed | 4 passed (9)`, the same five rows red as on the base. Restore: blob equals HEAD `3bc96afefdd0`, `git diff HEAD` is empty, `git status --porcelain` is clean, marker count 0. ## The dispatch's mechanism assumptions 1. **No writability check in `reassignOrphanedMetadata`:** confirmed by reading (above), and reproduced at the door on the base. 2. **Order:** confirmed and pinned. Disable, publish, the manifest `PATCH` and delete ask manage, then walled, then `requireWritablePackage`, each before it resolves the service that writes. This arm now does the same. When several apply to one request the answer is, first to last: the anonymous floor `401`; no `manage_metadata` `403`; the walled `403 PERMISSION_DENIED`; read-only `422`; no adopting protocol `501`; then the protocol's own answer. 3. **Unknown id:** measured, unchanged. Before and after, adopting into an id nothing installed answers `200` and binds the orphans to that id (base probe: `[["loose_grid","com.acme.nopkg"],["loose_board","com.acme.nopkg"]]`; after: the CONTROL pin). It is not the same defect: `isWritablePackage` documents an unregistered id as a writable ADR-0048 authoring-workspace id, and the authoring door (`saveMetaItem` naming a package) accepts the same id. Noted below, not changed. ## Pin sweep Every test and dogfood case that sends `POST .../adopt-orphans`, read for its target: - `packages-capability-gate.test.ts`, `packages-walled-write-refusal.test.ts`: registry doubles with no `manifests` and no `scope`, so writable. - `packages-vetted-org-source.test.ts`: `scope: 'project'`, writable. - `packages-protocol-handle-typing.test.ts` (`/crm/adopt-orphans`, expects `501`): `getPackage` answers `undefined` and the double has no `manifests`, so writable; it still reaches its `501`. - `wall-package-doors-refusal.dogfood.test.ts`, `wall-environment-metadata-refusal.dogfood.test.ts`: every target is created through `POST /packages` in the same test (authored), and the walled `403` precedes the gate. - `packages/client` (`packages.adoptOrphans`): only its own request-shape tests call it. None names a read-only package, so no pin flips. ## Tests and gates All local readings below are at head `e0844d5ef` (`git rev-parse --short HEAD`), the final commit. - `pnpm --filter '@objectstack/runtime^...' build` (the dependency closure; this diff changes no package in it): verify-lock `VERDICT command-exit 0`. - `pnpm --filter @objectstack/runtime typecheck`: `VERDICT command-exit 0`, with `check:test-typecheck: OK` (27 files / 190 errors / 68 pinned signatures held, unchanged). The new test file is in the `tsconfig.test.json` program (`--listFiles` count 1) and has zero tsc errors, so it needs no ledger entry. - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2`: `Test Files 432 passed (432)`, `Tests 6791 passed | 19 skipped (6810)`, `VERDICT command-exit 0`. - `pnpm lint` (repo-wide `eslint . --no-inline-config`): exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` (67 families, from merge base `41646a578`): 67 exit 0. Two first exited 3 (`PREREQUISITE NOT MET`): `check:type-check-debt`, whose closure build never got a turn on the shared verify lock, and `check:dual-build-cjs-loads`, which needs every package's `dist/`. Re-run: `check:type-check-debt` under the lock, `VERDICT command-exit 0` (`--re-measure: OK`, none above its recorded number); then `check:dual-build-cjs-loads` exit 0 (112 require entry points across 69 packages load). `--ran` reconciliation: `67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN`. - Among them: `check-adr-0087-registration --base origin/main` exit 0 (one declared-breaking changeset carrying `not-required (no-migration-prescription)`); `check-changeset-no-major --base origin/main` exit 0; `check:nul-bytes` exit 0; `check:cross-package-test-inputs` exit 0. - `test:repo` was not run locally: the diff touches no test in that project and the new test reads nothing outside its package. CI owns it, with `Lint & Repo Gates`, `TypeScript Type Check`, `Test Core` and `Build Core` on this PR. - `origin/main` was not merged before opening; CI runs on the merge ref. ## Acceptance notes - **Observation, not filed:** adopting into an id nothing installed binds the environment's orphans to that id at `200` (measured above). By the predicate's documented rule an unregistered id is a writable authoring workspace, so this is the stated contract, not a violation; whether an orphan adoption should require an installed base is a product question this PR does not open. - **Rows already bound:** this PR stops new adoptions into a read-only package. Rows an earlier adoption already bound to one are not touched, and what the next boot does with them was not measured. - **Doc surfaces left as they are (outside the claimed file surface):** `requireWritablePackage`'s doc block in `packages.ts` lists the doors that ask it and does not name this one (the arm's own comment does); the block is on the dispatch's hold-off list. The JSDoc of `packages/client`'s `packages.adoptOrphans` does not mention the read-only refusal. - objectui already hides "Adopt loose items" for a read-only package (objectstack-ai/objectui#13181); this PR refuses at the door itself. --- _Generated by [Claude Code](https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 28ff8a9 commit 4cef6e2

3 files changed

Lines changed: 320 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime)!: `POST /api/v1/packages/:id/adopt-orphans` refuses a read-only package with `422 WRITABLE_PACKAGE_REQUIRED`, as the other package write doors already do
6+
7+
ADR-0070 D2 makes a package the deployment ships read-only: one booted by code, one installed from the marketplace, and one whose manifest `scope` is `system` or `cloud`. `isWritablePackage` is that rule, and every other door that changes a package (`POST /packages` with `overwrite`, `PATCH /packages/:id`, `PATCH /packages/:id/disable`, `POST /packages/:id/publish`, `POST /packages/:id/revert`, `DELETE /packages/:id`) refuses such a package with `422 WRITABLE_PACKAGE_REQUIRED`. The orphan-adoption door never asked the rule, and the protocol's `reassignOrphanedMetadata` asks none of its own: it rebinds every package-less `sys_metadata` row to whatever id it is handed. So a `manage_metadata` holder's adoption into such a package answered `200` and bound the environment's loose items into it. The door now asks the same rule after the capability gate and the walled refusal, and before the protocol is resolved.
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the answer one runtime REST door gives a caller who asks it to adopt the environment's package-less items into a package the deployment ships; the remedy is to adopt them into a package the caller owns, never a rewrite of a package source or a stored row. Rows an earlier adoption already bound to a read-only package are not touched. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
12+
13+
**BREAKING** (a REST door's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
- **FROM:** `POST /api/v1/packages/:id/adopt-orphans` naming a read-only package answered `200` with `{ success, reassignedCount, reassigned, targetPackageId }` and rebound every package-less `sys_metadata` row to that package.
16+
- **TO:** the same request answers `422` with `error.code: 'WRITABLE_PACKAGE_REQUIRED'` and `error.details.packageId`, and rebinds nothing. Because the refusal comes before the protocol is resolved, a deployment whose protocol cannot adopt answers the same `422` for a read-only package where it answered `501`.
17+
- **Unchanged:** a caller without `manage_metadata` is still refused `403` first, and under an `isolated` or `group` tenancy posture a caller below the operator rung is still refused `403 PERMISSION_DENIED` first. A package an organization owns adopts as before, and an id nothing installed still reaches the protocol, which binds the orphans to it as before.
18+
- **The fix for an integrator:** a package the deployment ships is managed by the deployment, not over this API. Adopt the loose items into a package you own (create one with `POST /api/v1/packages` if none exists).
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `POST /packages/:id/adopt-orphans` — the ADR-0070 D2 read-only rule at the
5+
* orphan-adoption door (#24813).
6+
*
7+
* ## The defect
8+
*
9+
* The arm asked `requireManageMetadata` and the walled refusal, and then called
10+
* `protocol.reassignOrphanedMetadata({ targetPackageId: id })`, which asks no
11+
* writability question of its own: it rebinds every package-less
12+
* `sys_metadata` row to whatever id it is handed. So a `manage_metadata`
13+
* holder could bind the environment's loose items INTO a package the
14+
* deployment ships — booted by code, installed from the marketplace, or a
15+
* stored row whose manifest `scope` is `system` / `cloud` — and the door
16+
* answered `200` with the rows it moved. Every other state-changing arm on the
17+
* domain (install-overwrite, disable, publish, revert, the manifest PATCH and
18+
* delete) refuses that same id `422 WRITABLE_PACKAGE_REQUIRED`.
19+
*
20+
* ## What is asserted, and why it is not the status alone
21+
*
22+
* The harm is the rebinding, so every refusal reads the rows back: the
23+
* orphans the real producer minted (a package-less `saveMetaItem`, which still
24+
* lands `package_id = NULL` today) are still package-less after the refusal,
25+
* and the protocol's `reassignOrphanedMetadata` was never called. The store is
26+
* REAL — better-sqlite3 `:memory:`, the real `sys_metadata*` objects, a real
27+
* `ObjectStackProtocolImplementation` over a real `ObjectQL` — so the rows are
28+
* the store's, not a double's. The code-booted and marketplace ids are booted
29+
* through the engine's own `registerApp`; the two scope-only ids are installed
30+
* the way `sys_packages` hydration installs a stored row (`source:
31+
* 'authored'`, no booted manifest), so their refusal can only come from the
32+
* scope signal.
33+
*
34+
* ## Order
35+
*
36+
* The gate sits where its siblings put it: after the capability gate and the
37+
* walled refusal (both `403`, both still answer first on a read-only id), and
38+
* before the protocol is resolved, so a read-only id answers `422` even on a
39+
* deployment whose protocol cannot adopt (`501` for a writable id there).
40+
*
41+
* ## The gate is not an outage
42+
*
43+
* CONTROL: a writable package still adopts (`200`, and the rows really move),
44+
* and an id nothing installed is writable by the predicate's own rule, so it
45+
* still reaches the protocol, which binds the orphans to it exactly as before.
46+
*/
47+
48+
import { describe, it, expect, vi, afterEach } from 'vitest';
49+
import { ObjectQL } from '@objectstack/objectql';
50+
import { SqlDriver } from '@objectstack/driver-sql';
51+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
52+
import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata';
53+
import { HttpDispatcher } from '../http-dispatcher.js';
54+
55+
/** Booted by the deployment's code: `registerApp`, default source `'code'`. */
56+
const CODE_BOOTED = 'com.acme.codepkg';
57+
/** Installed from the marketplace: `registerApp(…, 'marketplace')`. */
58+
const MARKETPLACE = 'com.acme.marketpkg';
59+
/** A stored row whose manifest declares `scope: 'system'`; nothing booted it. */
60+
const SYSTEM_SCOPED = 'com.acme.systempkg';
61+
/** A stored row whose manifest declares `scope: 'cloud'`; nothing booted it. */
62+
const CLOUD_SCOPED = 'com.acme.cloudpkg';
63+
/** An org's own base. */
64+
const AUTHORED = 'com.acme.authoredpkg';
65+
/** An id nothing has installed. */
66+
const UNKNOWN = 'com.acme.nopkg';
67+
/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */
68+
const TEST_PACKAGE_ID = 'objectstack-test';
69+
70+
/** The loose items the real producer leaves behind. */
71+
const ORPHANS = ['loose_grid', 'loose_board'] as const;
72+
73+
const manifest = (id: string, extra: Record<string, unknown> = {}) => ({
74+
id,
75+
name: id,
76+
version: '1.0.0',
77+
type: 'app',
78+
...extra,
79+
});
80+
81+
const TASK = {
82+
name: 'task',
83+
label: 'Task',
84+
fields: {
85+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
86+
name: { name: 'name', type: 'text' as const, label: 'Name' },
87+
},
88+
};
89+
90+
const view = (name: string) => ({
91+
name,
92+
label: name,
93+
object: 'task',
94+
viewKind: 'list',
95+
columns: [{ field: 'name', label: 'Name' }],
96+
});
97+
98+
const silentLogger: any = {
99+
debug() {}, info() {}, warn() {}, error() {}, trace() {}, fatal() {},
100+
child() { return silentLogger; },
101+
};
102+
103+
const liveEngines: ObjectQL[] = [];
104+
afterEach(async () => {
105+
while (liveEngines.length) {
106+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
107+
}
108+
});
109+
110+
interface BootOptions {
111+
/** Compose a protocol that cannot adopt (no `reassignOrphanedMetadata`). */
112+
protocolWithoutAdoption?: boolean;
113+
/** Compose a `tenancy` service answering this posture. */
114+
posture?: 'isolated' | 'group';
115+
}
116+
117+
/** The composed door over a real store, with the orphans already minted. */
118+
async function boot(options: BootOptions = {}) {
119+
const engine = new ObjectQL({ logger: silentLogger });
120+
liveEngines.push(engine);
121+
(engine.registry as unknown as { logLevel: string }).logLevel = 'silent';
122+
engine.registerDriver(new SqlDriver({
123+
client: 'better-sqlite3',
124+
connection: { filename: ':memory:' },
125+
useNullAsDefault: true,
126+
}), true);
127+
await engine.init();
128+
engine.registry.registerObject(TASK as any, TEST_PACKAGE_ID);
129+
engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID);
130+
engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID);
131+
engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID);
132+
await engine.syncSchemas();
133+
134+
engine.registerApp(manifest(CODE_BOOTED));
135+
engine.registerApp(manifest(MARKETPLACE), undefined, 'marketplace');
136+
engine.registry.installPackage(manifest(SYSTEM_SCOPED, { scope: 'system' }) as any, 'authored');
137+
engine.registry.installPackage(manifest(CLOUD_SCOPED, { scope: 'cloud' }) as any, 'authored');
138+
engine.registry.installPackage(manifest(AUTHORED, { scope: 'project' }) as any, 'authored');
139+
140+
const protocol = new ObjectStackProtocolImplementation(engine as any);
141+
// The real producer of an orphan: a save naming no package.
142+
for (const name of ORPHANS) {
143+
await protocol.saveMetaItem({ type: 'view', name, item: view(name), mode: 'publish' });
144+
}
145+
const adopt = vi.spyOn(protocol, 'reassignOrphanedMetadata');
146+
147+
const served = options.protocolWithoutAdoption
148+
? { saveMetaItem: protocol.saveMetaItem.bind(protocol) }
149+
: protocol;
150+
const services: Record<string, unknown> = { protocol: served, objectql: engine };
151+
if (options.posture) services.tenancy = { posture: options.posture };
152+
const get = (name: string) => services[name] ?? null;
153+
const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) };
154+
const dispatcher = new HttpDispatcher(kernel);
155+
156+
/** Each loose item's binding, read off the store. */
157+
const bindings = async (): Promise<Record<string, unknown>> => {
158+
const rows = (await engine.find('sys_metadata', { where: { type: 'view' } })) as any[];
159+
return Object.fromEntries(
160+
rows.filter((r) => (ORPHANS as readonly string[]).includes(r.name)).map((r) => [r.name, r.package_id ?? null]),
161+
);
162+
};
163+
164+
return { engine, dispatcher, adopt, bindings };
165+
}
166+
167+
type Caller = Record<string, unknown>;
168+
/** Holds `manage_metadata`; the operator rung, so no wall refuses it. */
169+
const ADMIN: Caller = { userId: 'u_pkg_admin', systemPermissions: ['manage_metadata', 'studio.access'], posture: 'PLATFORM_ADMIN' };
170+
/** Signed in, without `manage_metadata`. */
171+
const NO_CAPABILITY: Caller = { userId: 'u_reader', systemPermissions: ['studio.access'] };
172+
/** An organization's `manage_metadata` holder below the operator rung. */
173+
const TENANT_AUTHOR: Caller = { userId: 'u_tenant', tenantId: 'org_a', systemPermissions: ['manage_metadata'], posture: 'TENANT_ADMIN' };
174+
175+
type Door = Awaited<ReturnType<typeof boot>>;
176+
const adoptInto = async (d: Door, id: string, caller: Caller = ADMIN) => {
177+
const r = await d.dispatcher.handlePackages(
178+
`/${encodeURIComponent(id)}/adopt-orphans`, 'POST', {}, {},
179+
{ request: {}, environmentId: 'pkg-adopt-orphans-readonly-test', executionContext: caller } as any,
180+
);
181+
expect(r.handled).toBe(true);
182+
return { status: r.response?.status, body: r.response?.body as any };
183+
};
184+
185+
const UNBOUND = Object.fromEntries(ORPHANS.map((n) => [n, null]));
186+
const boundTo = (id: string) => Object.fromEntries(ORPHANS.map((n) => [n, id]));
187+
188+
// ══════════════════════════════════════════════════════════════════════════════
189+
// 1. REFUSED: adopting into a read-only package binds nothing
190+
// ══════════════════════════════════════════════════════════════════════════════
191+
192+
const READ_ONLY: Array<{ label: string; id: string; booted: boolean }> = [
193+
{ label: 'code-booted (registerApp)', id: CODE_BOOTED, booted: true },
194+
{ label: 'marketplace-installed (registerApp, marketplace)', id: MARKETPLACE, booted: true },
195+
{ label: 'system-scoped stored row (scope signal only)', id: SYSTEM_SCOPED, booted: false },
196+
{ label: 'cloud-scoped stored row (scope signal only)', id: CLOUD_SCOPED, booted: false },
197+
];
198+
199+
describe('POST /packages/:id/adopt-orphans on a read-only package is refused', () => {
200+
for (const pkg of READ_ONLY) {
201+
it(`422s the ${pkg.label} package and binds nothing`, async () => {
202+
const d = await boot();
203+
expect(await d.bindings(), 'precondition: the producer left orphans').toEqual(UNBOUND);
204+
expect((d.engine as unknown as { manifests: Map<string, unknown> }).manifests.has(pkg.id),
205+
'precondition: the boot signal').toBe(pkg.booted);
206+
207+
const r = await adoptInto(d, pkg.id);
208+
209+
expect(r.status).toBe(422);
210+
expect(r.body?.success).toBe(false);
211+
expect(r.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED');
212+
expect(r.body?.error?.httpStatus).toBe(422);
213+
expect(r.body?.error?.details?.packageId).toBe(pkg.id);
214+
expect(r.body?.error?.details?.docs).toBe('docs/adr/0070-package-first-authoring.md');
215+
// Nothing was rebound: the protocol was never asked, and the rows say so.
216+
expect(d.adopt).not.toHaveBeenCalled();
217+
expect(await d.bindings()).toEqual(UNBOUND);
218+
}, 30_000);
219+
}
220+
});
221+
222+
// ══════════════════════════════════════════════════════════════════════════════
223+
// 2. ORDER: which refusal answers when more than one applies to a read-only id
224+
// ══════════════════════════════════════════════════════════════════════════════
225+
226+
describe('POST /packages/:id/adopt-orphans — the gate\'s place among the refusals', () => {
227+
it('a caller without manage_metadata is refused 403 by the capability gate first', async () => {
228+
const d = await boot();
229+
230+
const r = await adoptInto(d, CODE_BOOTED, NO_CAPABILITY);
231+
232+
expect(r.status).toBe(403);
233+
expect(r.body?.error?.code).not.toBe('WRITABLE_PACKAGE_REQUIRED');
234+
expect(String(r.body?.error?.message)).toContain('manage_metadata');
235+
expect(d.adopt).not.toHaveBeenCalled();
236+
expect(await d.bindings()).toEqual(UNBOUND);
237+
}, 30_000);
238+
239+
it('under a wall, a tenant holder is refused 403 PERMISSION_DENIED by the walled refusal first', async () => {
240+
const d = await boot({ posture: 'isolated' });
241+
242+
const r = await adoptInto(d, CODE_BOOTED, TENANT_AUTHOR);
243+
244+
expect(r.status).toBe(403);
245+
expect(r.body?.error?.code).toBe('PERMISSION_DENIED');
246+
expect(String(r.body?.error?.message)).toContain('tenancy posture');
247+
expect(d.adopt).not.toHaveBeenCalled();
248+
expect(await d.bindings()).toEqual(UNBOUND);
249+
}, 30_000);
250+
251+
it('the gate precedes the protocol: a read-only id answers 422 where a writable id answers 501', async () => {
252+
const d = await boot({ protocolWithoutAdoption: true });
253+
254+
const readOnly = await adoptInto(d, CODE_BOOTED);
255+
expect(readOnly.status).toBe(422);
256+
expect(readOnly.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED');
257+
258+
const writable = await adoptInto(d, AUTHORED);
259+
expect(writable.status).toBe(501);
260+
expect(writable.body?.error?.code).not.toBe('WRITABLE_PACKAGE_REQUIRED');
261+
expect(await d.bindings()).toEqual(UNBOUND);
262+
}, 30_000);
263+
});
264+
265+
// ══════════════════════════════════════════════════════════════════════════════
266+
// 3. CONTROL: the gate is not an outage
267+
// ══════════════════════════════════════════════════════════════════════════════
268+
269+
describe('POST /packages/:id/adopt-orphans still adopts into what an org owns', () => {
270+
it('a writable package adopts: 200, and every orphan is really bound to it', async () => {
271+
const d = await boot();
272+
273+
const r = await adoptInto(d, AUTHORED);
274+
275+
expect(r.status).toBe(200);
276+
expect(r.body?.data).toMatchObject({ success: true, reassignedCount: ORPHANS.length, targetPackageId: AUTHORED });
277+
expect(d.adopt).toHaveBeenCalledTimes(1);
278+
expect(await d.bindings()).toEqual(boundTo(AUTHORED));
279+
}, 30_000);
280+
281+
it('an id nothing installed is writable by the predicate\'s own rule, so it still reaches the protocol, as before', async () => {
282+
const d = await boot();
283+
expect(d.engine.registry.getPackage(UNKNOWN), 'precondition: nothing installed it').toBeUndefined();
284+
285+
const r = await adoptInto(d, UNKNOWN);
286+
287+
expect(r.status).toBe(200);
288+
expect(r.body?.error?.code).toBeUndefined();
289+
expect(d.adopt).toHaveBeenCalledTimes(1);
290+
expect(await d.bindings()).toEqual(boundTo(UNKNOWN));
291+
}, 30_000);
292+
});

‎packages/runtime/src/domains/packages.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2141,6 +2141,16 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
21412141
// [ADR-0131 D12 items 7–8] Under a wall, the platform operator's.
21422142
const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Adopting orphaned metadata into package "${id}"`);
21432143
if (walled) return walled;
2144+
// [#24813] ADR-0070 D2: a package the deployment ships is not a
2145+
// base, so nothing is adopted into it. `reassignOrphanedMetadata`
2146+
// asks no writability question and rebinds every package-less row
2147+
// to whatever id it is handed, so this door refuses with the answer
2148+
// install-overwrite, disable, publish, revert, the manifest PATCH
2149+
// and delete give. Asked BEFORE the protocol is resolved, so a
2150+
// read-only id answers 422 whether or not this deployment can
2151+
// adopt. An id nothing installed is writable by the predicate's own
2152+
// rule, so it still reaches the protocol.
2153+
const readOnly = requireWritablePackage(deps, qlService, id, 'adopt items into'); if (readOnly) return readOnly;
21442154
const protocol = await resolveProtocol(deps, _context);
21452155
if (!protocol || typeof protocol.reassignOrphanedMetadata !== 'function') {
21462156
return { handled: true, response: deps.error('Orphan adoption not supported', 501) };

0 commit comments

Comments
 (0)