Skip to content

Commit d6bd98a

Browse files
fix(metadata-protocol): duplicatePackage reports a refused install of the copy's package record (#24081)
Fixes #24041 Clause-②: no (narrowing) ## What changed (ruling B, claim amendment 6227008947) `duplicatePackage` (`packages/metadata-protocol/src/protocol.ts`) installs the copy's package record through `installPackage` before it copies any item. That call sat in a bare `catch {}`. Now, when the install is refused, the duplicate is refused with that refusal, rethrown unchanged, before any item is copied. Nothing is minted or copied, and the door answers the refusal's own status: - **`422 NAMESPACE_CONFLICT`**, with the registry's sentence and remedy ("Choose a different namespace for …"), when another installed package owns the copy's namespace (ADR-0048 Phase 1, the install-time gate in `SchemaRegistry.installPackage`). That covers an explicit `targetNamespace` and one derived from the last segment of `targetPackageId`. - **`500`**, the install's own sentence, when the `sys_packages` write fails. The refusal is logged once at `warn` by `[protocol.duplicatePackage]`, naming the target id, the source id, and the refusal's code and message, because neither the primitive nor the HTTP door logs it (measured below). For a store fault the error itself is attached to the log line, since the driver's text survives only on its `cause`. A client refusal is not attached; its sentence is already the whole story. This keeps the rule the sibling verb `importUnmanagedPackage` states: "There is no half-copied app." The first round of this PR (a `failed[]` entry of type `package` on a duplicate that still copied) is retired: that partial state is what the ruling removes. Also in `duplicatePackage`: the method docblock gains one sentence (the package record is not an item; its refusal refuses the whole duplicate), and comment ① of the target-id note is rewritten for the rethrow. Two test comments beside it (`protocol.duplicate-package-target-namespace.test.ts`, `protocol.install-manifest-id.test.ts`) are corrected the same way. ## Premises, measured before any edit (at `cfa01c279`) 1. **No mutation precedes the install call.** Code read of every call from the method head to the install: an organization-scope refusal and two declaration parses (throws only), `registry.getPackage` (read), `findEnvironmentRows('sys_metadata', …)` (`engine.find` plus the legacy-row read, both reads), and in-memory `Map`s. Measured with a one-off probe (not committed) over the real `ObjectKernel`, `ObjectQLPlugin` and `SchemaRegistry`, an instrumented in-memory driver, and a source holding an object and a view. A snapshot of every driver store, the package records, the namespace owners, the object contributors and the metadata keys was identical before the duplicate, at `installPackage` entry, and right after the refused install, for both refusals. Zero driver writes before the install and zero during the refused install. So no rollback is needed. 2. **The door maps the status.** A one-off probe (not committed) drove the real `HttpDispatcher.handlePackages` for `POST /:id/duplicate` over a real `SchemaRegistry`, with `duplicatePackage` throwing the exact objects the real `installPackage` throws. The namespace conflict answered `422` with `error.code` `NAMESPACE_CONFLICT` and the registry's full sentence, remedy included. The store fault answered `500` `INTERNAL_ERROR` with the primitive's own sentence, which quotes only the package id. The door's `catch` is `deps.errorFromThrown(e, 500)` (`packages/runtime/src/domains/packages.ts`). Neither the door nor the primitive logged either refusal. 3. **No caller relies on the partial copy.** objectui at the pinned `.objectui-sha` `f8267df93`: `packages-io.ts` `duplicatePackage` throws `readEnvelopeFailureText(payload)` on any non-OK answer (lines 278-289), and both callers show the thrown message: `PackagesPage.tsx` 584-588 (an error banner) and `BuilderLanding.tsx` 140-141 (`setDupErr`). Studio sends no `targetNamespace`, so from Studio the conflict comes only through a derived namespace. In this repo, the SDK's `client.packages.duplicate` goes through `unwrapResponse`, and the dogfood duplicate (`package-first-authoring.dogfood.test.ts`) duplicates a source with no registry record, so it never reaches the install call. ## Refusals a duplicate can reach | Refusal | Raised by | Reachable from a duplicate? | |---|---|---| | Namespace conflict, `NAMESPACE_CONFLICT`, 422 | `SchemaRegistry.installPackage`'s namespace gate | **Yes.** The HTTP door forwards `targetNamespace` verbatim. Reached by an explicit namespace another package owns, or a derived one equal to another package's namespace, the source's own included. `OS_METADATA_COLLISION=warn` downgrades it to a warning, and the duplicate then proceeds. | | `sys_packages` write failed, 500 | `installPackage` (`packagePersistFailureError`, after its undo) | Yes, on a store fault. | | Manifest id | `installPackage`'s id leg | No. `duplicatePackage` parses `targetPackageId` with the same declaration first. | | ADR-0087 D1 handshake | `assertProtocolCompat` | Not in practice: the copy carries the source record's `engines`, which this runtime already admitted. | | Security catalog or default record page conflict | the registry's other install judges | Not over HTTP: they read manifest collections a base's manifest cannot carry. | | A code or marketplace source | the runtime door's `requireDuplicableSource` | Refused 422 before the protocol is called. | ## Pins `packages/metadata-protocol/src/protocol.duplicate-package-install-refusal.test.ts`, three cases: 1. **The registry's namespace refusal.** `SchemaRegistry` cannot be imported here, because `@objectstack/objectql` depends on this package. So the harness registry refuses exactly when another package owns the namespace, throwing `NamespaceConflictError`'s envelope (`code`, `status` 422, its sentence), and the real `installPackage` meets it. It asserts that the duplicate rejects with that very object (`toBe`), that `resolveThrownHttpError` resolves it to `422` and `NAMESPACE_CONFLICT`, that `saveMetaItem` was never called, that no target record exists and the namespace owners are unchanged, and that one warn line names the target id, the code and the sentence, with no error object attached. 2. **A failed `sys_packages` write**, with no stub on the install path. The `package` service answers `{ success: false, driverFault }`, and the real primitive undoes its registry write and throws its 500. It asserts the rejection resolves to `500`, its message names the target and quotes nothing of the store, nothing was copied, the target record is gone, and the one warn line carries the error itself, whose `cause` holds the driver text. 3. **CONTROL.** An accepted install still copies (`success: true`, the item copied as `leave_copy_ticket`) and logs no refusal. The real-registry measurements are the premise probes above. They are cited, not kept as tests: they sit outside the claimed file surface, and the dependency cycle keeps them out of this package. ## Red to green, and the rethrow ablation - GREEN at `68429a829`: `typecheck` exit 0, and the three files `Test Files 3 passed (3)`, `Tests 35 passed (35)`. - Ablation with `node scripts/ablation-replace.mjs` in WRAP mode: `throw e;` replaced by a comment (anchor x1 to x0, marker x0 to x1, blob `bf8238167aba` to `2ea0aabb7b15`). The pin read `Tests 2 failed | 1 passed (3)`: both refusal cases failed with `Error: expected the duplicate to be refused, but it resolved`, and the CONTROL stayed green. The restore is proven: blob equals HEAD (`bf8238167aba`), `git diff HEAD` is empty, and the marker count is 0. Predicted direction red, observed red. The test imports the source by relative path, so no `dist/` leg applies. ## Tests and gates - `pnpm --filter @objectstack/metadata-protocol exec vitest run` at `de14c8521`: `Test Files 243 passed | 3 skipped (246)`, `Tests 20611 passed | 19 skipped (20630)`. - objectql's five test files that drive the real registry through `duplicatePackage`, against a rebuilt metadata-protocol `dist/` that carries the rethrow: `Test Files 5 passed (5)`, `Tests 29 passed (29)`. - Changeset gates at `de14c8521`: `check-changeset-no-major --base origin/main` exit 0 (no `major`); `check-adr-0087-registration --base origin/main` exit 0, reading `[clause-②-narrowing] not-required (no-migration-prescription)`; `check-empty-changeset --base origin/main` exit 0. The changeset is `minor`, as a narrowing ships during the launch window. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `de14c8521` (0 commits behind `origin/main`) derived 65 families, each run with its exit captured before any pipe: 64 exit 0, among them `check:durability-log-level`, `check:engine-double-contract`, `check:doc-authoring`, `check:issue-citations`, `check:nul-bytes`, `check:lean-entry-closure`, `check:test-source-alias`, `check:dts-closure` and `check:published-files`. `pnpm check:dual-build-cjs-loads` exit 3 (PREREQUISITE NOT MET: it needs every package's `dist/`), so it is NOT MEASURED here and read from CI. `--ran`: `65 derived, 64 run, 1 NOT-MEASURED, 0 UNRUN`. The three roster families whose roster sits in a directory this diff touches (`node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing`): exit 0. The level axis, run with this body through `--event`: `Clause-②: no (narrowing)`, a BREAKING change that ships `minor`, exit 0. ## Review owed A narrowing: one `CONTRACT_REVIEW_TIER` review is owed before the queue (claim amendment 6227008947). ## Acceptance notes - When the source id has no registry record (`srcPkg` undefined), the install is skipped without a word, and that copy has no package record. The runtime door's `requireDuplicableSource` passes an unknown id on. This is outside the card by the claim; not measured; noted, not filed. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 570c7a2 commit d6bd98a

5 files changed

Lines changed: 274 additions & 14 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
---
4+
5+
fix(metadata-protocol): `duplicatePackage` is refused, with nothing copied, when the install of the copy's package record is refused
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) The refusal is one verb's runtime answer at call time; no stored metadata, spec key or published type changes, so there is nothing for `objectstack migrate meta` to rewrite. The remedy is a different argument on the next call. -->
10+
11+
- **Refused now.** `duplicatePackage`, and `POST /api/v1/packages/:id/duplicate` which calls it, installs the copy's package record before it copies any item. When that install is refused, the duplicate is now refused with that same refusal, and nothing is minted or copied. A copy whose namespace another installed package owns answers `422 NAMESPACE_CONFLICT` with the registry's own sentence (ADR-0048); that covers an explicit `targetNamespace` and one derived from the last segment of `targetPackageId`. A failed `sys_packages` write answers the install's own `500`. Before, both refusals were dropped and the items were copied anyway: bound to an id with no package record (absent from `GET /packages`), after a namespace conflict inside the namespace the other package owns, and the answer read `success: true`.
12+
- **Unchanged.** A duplicate whose package record installs copies exactly as before, with the same answer shape, and per-item failures still ride `failed[]`. A source with no package record still copies without installing one.
13+
- **Remedy.** Choose a target namespace no installed package owns: pass `targetNamespace` explicitly, or pick a `targetPackageId` whose last segment derives a free one. For a deliberate migration, `OS_METADATA_COLLISION=warn` still downgrades the namespace gate to a warning, and the duplicate then proceeds.
14+
- **Logged.** The refusal is logged once at `warn` by `[protocol.duplicatePackage]`, naming the target and source ids and the refusal's code and message. For a store fault the error itself is attached, so its cause reaches the log.
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24041] `duplicatePackage` is refused, with nothing copied, when the install
5+
* of the copy's package record is refused — and the refusal it answers is the
6+
* install's own, unchanged.
7+
*
8+
* ---------------------------------------------------------------------------
9+
* The gap these pins close
10+
* ---------------------------------------------------------------------------
11+
* The duplicate writes the copy's package record through `installPackage`
12+
* before its copy loop, inside a `catch {}` that kept nothing. A refused
13+
* install leaves no registry record and no `sys_packages` row, so the copy's
14+
* items were live, bound to an id no package record holds — after a namespace
15+
* conflict, inside a namespace another package owns — and the caller read
16+
* `success: true`, with no line logged at any level. The door now keeps the
17+
* rule its sibling `importUnmanagedPackage` states: "There is no half-copied
18+
* app." (ruling B, claim amendment 6227008947).
19+
*
20+
* ---------------------------------------------------------------------------
21+
* The two refusals, and why each is built the way it is
22+
* ---------------------------------------------------------------------------
23+
* - The REGISTRY'S NAMESPACE GATE (ADR-0048 Phase 1).
24+
* `SchemaRegistry.installPackage` refuses a namespace another installed
25+
* package owns with `NamespaceConflictError` (`NAMESPACE_CONFLICT`, 422). That
26+
* judge lives in `@objectstack/objectql`, which depends on this package, so it
27+
* cannot be imported here: the registry below refuses exactly when another
28+
* package owns the namespace and throws that error's envelope (`code`,
29+
* `status`, its sentence) at the registry's own boundary, and the protocol's
30+
* real `installPackage` is the code that meets it.
31+
* - A FAILED `sys_packages` WRITE. No stub on the install path at all: the
32+
* `package` service reports a driver fault the way the real one does
33+
* (`{ success: false, … }`), and the real primitive undoes its registry write
34+
* and throws its own 500.
35+
*
36+
* Each asserts the refusal as the ENVELOPE an HTTP boundary resolves it to
37+
* (`resolveThrownHttpError`, the resolver the door's `errorFromThrown` uses),
38+
* that it is the install's own error object, rethrown unchanged, and that
39+
* nothing was copied. A CONTROL on the same harness holds the other side: an
40+
* accepted install still copies, and logs nothing.
41+
*/
42+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
43+
import { resolveThrownHttpError } from '@objectstack/types';
44+
import { ObjectStackProtocolImplementation } from './protocol.js';
45+
46+
const SOURCE = 'com.example.leave';
47+
const TARGET = 'com.example.leave-copy';
48+
/** The installed package whose namespace the copy asks for. */
49+
const OWNER = 'com.example.taken';
50+
const DRIVER_TEXT = 'SQLITE_ERROR: no such table: sys_packages';
51+
52+
/** `NamespaceConflictError`'s envelope, as `SchemaRegistry.installPackage` throws it. */
53+
function namespaceConflict(namespace: string, existing: string, incoming: string): Error {
54+
return Object.assign(
55+
new Error(
56+
`Namespace conflict: namespace "${namespace}" is already owned by package "${existing}", `
57+
+ `so package "${incoming}" cannot be installed alongside it. Choose a different namespace for `
58+
+ `"${incoming}", or uninstall "${existing}" first.`,
59+
),
60+
{ name: 'NamespaceConflictError', code: 'NAMESPACE_CONFLICT', status: 422, httpStatus: 422 },
61+
);
62+
}
63+
64+
/**
65+
* A duplicate-door harness: one source package holding one object row, a
66+
* registry that keeps its package records and namespace owners, and
67+
* optionally a `package` service. The namespace gate refuses exactly as the
68+
* real one does: a namespace another installed package owns. Whatever it
69+
* throws is kept in `thrown`, so a pin can ask for that very object back.
70+
*/
71+
function makeHarness(opts: { publish?: (data: unknown) => Promise<unknown> } = {}) {
72+
const rows = [{
73+
id: 'r_1',
74+
type: 'object',
75+
name: 'leave_ticket',
76+
organization_id: null,
77+
package_id: SOURCE,
78+
state: 'active',
79+
metadata: JSON.stringify({ name: 'leave_ticket', label: 'Ticket' }),
80+
}];
81+
const packages = new Map<string, any>([
82+
[SOURCE, { manifest: { id: SOURCE, name: 'Leave', namespace: 'leave', version: '1.0.0' } }],
83+
[OWNER, { manifest: { id: OWNER, name: 'Taken', namespace: 'taken', version: '1.0.0' } }],
84+
]);
85+
const namespaceOwners = new Map<string, string[]>([['leave', [SOURCE]], ['taken', [OWNER]]]);
86+
const thrown: unknown[] = [];
87+
const registry: any = {
88+
getPackage: vi.fn((id: string) => packages.get(id)),
89+
getNamespaceOwners: vi.fn((ns: string) => [...(namespaceOwners.get(ns) ?? [])]),
90+
installPackage: vi.fn((manifest: any) => {
91+
const owner = (namespaceOwners.get(manifest.namespace) ?? []).find((id) => id !== manifest.id);
92+
if (owner) {
93+
const refusal = namespaceConflict(manifest.namespace, owner, manifest.id);
94+
thrown.push(refusal);
95+
throw refusal;
96+
}
97+
const row = { manifest, status: 'installed', enabled: true };
98+
packages.set(manifest.id, row);
99+
namespaceOwners.set(manifest.namespace, [...(namespaceOwners.get(manifest.namespace) ?? []), manifest.id]);
100+
return row;
101+
}),
102+
unregisterItem: vi.fn((type: string, id: string) => {
103+
if (type === 'package') packages.delete(id);
104+
}),
105+
unregisterNamespace: vi.fn((ns: string, id: string) => {
106+
namespaceOwners.set(ns, (namespaceOwners.get(ns) ?? []).filter((owner) => owner !== id));
107+
}),
108+
};
109+
const engine: any = { find: vi.fn(async () => rows), registry };
110+
const services = new Map<string, unknown>();
111+
if (opts.publish) services.set('package', { publish: vi.fn(opts.publish) });
112+
const impl = new ObjectStackProtocolImplementation(engine as never, () => services as never);
113+
const saveMetaItem = vi.spyOn(impl, 'saveMetaItem' as never);
114+
(saveMetaItem as any).mockResolvedValue({ success: true } as never);
115+
return { impl, packages, namespaceOwners, saveMetaItem, thrown };
116+
}
117+
118+
/** The thrown refusal, or a failure naming what happened instead. */
119+
async function refusalOf(run: () => Promise<unknown>): Promise<any> {
120+
try {
121+
await run();
122+
} catch (e) {
123+
return e;
124+
}
125+
throw new Error('expected the duplicate to be refused, but it resolved');
126+
}
127+
128+
/** The `[protocol.duplicatePackage] refused:` log calls, each with its arguments. */
129+
function refusalLogs(warn: { mock: { calls: unknown[][] } }): unknown[][] {
130+
return warn.mock.calls.filter((args: unknown[]) => String(args[0]).startsWith('[protocol.duplicatePackage] refused:'));
131+
}
132+
133+
describe('[#24041] duplicatePackage is refused when the copy\'s package record is refused', () => {
134+
let warn: { mock: { calls: unknown[][] }; mockRestore: () => void };
135+
beforeEach(() => {
136+
warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) as never;
137+
});
138+
afterEach(() => {
139+
warn.mockRestore();
140+
});
141+
142+
it('answers the registry\'s namespace refusal (422 NAMESPACE_CONFLICT) unchanged, and copies nothing', async () => {
143+
const { impl, packages, namespaceOwners, saveMetaItem, thrown } = makeHarness();
144+
145+
const err = await refusalOf(() => (impl as any).duplicatePackage({
146+
sourcePackageId: SOURCE,
147+
targetPackageId: TARGET,
148+
targetNamespace: 'taken',
149+
}));
150+
151+
// The registry's own refusal, rethrown unchanged — so the door answers
152+
// its status, its code and its remedy.
153+
expect(thrown).toHaveLength(1);
154+
expect(err).toBe(thrown[0]);
155+
const envelope = resolveThrownHttpError(err);
156+
expect(envelope.status).toBe(422);
157+
expect(envelope.code).toBe('NAMESPACE_CONFLICT');
158+
159+
// Nothing was copied, and nothing was minted.
160+
expect(saveMetaItem).not.toHaveBeenCalled();
161+
expect(packages.has(TARGET)).toBe(false);
162+
expect(namespaceOwners.get('taken')).toEqual([OWNER]);
163+
164+
// Logged once, naming the target, the code and the sentence; a client
165+
// refusal is not handed to the log as an error object.
166+
const logs = refusalLogs(warn);
167+
expect(logs).toHaveLength(1);
168+
expect(logs[0]).toHaveLength(1);
169+
expect(String(logs[0]![0])).toContain(`'${TARGET}'`);
170+
expect(String(logs[0]![0])).toContain('NAMESPACE_CONFLICT');
171+
expect(String(logs[0]![0])).toContain(err.message);
172+
});
173+
174+
it('answers a failed sys_packages write with the primitive\'s 500, undone, and copies nothing', async () => {
175+
const { impl, packages, saveMetaItem } = makeHarness({
176+
publish: async () => ({ success: false, driverFault: { message: DRIVER_TEXT } }),
177+
});
178+
179+
const err = await refusalOf(() => (impl as any).duplicatePackage({ sourcePackageId: SOURCE, targetPackageId: TARGET }));
180+
181+
// The primitive's own failure: a 500 whose sentence names the target and
182+
// quotes nothing of the store.
183+
const envelope = resolveThrownHttpError(err);
184+
expect(envelope.status).toBe(500);
185+
expect(err.message).toContain(`'${TARGET}'`);
186+
expect(err.message).not.toContain('SQLITE');
187+
188+
// Nothing was copied, and the primitive undid its registry write.
189+
expect(saveMetaItem).not.toHaveBeenCalled();
190+
expect(packages.has(TARGET)).toBe(false);
191+
192+
// Logged once, with the error itself attached: its `cause` is the only
193+
// place the driver's text survives.
194+
const logs = refusalLogs(warn);
195+
expect(logs).toHaveLength(1);
196+
expect(String(logs[0]![0])).toContain(`'${TARGET}'`);
197+
expect(logs[0]![1]).toBe(err);
198+
expect((err as { cause?: any }).cause?.driverFault?.message).toBe(DRIVER_TEXT);
199+
});
200+
201+
it('CONTROL: an accepted install still copies, and logs no refusal', async () => {
202+
const { impl, packages, saveMetaItem } = makeHarness({ publish: async () => ({ success: true }) });
203+
204+
const res = await (impl as any).duplicatePackage({ sourcePackageId: SOURCE, targetPackageId: TARGET });
205+
206+
expect(res.failed).toEqual([]);
207+
expect(res.success).toBe(true);
208+
expect(res.copied).toEqual([{ type: 'object', name: 'leave_copy_ticket' }]);
209+
expect(saveMetaItem).toHaveBeenCalledTimes(1);
210+
expect(packages.get(TARGET)?.manifest?.namespace).toBe('leave_copy');
211+
expect(refusalLogs(warn)).toEqual([]);
212+
});
213+
});

‎packages/metadata-protocol/src/protocol.duplicate-package-target-namespace.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,8 @@ describe('[#19577] duplicatePackage refuses an explicit targetNamespace the decl
129129

130130
// ⭐ Nothing was minted and nothing was scanned: the refusal
131131
// precedes the manifest write AND the copy loop. The manifest write
132-
// sits inside a best-effort `catch {}`, so a refusal raised only
133-
// there would be swallowed and reported as `success: true`.
132+
// does not judge a namespace's charset at all, so this is the only
133+
// place the refusal can be raised.
134134
expect(installed).toHaveLength(0);
135135
expect(engine.registry.installPackage).not.toHaveBeenCalled();
136136
expect(engine.find).not.toHaveBeenCalled();

‎packages/metadata-protocol/src/protocol.install-manifest-id.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,9 @@ describe('[#19417] duplicatePackage refuses a target id the declaration refuses'
183183
expect(err.message).toBe(manifestIdRefusal('targetPackageId', id));
184184
// ⭐ Nothing was minted and nothing was scanned: the refusal
185185
// precedes the manifest write AND the copy loop, so no empty shell
186-
// is left behind. The manifest write below sits inside a
187-
// best-effort `catch {}` — a refusal raised only there would be
188-
// swallowed and reported as `success: true`.
186+
// is left behind. A refusal raised only at the manifest write would
187+
// refuse the duplicate too, but under `manifest.id`, a key the
188+
// caller never wrote.
189189
expect(installed).toHaveLength(0);
190190
expect(engine.registry.installPackage).not.toHaveBeenCalled();
191191
expect(engine.find).not.toHaveBeenCalled();

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

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27078,7 +27078,9 @@ export class ObjectStackProtocolImplementation implements
2707827078
* `sys_metadata` keys on (type,name,org), so a same-name copy would collide
2707927079
* with the source — and rewriting every intra-package reference (lookup
2708027080
* `reference`, view `object`, expressions, etc.) to the new names. Per-item
27081-
* best-effort; one failure never aborts the whole clone.
27081+
* best-effort; one failure never aborts the whole clone. The copy's package
27082+
* record is not an item: when its install is refused, that refusal refuses
27083+
* the whole duplicate, before any item is written.
2708227084
*
2708327085
* Every copied object is named in the target namespace: `iojn_customer`
2708427086
* becomes `iojn2_customer`, and an object whose name lacks the source
@@ -27132,12 +27134,10 @@ export class ObjectStackProtocolImplementation implements
2713227134
// covers the WRITE. It does not cover this method, for two reasons that
2713327135
// are both about where the refusal lands:
2713427136
//
27135-
// ① the `installPackage` call below sits inside a `catch {}` that is
27136-
// deliberately best-effort (a manifest row is not worth aborting a
27137-
// copy for), so a refusal raised THERE would be swallowed here and
27138-
// the caller would read `success: true` on a package with no
27139-
// manifest row — a silent partial state, strictly worse than the
27140-
// 201 this card set out to close;
27137+
// ① a refusal of the `installPackage` call below is rethrown before
27138+
// any item is copied, so it would not be lost — but it names
27139+
// `manifest.id`, the key of a manifest this door builds, never the
27140+
// `targetPackageId` the caller wrote (the closing paragraph below);
2714127141
// ② the refusal has to precede the MUTATIONS, and the manifest write
2714227142
// is not the first of them. #14451 already established the position
2714327143
// for this door's other precondition: `duplicatePackage` mints the
@@ -27527,8 +27527,41 @@ export class ObjectStackProtocolImplementation implements
2752727527
};
2752827528
delete dupManifest.scope;
2752927529
await this.installPackage({ manifest: dupManifest } as InstallPackageRequest);
27530-
} catch {
27531-
/* best-effort — the per-item package binding still works without a manifest row */
27530+
} catch (e: any) {
27531+
// [#24041] ⭐ A REFUSED PACKAGE RECORD REFUSES THE DUPLICATE —
27532+
// the refusal itself, rethrown unchanged, before any item is
27533+
// copied (ruling B, claim amendment 6227008947).
27534+
//
27535+
// This catch used to drop it and copy anyway. A refused install
27536+
// leaves no registry record and no `sys_packages` row, so the
27537+
// copy's items were live, bound to an id no package record
27538+
// holds, absent from `GET /packages`. After a namespace conflict
27539+
// they also sat inside a namespace another package owns — the
27540+
// very state ADR-0048 Phase 1's install-time gate in
27541+
// `SchemaRegistry.installPackage` exists to refuse. The sibling
27542+
// verb {@link importUnmanagedPackage} states the rule this door
27543+
// now keeps: "There is no half-copied app."
27544+
//
27545+
// Nothing needs undoing: this call is the duplicate's FIRST
27546+
// mutation (everything above it reads), and a refused install
27547+
// leaves nothing behind — the registry judges before it writes,
27548+
// and a failed persist undoes the registry write before it
27549+
// throws (#21243). So the door answers the refusal's own status:
27550+
// `422 NAMESPACE_CONFLICT` with the registry's remedy (choose
27551+
// another namespace), or the primitive's `500` for a store fault.
27552+
//
27553+
// Logged once, because neither the primitive nor the HTTP door
27554+
// logs it. A store fault's driver text rides only on the error's
27555+
// `cause`, which no answer quotes, so the error itself is handed
27556+
// to the log for that one; a client refusal's sentence is
27557+
// already the whole story.
27558+
console.warn(
27559+
`[protocol.duplicatePackage] refused: the package record for '${request.targetPackageId}' `
27560+
+ `(copy of '${request.sourcePackageId}') was not installed, so nothing was copied: `
27561+
+ `${typeof e?.code === 'string' ? `${e.code}: ` : ''}${e?.message ?? e}`,
27562+
...(declaresClientRefusal(e) ? [] : [e]),
27563+
);
27564+
throw e;
2753227565
}
2753327566
}
2753427567

0 commit comments

Comments
 (0)