Skip to content

Commit 9b2953d

Browse files
fix(runtime)!: PATCH /packages/:id refuses a read-only package with 422 WRITABLE_PACKAGE_REQUIRED (#24719)
Fixes #24705 Clause-②: no (narrowing) ## What changed `PATCH /api/v1/packages/:id` now asks `requireWritablePackage` (that is, `isWritablePackage`, the ADR-0070 D2 read-only rule) for the path's id. It asks after `requireManageMetadata` and the walled refusal, and before the body is read, in the order install-overwrite, disable, publish, revert and delete already use (manage, then walled, then writable). A manifest edit of 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 `PATCH /packages/:id` arm only. Verb `edit`, so the refusal reads "Cannot edit package 'ID': it is read-only (provided by code or an installed app)...". - `packages/runtime/src/domains/packages-manifest-patch-readonly.test.ts`: the pins, 38 cases on both limbs (the `protocol` service's `updatePackage`, and the no-protocol registry fallback). - `.changeset/24705-patch-manifest-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. ## Answers, path by path | Request | Before | After | |---|---|---| | `PATCH` on a read-only id, with `name` / `description` / `version` (flat, or under `manifest`) | `200`, manifest edited in the registry (and `sys_packages` written on the protocol limb) | `422 WRITABLE_PACKAGE_REQUIRED`, nothing written | | `PATCH` on a read-only id, with a body the arm itself refuses (`{}`, a non-SemVer `version`, a `dependencies`-only body on today's `main`) | `400` | `422 WRITABLE_PACKAGE_REQUIRED`: the gate precedes the body | | `PATCH` on a package an organization owns | `200` / `400` per the body | unchanged | | `PATCH` on an id nothing installed | `404` | `404`, unchanged | ## Serial with PR #24703 PR #24703 (#24670) edits the body handling of this same arm a few lines below the gate, adding `dependencies`. This PR is built on `origin/main` without it, does not touch the body handling, and lands first; PR #24703 then merges `main`. Because the gate is asked before the body is read, the `{ dependencies: {} }` refusal rows hold on both trees: on today's `main` that body answered `400` before this gate, and with PR #24703 it would answer `200`; both become `422` for a read-only id. ## Measured **Reproduction at base `1c1144fcd`.** The new pin file, committed ahead of the fix (`ade20c179`), against the unchanged arm: `Tests 32 failed | 6 passed (38)`. Every refusal row is red, with `expected 200 to be 422` for the editable bodies and `expected 400 to be 422` for the `{ dependencies }` and `{}` bodies. Every CONTROL row is green. **At head `5697a4d22`:** the new pin file run together with the seven suites that exercise the `/packages` arms (`packages-readonly-gate`, `packages-install-overwrite-readonly`, `packages-capability-gate`, `packages-walled-write-refusal`, `error-passthrough`, `packages-single-door`, `http-dispatcher`): `Test Files 8 passed (8)`, `Tests 459 passed (459)`. **Ablation**, on the committed fix (`5697a4d22`). `scripts/ablation-replace.mjs` replaced the gate call `requireWritablePackage(deps, qlService, id, 'edit')` with `null` plus a marker: anchor 1 to 0, marker 0 to 1, blob `d00fe003b219` to `90eadd49957b`. The test reaches `packages.ts` through `http-dispatcher.ts`'s relative import, so `src` is what runs and no rebuild was needed. Result: `Tests 32 failed | 6 passed (38)`. The 32 refusal rows went red; the 6 CONTROL rows stayed green. Restore: blob equals HEAD `d00fe003b219`, `git diff HEAD` is empty, and `git status --porcelain` is clean. ## The dispatch's mechanism assumptions 1. **Order:** confirmed. The arm called `requireManageMetadata` and `refuseWalledCatalogPackageWrite` and nothing else. Disable (`:1602`), publish (`:1629`) and delete (`:2309`) ask manage, then walled, then `requireWritablePackage`; delete reads its `keepData` arity between the second and third, and revert (`:2114`) asks the gate after the protocol's stored-row answer. This arm uses manage, walled, writable. 2. **Unknown id:** `isWritablePackage` answers writable for a non-empty id that no booted manifest and no registry row name, so the gate falls through and the arm's own `404` stands. Pinned on both limbs: the fallback's `registry.updatePackageManifest` answers `undefined`, and the protocol's `updatePackage` throws its `404`. The predicate's empty-id branch (not writable) is unreachable here, because the domain splits the path with `filter(Boolean)`. 3. **Precedent:** followed `62f959dd6` (PR #24055): the same changeset form, ADR-0087 marker and pin shape (a real `ObjectQL` engine, `registerApp` for the code and marketplace kinds, a stored row for the scope-only kinds, both limbs, spies on every write, CONTROL rows on the same spies). 4. **Who sends this PATCH:** - objectui at the `.objectui-sha` pin `12dc7b47c` (read with `git show` at that commit, nothing checked out): the one caller is `PackageFormDialog` in edit mode, opened by `EditPackageDialog` from the Packages page's detail sheet (`packages/app-shell/src/views/metadata-admin/PackagesPage.tsx`). It always sends `{ name, description, version }`. - Which packages the dialog offers to edit: the sheet shows the Edit button, beside Disable, Publish, Revert and Delete, whenever `isKernel` is false, and `isKernel` is `manifest.scope === 'system' || manifest.scope === 'cloud'`. So a `system` / `cloud` package is never offered Edit. A code-booted or marketplace-installed package whose manifest `scope` is `project` or absent is offered Edit; that is the case that now answers `422`, as Disable, Publish, Revert and Delete on the same sheet already do for it. The dialog's fall-through error arm renders the envelope's message, so the person reads the refusal sentence (static read; not measured in a browser). - `packages/client`: `packages.update(id, patch)` sends this PATCH; only its own tests call it. The CLI has no caller. - No test or dogfood case in this repo asserts a `200` PATCH on a read-only package (sweep below), so no pin flips. ## Full-repo pin sweep Searched for every test, dogfood and conformance case that sends `PATCH` to `/packages/:id` (not `/disable` or `/enable`), and for `WRITABLE_PACKAGE_REQUIRED` across the repo. Each hit names a writable or unknown package, so none flips: - `http-dispatcher.test.ts` (5 cases), `error-passthrough.test.ts`, `packages-capability-gate.test.ts`: engine doubles with no `manifests` and no `scope`, so writable. - `dispatcher-body-after-anonymous-floor.hono.integration.test.ts` (`pkg_x`): unknown, so writable; it still reaches the body. - `packages-walled-write-refusal.test.ts` and `wall-environment-metadata-refusal.dogfood.test.ts`: the walled `403` precedes the gate; the CONTROL operator edit there targets a package the same test created through `POST /packages` (authored). - `docs/qa/platform-checklist/areas/api-backend.json`: the manifest-edit step uses a scratch package the item installs itself. ## Tests and gates (local, at `5697a4d22`) - `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`. The new test file is in the `tsconfig.test.json` program (`--listFiles` count 1) and carries no ledger entry, so it has no tsc error. - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2`: `Test Files 427 passed (427)`, `Tests 6759 passed | 19 skipped (6778)`, `VERDICT command-exit 0`. - `pnpm lint` (repo-wide `eslint . --no-inline-config`): exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` (66 families, from merge base `1c1144fcd`): 66 exit 0. `check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`: not every package had a `dist/`); re-run after `check:type-check-debt` built every package: exit 0, 111 require entry points across 69 packages load. `--ran` reconciliation: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN. - `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 --event` with this body as the payload: exit 0, level axis `no (narrowing)` with the one moved package graded `minor`. - `test:repo` was not run locally: the new test reads nothing outside its package (`check:cross-package-test-inputs` exit 0). CI owns it. `Lint & Repo Gates`, `TypeScript Type Check`, `Test Core` and `Build Core`: CI on this PR. - `origin/main` was not merged before opening. Main has gained six commits since the base (`73d4cc739` to `54159fd74`). None touches `packages/runtime`, `package-writability.ts`, `protocol.updatePackage` or the registry's package methods; the `objectql` and `metadata-protocol` changes are in hooks, validation and the seed loader. CI runs on the merge ref. ## Acceptance notes - **Observation, not filed (a static read; not reproduced in a browser):** objectui's Packages detail sheet decides which action buttons to show from `manifest.scope` (`isKernel`), while its Scope badge reads the served `writable` verdict. So a code-booted or marketplace package whose `scope` is `project` or absent is offered Edit, Disable, Publish, Revert and Delete, and each of those now answers `422`. Read at the pin `12dc7b47c`, `packages/app-shell/src/views/metadata-admin/PackagesPage.tsx`. - **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 JSDoc of `packages/client`'s `packages.update` does not mention the read-only refusal. - **Carried from the card, still NOT measured:** what a code package's stored `sys_packages` row does on the next boot after an edit. This PR stops new edits; rows an earlier edit wrote are not touched. --- _Generated by [Claude Code](https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4bc3bec commit 9b2953d

3 files changed

Lines changed: 256 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)!: `PATCH /api/v1/packages/:id` refuses a read-only package with `422 WRITABLE_PACKAGE_REQUIRED`, as install-overwrite, disable, publish, revert and delete 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/disable`, `POST /packages/:id/publish`, `POST /packages/:id/revert`, `DELETE /packages/:id`) refuses such a package with `422 WRITABLE_PACKAGE_REQUIRED`. The manifest-edit door never asked the rule. So a `manage_metadata` holder's edit of such a package's `name`, `description` or `version` answered `200` and changed its manifest, and with a `package` service composed it also re-persisted that manifest to the package's `sys_packages` row. The door now asks the same rule before it reads the body, so the answer is the same whatever keys the body carries.
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 edit the manifest of a package the deployment ships; the remedy is to edit a package the caller owns or to customise beside the shipped package, never a rewrite of a package source or a stored row. 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:** `PATCH /api/v1/packages/:id` naming a read-only package, with `name`, `description` or `version` in the body (flat, or under `manifest`), answered `200` and edited that package's manifest, and its `sys_packages` row when a `package` service is composed.
16+
- **TO:** the same request answers `422` with `error.code: 'WRITABLE_PACKAGE_REQUIRED'` and `error.details.packageId`, and writes nothing: no registry edit, no `sys_packages` row. It is the refusal the other package write doors give. Because the refusal comes before the body is read, a body this door would otherwise answer `400` (an empty patch, a version that is not SemVer) is answered the same `422` for a read-only package.
17+
- **Unchanged:** a package an organization owns edits as before, its body checks still answer `400`, and an id nothing installed still answers `404`.
18+
- **The fix for an integrator:** a package the deployment ships is managed by the deployment (its code release, or a new marketplace version), not over this API. Edit the manifest of a package you own, or customise what the shipped package provides beside it as environment-level state (ADR-0131 D6).
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `PATCH /packages/:id` — the ADR-0070 D2 read-only rule at the manifest-edit
5+
* door (#24705).
6+
*
7+
* ## The defect
8+
*
9+
* The arm asked `requireManageMetadata` and the walled refusal, and nothing
10+
* else, so a `manage_metadata` holder could edit the manifest of a package the
11+
* deployment ships: `{ description }` on a package whose manifest declares
12+
* `scope: 'system'` answered `200` and edited it, and with a `package` service
13+
* composed the edited manifest was re-persisted to `sys_packages`. Every other
14+
* state-changing arm on the domain — install-overwrite, disable, publish,
15+
* revert and delete — refuses that same id `422 WRITABLE_PACKAGE_REQUIRED`.
16+
*
17+
* ## What is asserted, and why it is not the status alone
18+
*
19+
* The harm is the edited manifest and its persisted row, so every refusal
20+
* reads the observables: the registry row's content is what it was (the
21+
* registry's `updatePackageManifest` edits the row IN PLACE, so the snapshot is
22+
* taken by value), `updatePackageManifest` was never called, and on the
23+
* protocol limb neither `protocol.updatePackage` nor the `package` service's
24+
* `publish` (the `sys_packages` write) ran. The engine is a REAL `ObjectQL`:
25+
* the code-booted and marketplace ids are booted through its own `registerApp`,
26+
* and the two scope-only ids are installed the way `sys_packages` hydration
27+
* installs a stored row (`source: 'authored'`, no booted manifest), so their
28+
* refusal can only come from the scope signal.
29+
*
30+
* ## The gate precedes the body
31+
*
32+
* The refusal is the whole arm's, asked before the body is read, so it is the
33+
* same answer whatever keys the body carries: an editable key, `dependencies`,
34+
* the `{ manifest }` wrapper, and a body the arm would itself refuse `400`.
35+
*
36+
* ## The gate is not an outage
37+
*
38+
* CONTROL: a writable package still edits (`200`, and the row and
39+
* `sys_packages` really move, on the same spies), its body checks still answer
40+
* `400`, and an id nothing installed still answers the arm's own `404` — the
41+
* predicate treats an unknown id as writable, so the gate never re-labels it.
42+
*/
43+
44+
import { describe, it, expect, vi } from 'vitest';
45+
import { ObjectQL } from '@objectstack/objectql';
46+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
47+
import { HttpDispatcher } from '../http-dispatcher.js';
48+
49+
/** Booted by the deployment's code: `registerApp`, default source `'code'`. */
50+
const CODE_BOOTED = 'com.acme.codepkg';
51+
/** Installed from the marketplace: `registerApp(…, 'marketplace')`. */
52+
const MARKETPLACE = 'com.acme.marketpkg';
53+
/** A stored row whose manifest declares `scope: 'system'`; nothing booted it. */
54+
const SYSTEM_SCOPED = 'com.acme.systempkg';
55+
/** A stored row whose manifest declares `scope: 'cloud'`; nothing booted it. */
56+
const CLOUD_SCOPED = 'com.acme.cloudpkg';
57+
/** An org's own base. */
58+
const AUTHORED = 'com.acme.authoredpkg';
59+
/** An id nothing has installed. */
60+
const UNKNOWN = 'com.acme.nopkg';
61+
62+
const manifest = (id: string, extra: Record<string, unknown> = {}) => ({
63+
id,
64+
name: id,
65+
version: '1.0.0',
66+
description: 'as shipped',
67+
type: 'app',
68+
...extra,
69+
});
70+
71+
const silentLogger: any = {
72+
debug() {}, info() {}, warn() {}, error() {}, trace() {}, fatal() {},
73+
child() { return silentLogger; },
74+
};
75+
76+
type Limb = 'protocol' | 'fallback';
77+
const LIMBS: Limb[] = ['protocol', 'fallback'];
78+
79+
/**
80+
* The composed door over a real engine. `protocol` resolves the real protocol
81+
* primitive with a `package` service double standing in for `sys_packages`;
82+
* `fallback` resolves no protocol service, so the door takes its bare
83+
* registry edit.
84+
*/
85+
function door(limb: Limb) {
86+
const engine = new ObjectQL({ logger: silentLogger });
87+
(engine.registry as unknown as { logLevel: string }).logLevel = 'silent';
88+
engine.registerApp(manifest(CODE_BOOTED));
89+
engine.registerApp(manifest(MARKETPLACE), undefined, 'marketplace');
90+
engine.registry.installPackage(manifest(SYSTEM_SCOPED, { scope: 'system' }) as any, 'authored');
91+
engine.registry.installPackage(manifest(CLOUD_SCOPED, { scope: 'cloud' }) as any, 'authored');
92+
engine.registry.installPackage(manifest(AUTHORED, { scope: 'project' }) as any, 'authored');
93+
94+
const packageService = { publish: vi.fn(async () => ({ success: true })) };
95+
const services = new Map<string, unknown>([['package', packageService]]);
96+
const protocol = limb === 'protocol'
97+
? new ObjectStackProtocolImplementation(engine as any, () => services)
98+
: null;
99+
const kernel: any = {
100+
getService: (name: string) => {
101+
if (name === 'protocol') return protocol ? Promise.resolve(protocol) : null;
102+
if (name === 'objectql') return Promise.resolve(engine);
103+
return null;
104+
},
105+
context: { getService: (name: string) => (name === 'objectql' ? engine : null) },
106+
};
107+
const registryEdit = vi.spyOn(engine.registry, 'updatePackageManifest');
108+
const protocolEdit = protocol ? vi.spyOn(protocol, 'updatePackage') : undefined;
109+
return {
110+
dispatcher: new HttpDispatcher(kernel),
111+
engine,
112+
registryEdit,
113+
protocolEdit,
114+
publish: packageService.publish,
115+
};
116+
}
117+
118+
const ADMIN = (): any => ({
119+
request: {},
120+
environmentId: 'pkg-manifest-patch-readonly-test',
121+
executionContext: {
122+
userId: 'u_pkg_admin',
123+
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
124+
},
125+
});
126+
127+
type Door = ReturnType<typeof door>;
128+
const patch = (d: Door, id: string, body: unknown) =>
129+
d.dispatcher.handlePackages(`/${encodeURIComponent(id)}`, 'PATCH', body, {}, ADMIN());
130+
131+
/** The row by value: `updatePackageManifest` edits the row object in place. */
132+
const snapshot = (d: Door, id: string) => JSON.parse(JSON.stringify(d.engine.registry.getPackage(id) ?? null));
133+
134+
// ══════════════════════════════════════════════════════════════════════════════
135+
// 1. REFUSED: a read-only package's manifest, on both limbs, whatever the body
136+
// ══════════════════════════════════════════════════════════════════════════════
137+
138+
const READ_ONLY: Array<{ label: string; id: string; booted: boolean }> = [
139+
{ label: 'code-booted (registerApp)', id: CODE_BOOTED, booted: true },
140+
{ label: 'marketplace-installed (registerApp, marketplace)', id: MARKETPLACE, booted: true },
141+
{ label: 'system-scoped stored row (scope signal only)', id: SYSTEM_SCOPED, booted: false },
142+
{ label: 'cloud-scoped stored row (scope signal only)', id: CLOUD_SCOPED, booted: false },
143+
];
144+
145+
const BODIES: Array<{ label: string; body: unknown }> = [
146+
{ label: '{ description }', body: { description: 'edited over the API' } },
147+
{ label: '{ dependencies }', body: { dependencies: {} } },
148+
{ label: 'the { manifest } wrapper', body: { manifest: { name: 'Renamed', version: '2.0.0' } } },
149+
{ label: 'an empty body (the arm\'s own 400 sits behind the gate)', body: {} },
150+
];
151+
152+
for (const limb of LIMBS) {
153+
describe(`PATCH /packages/:id on a read-only package is refused (${limb} limb)`, () => {
154+
for (const pkg of READ_ONLY) {
155+
for (const b of BODIES) {
156+
it(`422s ${b.label} on the ${pkg.label} package and writes nothing`, async () => {
157+
const d = door(limb);
158+
const before = snapshot(d, pkg.id);
159+
expect(before?.manifest?.description, 'precondition: the shipped row').toBe('as shipped');
160+
expect((d.engine as unknown as { manifests: Map<string, unknown> }).manifests.has(pkg.id),
161+
'precondition: the boot signal').toBe(pkg.booted);
162+
163+
const r = await patch(d, pkg.id, b.body);
164+
165+
expect(r.response?.status).toBe(422);
166+
expect(r.response?.body?.success).toBe(false);
167+
expect(r.response?.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED');
168+
expect(r.response?.body?.error?.httpStatus).toBe(422);
169+
expect(r.response?.body?.error?.details?.packageId).toBe(pkg.id);
170+
expect(r.response?.body?.error?.details?.docs).toBe('docs/adr/0070-package-first-authoring.md');
171+
// Neither the registry nor `sys_packages` moved.
172+
expect(snapshot(d, pkg.id)).toEqual(before);
173+
expect(d.registryEdit).not.toHaveBeenCalled();
174+
if (d.protocolEdit) expect(d.protocolEdit).not.toHaveBeenCalled();
175+
expect(d.publish).not.toHaveBeenCalled();
176+
});
177+
}
178+
}
179+
});
180+
}
181+
182+
// ══════════════════════════════════════════════════════════════════════════════
183+
// 2. CONTROL: the gate is not an outage
184+
// ══════════════════════════════════════════════════════════════════════════════
185+
186+
for (const limb of LIMBS) {
187+
describe(`PATCH /packages/:id still edits what an org owns (${limb} limb)`, () => {
188+
it('edits a writable package: 200, and the row (and sys_packages) really move', async () => {
189+
const d = door(limb);
190+
191+
const r = await patch(d, AUTHORED, { description: 'edited by its owner', version: '1.1.0' });
192+
193+
expect(r.response?.status).toBe(200);
194+
expect(d.registryEdit).toHaveBeenCalledTimes(1);
195+
expect(d.engine.registry.getPackage(AUTHORED)?.manifest?.description).toBe('edited by its owner');
196+
expect(d.engine.registry.getPackage(AUTHORED)?.manifest?.version).toBe('1.1.0');
197+
if (limb === 'protocol') {
198+
expect(d.protocolEdit).toHaveBeenCalledTimes(1);
199+
expect(d.publish).toHaveBeenCalledTimes(1);
200+
}
201+
});
202+
203+
it('a writable package still reaches the body checks: an empty body answers 400 and writes nothing', async () => {
204+
const d = door(limb);
205+
const before = snapshot(d, AUTHORED);
206+
207+
const r = await patch(d, AUTHORED, {});
208+
209+
expect(r.response?.status).toBe(400);
210+
expect(r.response?.body?.error?.code).not.toBe('WRITABLE_PACKAGE_REQUIRED');
211+
expect(snapshot(d, AUTHORED)).toEqual(before);
212+
expect(d.registryEdit).not.toHaveBeenCalled();
213+
expect(d.publish).not.toHaveBeenCalled();
214+
});
215+
216+
it('an id nothing installed keeps the arm\'s own 404 — the gate is no existence oracle', async () => {
217+
const d = door(limb);
218+
expect(d.engine.registry.getPackage(UNKNOWN), 'precondition: nothing installed it').toBeUndefined();
219+
220+
const r = await patch(d, UNKNOWN, { description: 'x' });
221+
222+
expect(r.response?.status).toBe(404);
223+
expect(r.response?.body?.error?.code).not.toBe('WRITABLE_PACKAGE_REQUIRED');
224+
// It reached the edit, which is where the 404 comes from.
225+
expect(d.registryEdit).toHaveBeenCalledTimes(1);
226+
expect(d.publish).not.toHaveBeenCalled();
227+
});
228+
});
229+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2255,6 +2255,15 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
22552255
// [ADR-0131 D12 items 7–8] Under a wall, the platform operator's.
22562256
const walled = await refuseWalledCatalogPackageWrite(deps, _context, `Updating package "${id}"`);
22572257
if (walled) return walled;
2258+
// [#24705] ADR-0070 D2: the manifest of a package the deployment
2259+
// ships is the deployment's, so this door refuses it with the
2260+
// answer install-overwrite, disable, publish, revert and delete
2261+
// give. Asked BEFORE the body is read, so the answer is the same
2262+
// whatever keys the body carries, and before `updatePackage`
2263+
// re-persists the edited manifest to `sys_packages`. An id nothing
2264+
// installed is writable by the predicate's own rule, so it still
2265+
// reaches the 404 below.
2266+
const readOnly = requireWritablePackage(deps, qlService, id, 'edit'); if (readOnly) return readOnly;
22582267
const src = (body?.manifest && typeof body.manifest === 'object' ? body.manifest : body) ?? {};
22592268
const patch: { name?: string; description?: string; version?: string } = {};
22602269
if (typeof src.name === 'string') patch.name = src.name.trim();

0 commit comments

Comments
 (0)