fix(runtime): refuse POST /packages/:id/duplicate on a source that is not a base - #15849
fix(runtime): refuse POST /packages/:id/duplicate on a source that is not a base#15849os-litant wants to merge 2 commits into
POST /packages/:id/duplicate on a source that is not a base#15849Conversation
…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
…plicate-non-base-refusal
📓 Docs Drift CheckThis PR changes 2 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 135 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Fixes #14451
POST /packages/:id/duplicateanswered 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 new422.The measurement
Reproduced on
examples/app-todounderos dev --seed-admin(independently, twice, on two separate processes before this card was filed):The source had one object, four flows, views, dashboards and reports. None of it was copied — and
com.acme.dupbasewas nonetheless listed byGET /packages(scope-less, sowritable: true), its detail door answered 200, and its manifest embedded a copy of the source bundle.Why
copiedCount: 0was BY CONSTRUCTION, not a copy that failedObjectStackProtocolImplementation.duplicatePackageclones the rowssys_metadataholds 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: acopied: []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:3— Status: "P1–P3 implemented … D4–D6 remaining", and:25lists 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.
requireDuplicableSource—422 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.isWritablePackage, ADR-0070 D2 — [Decision]OS_METADATA_WRITABLEunlocks 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_REQUIREDwould 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.duplicatePackagemints the target record (installPackage) ahead of its copy loop, so a refusal any later would still leave the empty shell behind.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.2. The sibling refusal stops prescribing a dead end.
requireWritablePackage's422(fromDELETE /packages/:idandPATCH /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/runtimeinpackages/spec/src/api/error-code-ledger.zod.ts, its two generatedcontent/docs/references/projections, and thePOST /packages/:id/duplicatenote inpackages/runtime/src/route-ledger.ts.200, is now422. Duplicating a writable base is untouched in every respect.Bounded in-scope repair, called out
The
requireWritablePackagemessage 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 baseappears in exactly 4 places tree-wide, of which one is this message; the rest are ametadata-protocoltest docstring, aCHANGELOGentry 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 tokenduplicateappears 0 times in that file, against a control of 70 forpackagesin the same file. The route lives inpackages/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.tsgains sections 6-8, driving a realSchemaRegistry(not a listing double).DUPLICATE_SOURCE_NOT_A_BASE, listing unchanged, target absent, protocol never called.httpStatus,details.packageId,details.docs, and a message naming ADR-0005.DELETEstill answersWRITABLE_PACKAGE_REQUIRED.200/copiedCount: 0; an unknown id still falls through./duplicateand does containADR-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
isWritablePackageanswers true for all three and the gate is a no-op on them.Mutation proven on disk before running (removed-text
1 → 0, injected marker0 → 1,git diff HEAD --statnon-empty), restored under anEXIT INT TERMtrap viagit checkout HEAD -- ABSOLUTE_PATH, and the restore proven by HEAD-blob equality (6c7bba0e7ad735ed61ca1bd350f7e4e570c2728bboth sides) plus an emptygit diff HEAD.Result:
Tests 5 failed | 20 passed (25)— the predicted five, and only those. The first failure readsAssertionError: 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 throughdist/, the ablation would have come back green, which is the false-green this discipline exists to catch.Verification
All on merge commit
61b7fc474e1(after mergingorigin/mainto clear a STALE TREE reading), quoting each gate's own verdict line:@objectstack/runtimevitest (src/domains/, route-ledger + discovery conformance, duplicate/adopt integration)Test Files 55 passed (55)·Tests 908 passed (908)@objectstack/runtime typechecktsc --noEmitclean;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/specvitest (error-code-ledger,package-lifecycle)Test Files 2 passed (2)·Tests 37 passed (37)— the synonym detector admits the new code mechanicallycheck:error-code-provenanceOK — every registered-code stamp site is listed under its own owner keycheck:nul-bytesOK (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-testscheck-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 changesetGate union re-derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackafter 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
.tsfiles) and it reports the other three itself asFile 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 ineslint.config.mjs:327— this repo runs one config which "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) 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:
dispatch-gates.mjson the final change set: "no path-derived mandate: the surface hits none of the 3 declared glob(s)". It does flagpackages/spec/src/api/error-code-ledger.zod.tsas SUSPECT surface — "a hint, not a verdict".200is now refused422, and a new code enters the wire vocabulary. That is clause ② whatever the paths say.needs:contract-reviewis therefore carried on both the card and this PR, and the card's claim comment declaresClause-②: yes.Not done here, deliberately
packages-io.ts'sduplicatePackagedoc ("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.showcaseas a control. Unusable — the same call there fails atsys_packagespersistence for an unrelated reason, as the card records.Generated by Claude Code