Skip to content

fix(runtime): refuse POST /packages/:id/duplicate on a source that is not a base - #15849

Draft
os-litant wants to merge 2 commits into
mainfrom
claude/issue-14451-duplicate-non-base-refusal
Draft

fix(runtime): refuse POST /packages/:id/duplicate on a source that is not a base#15849
os-litant wants to merge 2 commits into
mainfrom
claude/issue-14451-duplicate-non-base-refusal

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #14451

POST /packages/:id/duplicate answered HTTP 200 with an empty copy when handed a running code package, and still created the target package record. This makes it refuse, loudly, with a new 422.

The measurement

Reproduced on examples/app-todo under os dev --seed-admin (independently, twice, on two separate processes before this card was filed):

POST /api/v1/packages/com.example.todo/duplicate  {"targetPackageId":"com.acme.dupbase"}
→ 200 {"success":true,"data":{"success":false,"copiedCount":0,"failedCount":0,
       "targetPackageId":"com.acme.dupbase","copied":[],"failed":[]}}

The source had one object, four flows, views, dashboards and reports. None of it was copied — and com.acme.dupbase was nonetheless listed by GET /packages (scope-less, so writable: true), its detail door answered 200, and its manifest embedded a copy of the source bundle.

Why copiedCount: 0 was BY CONSTRUCTION, not a copy that failed

ObjectStackProtocolImplementation.duplicatePackage clones the rows sys_metadata holds for the source ({ package_id: source, state: 'active' }). A code package's metadata is delivered as code — registered from an artifact at boot — so it owns no such rows. The scan was not a copy that came back empty; it was a copy that could never have found anything, and it cannot fail either, which is what made the old answer unfalsifiable: a copied: [] meaning "this gesture does not apply here" was byte-identical to one meaning "the base really is empty".

That is a rule this repo has already stated, one route over in packages/rest/src/package-routes.ts:758"a read that could not happen must not be reported as a read that found nothing" — pointed here at a write.

Why a refusal, and not teaching duplicate to clone code items

The card asked which of two worlds we are in. Measured on this tree, it is a third one:

  • docs/adr/0070-package-first-authoring.md:3Status: "P1–P3 implemented … D4–D6 remaining", and :25 lists D4 (delete-cascade / export / duplicate) under Remaining.
  • :80 — D4's own text: "Duplicate: clone a base into a new writable package (the Airtable 'duplicate base' gesture)." A base is a writable DB package (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 described an aspiration rather than a contract the code was failing to meet. Making it clone a code package's items would extend D4 from bases to code packages, which is a new decision, not this repair — and one the ADR still carries as an open question at :103 ("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.

⛔ No ADR text is changed by this PR.

The change

1. requireDuplicableSource422 DUPLICATE_SOURCE_NOT_A_BASE (packages/runtime/src/domains/packages.ts). A code-loaded, platform- or marketplace-scoped source is refused, naming the package, the ADR pointer and the remedy that exists for it.

  • Same predicate as every other writability verdict in the file (isWritablePackage, ADR-0070 D2 — [Decision] OS_METADATA_WRITABLE unlocks a write on a read-only package while Studio renders a "Read-only" badge — which one is telling the truth? #8146's "one answer to 'is this package writable?'"), a different code. WRITABLE_PACKAGE_REQUIRED would be a lie by implicature here: nothing is written to the source, and its remedy reads as make the source writable, which is neither possible nor the point.
  • It runs before the protocol call. That placement is the whole pin: duplicatePackage mints the target record (installPackage) ahead of its copy loop, so a refusal any later would still leave the empty shell behind.
  • Not a check on emptiness. A writable base owning no active rows still answers 200 / copiedCount: 0 — that read happened and found nothing, the legitimate arm of the same ruling. The axis is whether the gesture applies, never whether it found anything.
  • An id that resolves to nothing is still treated as writable, so an unknown source falls through to the route's own answer rather than being re-labelled 422 — the gate never becomes an existence oracle.

2. The sibling refusal stops prescribing a dead end. requireWritablePackage's 422 (from DELETE /packages/:id and PATCH /packages/:id/disable) told read-only-package callers to "duplicate this one into a writable base (POST /packages/:id/duplicate) and change that" — a route that, for exactly the packages that refusal fires on, previously answered an empty 200 and now answers 422. It points at the ADR-0005 overlay instead.

3. New ledger row under @objectstack/runtime in packages/spec/src/api/error-code-ledger.zod.ts, its two generated content/docs/references/ projections, and the POST /packages/:id/duplicate note in packages/runtime/src/route-ledger.ts.

⚠️ Behaviour change for API callers: duplicating a code, platform or marketplace package was 200, is now 422. Duplicating a writable base is untouched in every respect.

Bounded in-scope repair, called out

The requireWritablePackage message edit (2) is not the route this card is about. It is admitted under the bounded-repair rule: same defect class (the same gesture, the same read-only packages), mechanically determined by the change in (1) — a refusal cannot keep prescribing a route that now refuses — inside the same file and the same gate family, adding no verification surface. Scanned for other carriers of that prescription: into a writable base appears in exactly 4 places tree-wide, of which one is this message; the rest are a metadata-protocol test docstring, a CHANGELOG entry and ADR-0070's own open-question text, none of which prescribe the route to a caller.

The card's cited landing point was wrong (not drift)

The card cites packages/rest/src/package-routes.ts:816-818. Measured here: the token duplicate appears 0 times in that file, against a control of 70 for packages in the same file. The route lives in packages/runtime/src/domains/packages.ts. So triage direction item 3 ("narrow the REST comment") has no carrier; the comment that needed narrowing is the runtime one, and it is narrowed in this PR.

Tests

packages/runtime/src/domains/packages-readonly-gate.test.ts gains sections 6-8, driving a real SchemaRegistry (not a listing double).

⚠️ The protocol double mints the target record, because the real implementation does. That is not decoration — it is what makes the refusal assertions 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.

  • 3 read-only shapes (system scope, cloud scope, code-loaded) → 422, DUPLICATE_SOURCE_NOT_A_BASE, listing unchanged, target absent, protocol never called.
  • The ADR-0112 envelope: httpStatus, details.packageId, details.docs, and a message naming ADR-0005.
  • The two codes are distinguishable on the same package: DELETE still answers WRITABLE_PACKAGE_REQUIRED.
  • The non-zero control: a writable base still reaches the protocol with the source/target it was given and the record is minted; an empty writable base still answers 200 / copiedCount: 0; an unknown id still falls through.
  • The read-only refusal no longer contains /duplicate and does contain ADR-0005, on both verbs.

Ablation — direction predicted in writing first

Prediction recorded before the run: deleting the one gate line makes exactly 5 cases red and every other case green, with the reasons named per case — in particular the allow-path control, the empty-base case and the unknown-id case must stay green, since isWritablePackage answers true for all three and the gate is a no-op on them.

Mutation proven on disk before running (removed-text 1 → 0, injected marker 0 → 1, git diff HEAD --stat non-empty), restored under an EXIT INT TERM trap via git checkout HEAD -- ABSOLUTE_PATH, and the restore proven by HEAD-blob equality (6c7bba0e7ad735ed61ca1bd350f7e4e570c2728b both sides) plus an empty git diff HEAD.

Result: Tests 5 failed | 20 passed (25) — the predicted five, and only those. The first failure reads AssertionError: expected 200 to be 422, i.e. the ablated tree reproduces the reported defect verbatim. No rebuild was needed and that is a measured claim rather than an assumption: the subject is reached by a relative in-package import, so vitest compiled the mutated source — had it been resolving through dist/, the ablation would have come back green, which is the false-green this discipline exists to catch.

Verification

All on merge commit 61b7fc474e1 (after merging origin/main to clear a STALE TREE reading), quoting each gate's own verdict line:

what result
@objectstack/runtime vitest (src/domains/, route-ledger + discovery conformance, duplicate/adopt integration) Test Files 55 passed (55) · Tests 908 passed (908)
@objectstack/runtime typecheck tsc --noEmit clean; check:test-typecheck: OK — @objectstack/runtime's test layer compiles under packages/runtime/tsconfig.test.json (so the new test file really is type-checked)
@objectstack/spec check:generated ✓ All 15 generated artifacts are up to date.
@objectstack/spec vitest (error-code-ledger, package-lifecycle) Test Files 2 passed (2) · Tests 37 passed (37) — the synonym detector admits the new code mechanically
check:error-code-provenance OK — every registered-code stamp site is listed under its own owner key
check:nul-bytes OK (scanned 7652 text file(s) … no raw ASCII control bytes)
check:error-code-casing · check:dispatcher-error-vocabulary · check:error-status-conformance · check:route-envelope · check:single-claim-paths · check:changeset-gate-self-tests all exit 0
check-empty-changeset · check-changeset-no-major · check-changeset-fixed · check-adr-0087-registration ✓ No empty-frontmatter changeset · ✓ no major bump · ✓ fixed group in sync · ✓ adds no declared-breaking changeset

Gate union re-derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack after the change set was final: 162 families over 7 paths. The repo-wide farm is CI's; run locally are the families this diff actually implicates, above.

ESLint — a proven narrowing, not a skip. Population read from ESLint's own config, not from a guess: of the 7 changed paths its config matches 4 (the .ts files) and it reports the other three itself as File ignored because no matching configuration was supplied. Counted from --format json: 7 result entries, 4 linted, 0 errors, 0 warnings (the 3 warnings are those ignore notices). Invariance for untouched files is declared in eslint.config.mjs:327 — this repo runs one config which "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not" — so no untouched file's verdict can move because of this diff.

Clause ②

Both limbs judged separately, per the dispatch:

  • Mechanical / path limb — NO. dispatch-gates.mjs on the final change set: "no path-derived mandate: the surface hits none of the 3 declared glob(s)". It does flag packages/spec/src/api/error-code-ledger.zod.ts as SUSPECT surface — "a hint, not a verdict".
  • Content limb — YES. This changes what the door accepts and rejects: a request that was answered 200 is now refused 422, and a new code enters the wire vocabulary. That is clause ② whatever the paths say.

needs:contract-review is therefore carried on both the card and this PR, and the card's claim comment declares Clause-②: yes.

Not done here, deliberately

  • objectui. packages-io.ts's duplicatePackage doc ("This is how a read-only code package becomes a customizable starting point") still says the opposite of D4, and the Studio read-only hint still steers users at duplicate. Fenced out of this card by the dispatch; it needs its own card in that repo. The client is not wrong about this server's behaviour any more — it will now receive a named 422 instead of the EMPTY arm.
  • Cloning code items into a base. A new decision extending ADR-0070 D4, with no measured pull and an existing shipped alternative (ADR-0005 overlay). Not implemented.
  • showcase as a control. Unusable — the same call there fails at sys_packages persistence for an unrelated reason, as the card records.

Generated by Claude Code

…is not a base

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/runtime, @objectstack/spec, touching 13 documentable anchor(s).

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-handling-server.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/getting-started/examples.mdx (via com.example.todo (literal, a string literal on a changed line))
  • content/docs/kernel/contracts/data-engine.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 135 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5a21d73af58e6456b253163af8ffa072573a0771packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2a6218619ff1a5e9a9a702fb6bba94c2bd13976f — the merge of head 61b7fc474e1484c3fcd57e607d0cb82d7550d60d into base 5a21d73af58e6456b253163af8ffa072573a0771, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2a6218619ff1a5e9a9a702fb6bba94c2bd13976f && git checkout 2a6218619ff1a5e9a9a702fb6bba94c2bd13976f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5a21d73af58e6456b253163af8ffa072573a0771 61b7fc474e1484c3fcd57e607d0cb82d7550d60d && git checkout -B drift-repro 5a21d73af58e6456b253163af8ffa072573a0771 && git merge --no-ff 61b7fc474e1484c3fcd57e607d0cb82d7550d60d

node scripts/docs-audit/affected-docs.mjs --json 5a21d73af58e6456b253163af8ffa072573a0771

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5a21d73af58e6456b253163af8ffa072573a0771 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants