From fd29c1dabc78dc33079a7200b5decfa1746d8d07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 10:44:02 +0000 Subject: [PATCH 1/3] fix(runtime): refuse `POST /packages/:id/duplicate` on a source that is not a base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duplicating a running CODE package answered HTTP 200 with `{"success":false,"copiedCount":0,"failedCount":0,"copied":[],"failed":[]}` and still created the target package record — a real, listed, empty package. Reproduced independently twice on two separate `os dev` processes, against `examples/app-todo` (one object, four flows, views, dashboards, reports; none of it copied). `copiedCount: 0` there is BY CONSTRUCTION. `duplicatePackage` clones the rows `sys_metadata` holds for the source, and a code package's metadata is delivered as code, so the scan could never have found anything — a read that could not happen, reported as a read that found nothing, which `packages/rest/src/package-routes.ts` already states as a rule one route over (#11063). ADR-0070 D4 is declared and NOT built ("D4-D6 remaining") and its object is a *base*, so cloning a code package's items would EXTEND the decision rather than implement it — and the ADR still lists that as an open question. The unbuilt case therefore refuses loudly: - `requireDuplicableSource` answers 422 `DUPLICATE_SOURCE_NOT_A_BASE` (new ledger row under `@objectstack/runtime`) for a code-loaded, platform- or marketplace-scoped source, naming the package and the ADR-0005 overlay remedy. It runs BEFORE the protocol call, because `duplicatePackage` mints the target record ahead of its copy loop. - Same predicate as every other writability verdict here (`isWritablePackage`, ADR-0070 D2), a different code: `WRITABLE_PACKAGE_REQUIRED` means "may not be written to", and its remedy reads as "make the source writable", which is neither possible nor the point. - `requireWritablePackage`'s own 422 stops prescribing a dead end: it used to send read-only-package callers at `POST /:id/duplicate`, the route that now refuses them. It points at the ADR-0005 overlay. Deliberately unchanged: a WRITABLE base that owns no active rows still answers 200 with `copiedCount: 0`. That read happened and found nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .changeset/duplicate-source-must-be-a-base.md | 17 ++ content/docs/references/api/contract.mdx | 3 +- .../docs/references/api/error-code-ledger.mdx | 1 + .../domains/packages-readonly-gate.test.ts | 204 +++++++++++++++++- packages/runtime/src/domains/packages.ts | 136 +++++++++++- packages/runtime/src/route-ledger.ts | 2 +- .../spec/src/api/error-code-ledger.zod.ts | 15 ++ 7 files changed, 369 insertions(+), 9 deletions(-) create mode 100644 .changeset/duplicate-source-must-be-a-base.md diff --git a/.changeset/duplicate-source-must-be-a-base.md b/.changeset/duplicate-source-must-be-a-base.md new file mode 100644 index 0000000000..12d539121c --- /dev/null +++ b/.changeset/duplicate-source-must-be-a-base.md @@ -0,0 +1,17 @@ +--- +"@objectstack/runtime": minor +"@objectstack/spec": minor +--- + +`POST /packages/:id/duplicate` now refuses a source that is not a writable base, instead of answering `200` with an empty copy. + +Duplicating a **running code package** answered `HTTP 200` with `{"success":false,"copiedCount":0,"failedCount":0,"copied":[],"failed":[]}` — and still created the target package record, leaving a real, listed, empty package behind. The source package had one object, four flows, views, dashboards and reports; none of it was copied, and nothing said why. + +`copiedCount: 0` there was **by construction**, not a copy that failed. `duplicatePackage` clones the rows `sys_metadata` holds for the source, and a code package's metadata is delivered as code — it has no such rows — so the scan could never have found anything. A caller could not tell that from a base that really is empty, which is the ambiguity the platform already refuses to ship elsewhere: *a read that could not happen must not be reported as a read that found nothing.* + +- **The refusal.** A code-loaded, platform- or marketplace-scoped source is now refused `422` with the new error code `DUPLICATE_SOURCE_NOT_A_BASE` (registered under `@objectstack/runtime`), naming the package and prescribing the remedy that exists for it — duplicate a base you own, or customise the code package in place with an ADR-0005 org overlay. The refusal runs **before** the protocol call, so the empty target record is no longer created; the writability verdict is the same `isWritablePackage` predicate the authoring and lifecycle gates already use. +- **The read-only lifecycle refusal stops prescribing a dead end.** `WRITABLE_PACKAGE_REQUIRED` (from `DELETE /packages/:id` and `PATCH /packages/:id/disable`) used to tell callers to "duplicate this one into a writable base (`POST /packages/:id/duplicate`) and change that" — a route which, for exactly the packages that refusal fires on, cannot help. It now points at the ADR-0005 overlay instead. + +⚠️ Behaviour change for API callers: duplicating a code, platform or marketplace package was `200`, and is now `422`. Duplicating a **writable base** is untouched in every respect — including a base that owns no active rows, which still answers `200` with `copiedCount: 0`, because that read happened and found nothing. + +Not changed: duplicate still does not clone a code package's items. ADR-0070 D4 duplicates a *base*, and is itself declared-and-not-built; teaching it to fork code packages would extend the decision rather than implement it, and the ADR still carries that as an open question. diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 76362bb2dc..6bedba2190 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +294 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +295 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -140,6 +140,7 @@ const result = ApiErrorSchema.parse(data); * `DOMAIN_VERIFICATION_FAILED` * `DRIVER_UNAVAILABLE` * `DUPLICATE_REQUEST` +* `DUPLICATE_SOURCE_NOT_A_BASE` * `ELIGIBILITY_UNEVALUABLE` * `EMAIL_DOMAIN_NOT_ALLOWED` * `EMAIL_SEND_FAILED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index c457dbaf44..6d85208aae 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -256,6 +256,7 @@ const result = ErrorCode.parse(data); * `DOMAIN_VERIFICATION_FAILED` * `DRIVER_UNAVAILABLE` * `DUPLICATE_REQUEST` +* `DUPLICATE_SOURCE_NOT_A_BASE` * `ELIGIBILITY_UNEVALUABLE` * `EMAIL_DOMAIN_NOT_ALLOWED` * `EMAIL_SEND_FAILED` diff --git a/packages/runtime/src/domains/packages-readonly-gate.test.ts b/packages/runtime/src/domains/packages-readonly-gate.test.ts index 0195563675..c827046a0f 100644 --- a/packages/runtime/src/domains/packages-readonly-gate.test.ts +++ b/packages/runtime/src/domains/packages-readonly-gate.test.ts @@ -42,7 +42,7 @@ * the same listing. */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -95,6 +95,30 @@ function make() { // Present so a DELETE that is ALLOWED reaches its persisted half too — // the allow-path must be exercised end to end, not just to the gate. deletePackage: async () => ({ deletedCount: 1 }), + // [#14451] This double MINTS THE TARGET PACKAGE RECORD, because the real + // `duplicatePackage` does: its `registry.installPackage` call runs ahead + // of the copy loop, which is why the reported defect left a real, empty + // `com.acme.dupbase` in `GET /packages`. + // + // ⚠️ Not decoration — it is what makes the refusal cases falsifiable. A + // double that only returned a value would leave "the target is not in + // the listing" true whether or not the gate exists, i.e. an assertion + // that can never go red. With the mint in place, deleting the gate makes + // those cases fail on the listing, not merely on the status. + duplicatePackage: vi.fn(async (req: any) => { + registry.installPackage(manifest(req.targetPackageId)); + // The exact empty-success body the card measured on a running code + // package: HTTP 200, `success: false`, nothing copied, nothing named + // as failed. + return { + success: false, + copiedCount: 0, + failedCount: 0, + targetPackageId: req.targetPackageId, + copied: [], + failed: [], + }; + }), }; const kernel: any = { context: { @@ -102,7 +126,7 @@ function make() { name === 'objectql' ? objectql : name === 'protocol' ? protocol : null, }, }; - return { dispatcher: new HttpDispatcher(kernel), registry }; + return { dispatcher: new HttpDispatcher(kernel), registry, protocol }; } /** Authorized under #7033 — holds the write capability on every call below. */ @@ -292,3 +316,179 @@ describe('/packages lifecycle — an unknown package id keeps its 404 (#7560)', expect(r.response?.status).toBe(404); }); }); + +// ══════════════════════════════════════════════════════════════════════════════ +// 6. [#14451] POST /:id/duplicate — the SOURCE must be a base +// +// ## The defect this section pins +// +// `POST /api/v1/packages/com.example.todo/duplicate` against a RUNNING code +// package answered **HTTP 200** with +// `{"success":false,"copiedCount":0,"failedCount":0,"copied":[],"failed":[]}` +// and left a real, empty `com.acme.dupbase` behind in `GET /packages`. +// Reproduced independently twice on two separate `os dev` processes. +// +// `copiedCount: 0` there is BY CONSTRUCTION: `duplicatePackage` clones the +// source's `sys_metadata` rows, and a code package's metadata is delivered as +// code, so the scan is one that could never have found anything. That is the +// #11063 ruling one route over — "a read that could not happen must not be +// reported as a read that found nothing" — and the caller could not tell it +// from a base that really is empty. +// +// ⛔ The fix is NOT to make duplicate clone code items. ADR-0070 D4 is declared +// and NOT built ("D4–D6 remaining") and its object is a *base*; cloning a code +// package would EXTEND D4, and the ADR still lists that as an open question +// ("should customising a code item also fork it into a writable base?"). +// +// ## What is asserted, and why it is not the status code +// +// The harm was the empty shell, so every refusal case asserts the listing — +// and the protocol double MINTS that shell exactly as the real implementation +// does, so these assertions can actually go red. A double that merely returned +// a value would make "the target is absent" true with or without the gate. +// ══════════════════════════════════════════════════════════════════════════════ + +const DUP_TARGET = 'com.acme.dupbase'; + +describe('/packages duplicate — a source that is not a base is refused (#14451)', () => { + for (const pkg of READ_ONLY_PACKAGES) { + it(`422s duplicate of the ${pkg.label} package AND mints no target record`, async () => { + const { dispatcher, registry, protocol } = make(); + const before = listedIds(registry); + + const r = await dispatcher.handlePackages( + `/${pkg.id}/duplicate`, 'POST', { targetPackageId: DUP_TARGET, targetName: 'Dup Base' }, {}, admin(), + ); + + expect(r.response?.status).toBe(422); + expect(r.response?.body?.error?.code).toBe('DUPLICATE_SOURCE_NOT_A_BASE'); + // THE observable the card was written from: the pre-fix answer left + // a real, listed, `writable: true` package record behind. + expect(listedIds(registry)).toEqual(before); + expect(listedIds(registry)).not.toContain(DUP_TARGET); + expect(registry.getPackage(DUP_TARGET)).toBeUndefined(); + // The refusal ran BEFORE the protocol call — which is the only + // placement that can keep the shell from being minted, since + // `duplicatePackage` installs it ahead of its own copy loop. + expect(protocol.duplicatePackage).not.toHaveBeenCalled(); + // …and the source is untouched: this route never wrote to it, and + // the refusal must not have changed that. + expect(registry.getPackage(pkg.id)).toBeDefined(); + }); + } + + it('answers the ADR-0112 envelope with the package id and the ADR pointer', async () => { + const { dispatcher } = make(); + const r = await dispatcher.handlePackages( + `/${CODE_LOADED}/duplicate`, 'POST', { targetPackageId: DUP_TARGET }, {}, admin(), + ); + const err = r.response?.body?.error; + expect(r.response?.status).toBe(422); + expect(err?.code).toBe('DUPLICATE_SOURCE_NOT_A_BASE'); + expect(err?.httpStatus).toBe(422); + expect(err?.details?.packageId).toBe(CODE_LOADED); + expect(err?.details?.docs).toBe('docs/adr/0070-package-first-authoring.md'); + // The remedy is the one that EXISTS for a code package (ADR-0005 + // overlay), and the message says why the copy would be empty rather + // than only that it is refused. + expect(err?.message).toContain('ADR-0005'); + expect(err?.message).toContain('read-only'); + }); + + it('is its OWN code, not WRITABLE_PACKAGE_REQUIRED — the two conditions differ', async () => { + const { dispatcher } = make(); + const dup = await dispatcher.handlePackages( + `/${CODE_LOADED}/duplicate`, 'POST', { targetPackageId: DUP_TARGET }, {}, admin(), + ); + const del = await dispatcher.handlePackages(`/${CODE_LOADED}`, 'DELETE', {}, {}, admin()); + // Same predicate (`isWritablePackage`), same status, different meaning: + // DELETE is refused because the package may not be WRITTEN to; duplicate + // is refused because the GESTURE does not apply to this source. A caller + // told `WRITABLE_PACKAGE_REQUIRED` here would hunt for a way to make a + // code package writable, which is neither possible nor the remedy. + expect(del.response?.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED'); + expect(dup.response?.body?.error?.code).toBe('DUPLICATE_SOURCE_NOT_A_BASE'); + expect(dup.response?.body?.error?.code).not.toBe(del.response?.body?.error?.code); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 7. [#14451] The gate is not an outage — the control that must be non-zero +// ══════════════════════════════════════════════════════════════════════════════ + +describe('/packages duplicate — a WRITABLE base still duplicates (#14451)', () => { + it('reaches the protocol with the source and target it was given, and mints the target', async () => { + const { dispatcher, registry, protocol } = make(); + expect(listedIds(registry)).not.toContain(DUP_TARGET); + + const r = await dispatcher.handlePackages( + `/${WRITABLE}/duplicate`, 'POST', { targetPackageId: DUP_TARGET, targetName: 'Dup Base' }, {}, admin(), + ); + + expect(r.response?.status).toBe(200); + expect(protocol.duplicatePackage).toHaveBeenCalledTimes(1); + expect(protocol.duplicatePackage.mock.calls[0]?.[0]).toMatchObject({ + sourcePackageId: WRITABLE, + targetPackageId: DUP_TARGET, + targetName: 'Dup Base', + }); + // The non-zero control the refusal cases are read against: on the allow + // path the double really does mint the record, so their `not.toContain` + // is a measurement rather than a vacuous truth. + expect(listedIds(registry)).toContain(DUP_TARGET); + }); + + it('an EMPTY writable base still answers 200 / copiedCount 0 — that read HAPPENED', async () => { + // ⛔ Deliberately unchanged by this card. The axis is whether the gesture + // APPLIES to the source, never whether it found anything: a base that + // owns no active rows is a read that ran and came back empty, which is + // the legitimate arm of the #11063 ruling. Narrowing this to "refuse an + // empty result" would refuse a legitimate duplicate of a fresh base. + const { dispatcher } = make(); + const r = await dispatcher.handlePackages( + `/${WRITABLE}/duplicate`, 'POST', { targetPackageId: DUP_TARGET }, {}, admin(), + ); + expect(r.response?.status).toBe(200); + expect(r.response?.body?.data?.copiedCount).toBe(0); + expect(r.response?.body?.data?.success).toBe(false); + }); + + it('an UNKNOWN source id falls through to the protocol — the gate is no existence oracle', async () => { + // Same rule `requireWritablePackage` follows: an id that resolves to + // nothing is treated as writable, so it reaches the route's own answer + // instead of being re-labelled 422 by a gate that would then leak which + // ids exist. + const { dispatcher, protocol } = make(); + const r = await dispatcher.handlePackages( + '/com.nobody.nothing/duplicate', 'POST', { targetPackageId: DUP_TARGET }, {}, admin(), + ); + expect(r.response?.status).not.toBe(422); + expect(protocol.duplicatePackage).toHaveBeenCalledTimes(1); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 8. [#14451] The sibling refusal stops prescribing a dead end +// ══════════════════════════════════════════════════════════════════════════════ + +describe('/packages lifecycle — the read-only refusal no longer sends callers at duplicate (#14451)', () => { + for (const verb of [ + { label: 'DELETE', path: (id: string) => `/${id}`, method: 'DELETE' }, + { label: 'disable', path: (id: string) => `/${id}/disable`, method: 'PATCH' }, + ]) { + it(`${verb.label} prescribes the ADR-0005 overlay, not POST /:id/duplicate`, async () => { + const { dispatcher } = make(); + const r = await dispatcher.handlePackages(verb.path(CODE_LOADED), verb.method, {}, {}, admin()); + const message = String(r.response?.body?.error?.message ?? ''); + + expect(r.response?.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED'); + // It used to read "…or duplicate this one into a writable base + // (POST /packages//duplicate) and change that" — a remedy that, + // for exactly the packages this refusal fires on, now answers 422 + // and before that answered an empty 200. A refusal that prescribes a + // dead end is worse than one that prescribes nothing. + expect(message).not.toContain('/duplicate'); + expect(message).toContain('ADR-0005'); + }); + } +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 39672a7e89..6c7bba0e7a 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -306,6 +306,15 @@ function requireReadCapability(deps: DomainHandlerDeps, context: HttpProtocolCon * authoring one ("switch to a writable package in the package selector") names * a remedy that makes no sense for a delete. * + * [#14451] The remedy it names is ADR-0005 org overlay, and it used to name + * `POST /packages/:id/duplicate` instead. That sentence sent a caller holding a + * read-only package at a route which — for exactly the packages this refusal + * fires on — cannot help: duplicate clones a base's `sys_metadata` rows, a code + * package owns none, and until {@link requireDuplicableSource} the trip ended + * in a 200 reporting `copiedCount: 0` (the #14451 measurement). A refusal that + * prescribes a dead end is worse than one that prescribes nothing, so the two + * doors now agree: overlay is how a code package is customised. + * * ⛔ Deliberately NOT caller-sensitive — there is no `isSystem` bypass, unlike * {@link requireManageMetadata}. Read-only is a property of the PACKAGE. Engine * self-invocation has no business uninstalling a code package over HTTP, and @@ -333,8 +342,8 @@ function requireWritablePackage( response: deps.error( `[writable_package_required] Cannot ${action} package '${id}': it is read-only ` + `(provided by code or an installed app). Packages the deployment ships are managed by ` - + `the deployment, not over this API — ${action} a package you own, or duplicate this one ` - + `into a writable base (POST /packages/${encodeURIComponent(id)}/duplicate) and change that.`, + + `the deployment, not over this API — ${action} a package you own, or customise what ` + + `this one provides in place, with an ADR-0005 org overlay.`, 422, { code: 'WRITABLE_PACKAGE_REQUIRED', @@ -345,6 +354,110 @@ function requireWritablePackage( }; } +/** + * [#14451] `POST /packages/:id/duplicate` — the SOURCE must be a BASE. + * + * ## The measurement + * + * `POST /api/v1/packages/com.example.todo/duplicate` against a RUNNING code + * package (`examples/app-todo`, one object + four flows + views + dashboards + + * reports) answered **HTTP 200**: + * + * {"success":true,"data":{"success":false,"copiedCount":0,"failedCount":0, + * "targetPackageId":"com.acme.dupbase","copied":[],"failed":[]}} + * + * …and left a real, empty package record behind: `com.acme.dupbase` appeared in + * `GET /packages` (scope-less, so `writable: true`), its detail door answered + * 200, and its manifest embedded a copy of the SOURCE bundle + * (`manifest.manifest.id === 'com.example.todo'`). Reproduced independently + * twice, on two separate `os dev` processes. + * + * ## Why `copiedCount: 0` is BY CONSTRUCTION, not a copy that failed + * + * {@link ObjectStackProtocolImplementation.duplicatePackage} clones the rows + * `sys_metadata` holds for the source (`{ package_id: source, state: 'active' }`). + * A code package's metadata is CODE — it is registered from an artifact at boot + * and has no `sys_metadata` rows at all — so the scan is not a copy that came + * back empty, it is a copy that could never have found anything. The scan + * cannot fail either, which is what made the old answer unfalsifiable: a + * `copied: []` that means "this gesture does not apply here" was byte-identical + * to one meaning "the base really is empty". + * + * That is the #11063 ruling, one route over and pointed at a WRITE: + * `packages/rest/src/package-routes.ts` states it verbatim — **"a read that + * could not happen must not be reported as a read that found nothing."** + * + * ## Why the refusal, rather than teaching duplicate to clone code items + * + * ADR-0070 D4 is DECLARED AND NOT BUILT — the ADR's own status line says + * "D4–D6 remaining", and D4's text names its object precisely: *"Duplicate: + * clone a **base** into a new writable package (the Airtable 'duplicate base' + * gesture)."* A base is a writable DB package (D-TL;DR 2). So this route is not + * a broken implementation of D4; it is a route shipped AHEAD of it, and the + * "duplicate base" prose around it describes an aspiration rather than a + * contract the code is failing to meet. + * + * Making it clone a code package's items would extend D4 from bases to code + * packages — a NEW decision, and one the ADR itself still lists as open + * ("should customising a code item also fork it into a writable base? + * *Leaning: keep overlay for surgical tweaks*"). ADR-0005 overlay is the built, + * shipped answer for customising what a code package provides, so the honest + * behaviour of an unbuilt gesture is to say so. + * + * ## Shape + * + * Same PREDICATE as every other writability verdict in this file + * ({@link isWritablePackage}, ADR-0070 D2 — #8146's "one answer to 'is this + * package writable?'"), a DIFFERENT code. `WRITABLE_PACKAGE_REQUIRED` would be + * a lie by implicature here: nothing is being written to the source, and its + * remedy ("use a writable package") reads as *make the source writable*, which + * is neither possible nor the point. `DUPLICATE_SOURCE_NOT_A_BASE` names the + * one thing the caller can act on — pick a base, or overlay instead. + * + * ⛔ Deliberately NOT a check on emptiness. A WRITABLE base holding no active + * rows still duplicates to `copiedCount: 0`, and that answer stays exactly as + * it is: that read HAPPENED and found nothing, which is the legitimate arm of + * the same ruling. The axis is whether the gesture applies, not whether it + * found anything. + * + * Runs BEFORE the protocol call, which is what makes it observable: the shell + * package is minted INSIDE `duplicatePackage` (its `installPackage` call + * precedes the copy loop), so a refusal that ran afterwards would still leave + * the empty `com.acme.dupbase` the report is about. Same "refuse before you + * mutate" ordering as {@link requireWritablePackage}, for the same reason. + * + * An id that resolves to nothing is treated as writable, exactly as + * {@link requireWritablePackage} treats it — an unknown source falls through to + * the protocol rather than being re-labelled 422, so this gate never becomes an + * existence oracle. + * + * Returns a refusal result to short-circuit on, or `null` to proceed. + */ +function requireDuplicableSource( + deps: DomainHandlerDeps, + engine: unknown, + id: string, +): HttpDispatcherResult | null { + if (isWritablePackage(engine, id)) return null; + return { + handled: true, + response: deps.error( + `[duplicate_source_not_a_base] Cannot duplicate package '${id}': it is read-only ` + + `(provided by code or an installed app), and duplicate clones a writable base's ` + + `stored metadata rows. A code package's metadata is delivered as code, so there are ` + + `no rows to clone and the copy would be empty (ADR-0070 D4 duplicates a BASE). ` + + `Duplicate a base you own, or customise what this package provides in place, with an ` + + `ADR-0005 org overlay.`, + 422, + { + code: 'DUPLICATE_SOURCE_NOT_A_BASE', + packageId: id, + docs: 'docs/adr/0070-package-first-authoring.md', + }, + ), + }; +} + /** * Handles Package Management requests * @@ -1021,9 +1134,18 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin } } - // POST /packages/:id/duplicate → clone this base into a NEW writable - // package, re-namespacing objects + rewriting references (ADR-0070 D4 - // "duplicate base"). Body { targetPackageId, targetName?, targetNamespace? }. + // POST /packages/:id/duplicate → clone a writable BASE into a NEW + // writable package, re-namespacing objects + rewriting references. + // Body { targetPackageId, targetName?, targetNamespace? }. + // + // [#14451] The source must BE a base — {@link requireDuplicableSource}, + // which carries the measurement and the reasoning. ADR-0070 D4 is + // declared-and-not-built ("D4–D6 remaining"), and its object is a *base* + // ("clone a base into a new writable package"): what this route clones + // is the source's `sys_metadata` rows, which a code package does not + // have. ⛔ Do not read the sentence above as "any package can be + // duplicated into an editable copy" — that is the aspiration the card + // measured against, and the route does not implement it. if (parts.length === 2 && parts[1] === 'duplicate' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); @@ -1035,6 +1157,10 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (!targetPackageId) { return { handled: true, response: deps.error('Body { targetPackageId } is required', 400) }; } + // [#14451] Refuse BEFORE the protocol call: `duplicatePackage` mints + // the target package record (`installPackage`) ahead of its copy + // loop, so a refusal any later still leaves the empty shell behind. + const notABase = requireDuplicableSource(deps, qlService, id); if (notABase) return notABase; try { const organizationId = await deps.resolveActiveOrganizationId(_context); const result = await protocol.duplicatePackage({ diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 56b7f93fd0..f77d63625b 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -367,7 +367,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ note: '[#12038] enveloped — the named schema is the `data`: `reassignOrphanedMetadata`\'s declared return, transcribed describe-only. Conformance: spec `api/package-lifecycle.test.ts`' }, { route: 'POST /packages/:id/duplicate', domain: '/packages', disposition: 'sdk', client: 'packages.duplicate', responseSchema: 'DuplicatePackageResponseSchema', - note: '[#12038] enveloped — the named schema is the `data`: `duplicatePackage`\'s declared return, transcribed describe-only. ⚠ `data.success` is the operation\'s verdict; the envelope `success` is transport-level and true even for a partial/empty duplicate (the objectui#6593 defect). Conformance: spec `api/package-lifecycle.test.ts`' }, + note: '[#12038] enveloped — the named schema is the `data`: `duplicatePackage`\'s declared return, transcribed describe-only. ⚠ `data.success` is the operation\'s verdict; the envelope `success` is transport-level and true even for a partial/empty duplicate (the objectui#6593 defect). [#14451] The SOURCE must be a writable base: a code-loaded or platform/marketplace source is refused 422 `DUPLICATE_SOURCE_NOT_A_BASE` (`requireDuplicableSource`, `domains/packages.ts`), BEFORE the protocol call and therefore before the target package record is minted — `duplicatePackage` installs that record ahead of its copy loop, so the pre-#14451 answer left a real empty shell behind. ADR-0070 D4 is declared-and-not-built and its object is a BASE, so cloning a code package\'s items would EXTEND D4 rather than implement it; the unbuilt case refuses loudly instead of answering 200 with `copiedCount: 0`, which no caller could tell from a base that really is empty. An empty WRITABLE base still answers 200 / `copiedCount: 0` — that read happened and found nothing. Pinned in `domains/packages-readonly-gate.test.ts`. Conformance: spec `api/package-lifecycle.test.ts`' }, // ── automation ──────────────────────────────────────────────────────────── { route: 'POST /automation/trigger/:name', domain: '/automation', disposition: 'sdk', client: 'automation.trigger', diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 617a803094..c2cf7c7e27 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -280,6 +280,21 @@ export const ERROR_CODE_LEDGER = { // operator acts on, and none of them is a synonym of `RESOURCE_CONFLICT` // in the detector's sense or in meaning. 'ACTION_DISABLED', + // [#14451] `POST /packages/:id/duplicate` was handed a source that is not a + // BASE — a code-loaded or platform/marketplace package. 422 from the + // `/packages` door (`domains/packages.ts`), refused BEFORE the protocol + // call so the target package record is not minted. + // + // ⛔ NOT a re-spelling of `WRITABLE_PACKAGE_REQUIRED`, and the distinction + // is why it is registered: that code says "this package may not be WRITTEN + // to", and its remedy is to use a writable package. Duplicate writes + // nothing to its source, so a caller reading that code here hunts for a way + // to make a code package writable — there is none, and it is not the point. + // This code says the GESTURE does not apply to this source: ADR-0070 D4 + // duplicates a base's stored rows, a code package's metadata is delivered + // as code, and the remedy is a base or an ADR-0005 overlay. Same predicate + // (`isWritablePackage`), different consequence. + 'DUPLICATE_SOURCE_NOT_A_BASE', 'EXPIRED_OR_REVOKED', // share link // [#9415] the trigger door refused to dispatch a flow that is switched off // — `respondToFlowTrigger` (`domains/automation.ts`) reads the engine's From 01aed5e00a04dce436d97c9757a0225e3b845120 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 13:27:23 +0000 Subject: [PATCH 2/3] docs(permissions): re-anchor the system-context census row this diff shifted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/permissions/system-context.mdx` anchors source files by ABSOLUTE LINE NUMBER, so adding `requireDuplicableSource` to `packages/runtime/src/domains/packages.ts` rotted row 52's citation of the anonymous-deny seam: `domains/packages.ts:422` -> `:535`, a shift of exactly the 113 lines inserted above it. Repaired with `node scripts/check-system-context-census.mjs --fix` (1 anchor rewritten); no line number was hand-edited. Verified as a PURE SHIFT rather than a population change, by re-deriving `node scripts/isystem-census.mjs --json` on BOTH sides (origin/main 4f379125e31 and this branch) in the same session rather than reusing any earlier figure: sites 106 = 106, identifierAppearances 885 = 885, classified 462 = 462, scannedFiles 293 = 293 (non-zero control), nonElevationReads 6 = 6, staleLedgerRows 0 = 0, and every roleCounts / text member equal. Keyed on identity (file, package, receiver, text) and compared as a MULTISET so duplicate keys inside one file cannot collapse: 89 distinct keys carrying 106 sites on both sides, ARRIVED = 0, VANISHED = 0, and exactly one site's LINE moved — in the one file this branch edits, with zero line moves anywhere else. Row 55's sibling anchor `domains/packages.ts:241` is deliberately untouched: it sits above the first edit, so it did not shift. Swept the whole of `content/` and `docs/` for other anchors into the four files this branch resizes — those two are the only hits, against a control of 559 anchor-shaped citations tree-wide. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 1c5ffe7baf..d5c9f17d0c 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -160,7 +160,7 @@ The largest single consumer — **17 of the 106 sites**. | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | | 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5084`, `:6510`, `:6758`, `:7189`, `:7382` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | -| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | +| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:535`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | | 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:241`, `:274` | From 87893fbd22c5a3931c9a39d02fea175e34199488 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 13:57:42 +0000 Subject: [PATCH 3/3] docs(runtime): strip the tracker id from the duplicate route's ledger note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:doc-authoring` refuses a new internal issue-id reference in sibling-package STRING prose, and the note added to `packages/runtime/src/route-ledger.ts` carried two: the `[#NNNN]` prefix and a `pre-#NNNN` back-reference. A route-ledger note is a runtime string that reaches authors, operators and generated surfaces, none of whom can resolve a tracker id. Maintainer ruling 2026-08-12, verbatim and untranslated: 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」 So the note now states the lesson self-containedly -- what the refusal is, where it lives, why it must precede the protocol call, and what the answer looked like before it -- with no id to resolve. The adjacent `//` comments in `domains/packages.ts` keep their ids: comments are the sanctioned carrier, and the gate reads only strings. ⛔ Not repaired by a baseline entry: `doc-authoring-prose-id.baseline.json` pins the adjudicated pre-existing population and is shrink-only, so an entry there weakens a ratchet and is maintainer-only. Gate now reads: sibling-package prose ids hold the baseline -- 829 pinned site(s) across 231 file(s), 85387 string(s) read in 1156 parsed source(s), no growth, no burn-down unrecorded. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- packages/runtime/src/route-ledger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index f77d63625b..3ce9ed4b97 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -367,7 +367,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ note: '[#12038] enveloped — the named schema is the `data`: `reassignOrphanedMetadata`\'s declared return, transcribed describe-only. Conformance: spec `api/package-lifecycle.test.ts`' }, { route: 'POST /packages/:id/duplicate', domain: '/packages', disposition: 'sdk', client: 'packages.duplicate', responseSchema: 'DuplicatePackageResponseSchema', - note: '[#12038] enveloped — the named schema is the `data`: `duplicatePackage`\'s declared return, transcribed describe-only. ⚠ `data.success` is the operation\'s verdict; the envelope `success` is transport-level and true even for a partial/empty duplicate (the objectui#6593 defect). [#14451] The SOURCE must be a writable base: a code-loaded or platform/marketplace source is refused 422 `DUPLICATE_SOURCE_NOT_A_BASE` (`requireDuplicableSource`, `domains/packages.ts`), BEFORE the protocol call and therefore before the target package record is minted — `duplicatePackage` installs that record ahead of its copy loop, so the pre-#14451 answer left a real empty shell behind. ADR-0070 D4 is declared-and-not-built and its object is a BASE, so cloning a code package\'s items would EXTEND D4 rather than implement it; the unbuilt case refuses loudly instead of answering 200 with `copiedCount: 0`, which no caller could tell from a base that really is empty. An empty WRITABLE base still answers 200 / `copiedCount: 0` — that read happened and found nothing. Pinned in `domains/packages-readonly-gate.test.ts`. Conformance: spec `api/package-lifecycle.test.ts`' }, + note: '[#12038] enveloped — the named schema is the `data`: `duplicatePackage`\'s declared return, transcribed describe-only. ⚠ `data.success` is the operation\'s verdict; the envelope `success` is transport-level and true even for a partial/empty duplicate (the objectui#6593 defect). The SOURCE must be a writable base: a code-loaded or platform/marketplace source is refused 422 `DUPLICATE_SOURCE_NOT_A_BASE` (`requireDuplicableSource`, `domains/packages.ts`), BEFORE the protocol call and therefore before the target package record is minted — `duplicatePackage` installs that record ahead of its copy loop, so refusing any later still left a real, listed, empty shell package behind. ADR-0070 D4 is declared-and-not-built and its object is a BASE, so cloning a code package\'s items would EXTEND D4 rather than implement it; the unbuilt case refuses loudly instead of answering 200 with `copiedCount: 0`, which no caller could tell from a base that really is empty. An empty WRITABLE base still answers 200 / `copiedCount: 0` — that read happened and found nothing. Pinned in `domains/packages-readonly-gate.test.ts`. Conformance: spec `api/package-lifecycle.test.ts`' }, // ── automation ──────────────────────────────────────────────────────────── { route: 'POST /automation/trigger/:name', domain: '/automation', disposition: 'sdk', client: 'automation.trigger',