Repository navigation
fix(metadata-protocol): duplicatePackage reports a refused install of the copy's package record - #24081
Conversation
… the copy's package record The duplicate wrote the copy's package record through installPackage inside a bare catch that kept nothing, so a refused install left a copy with no registry record and no sys_packages row, answered success: true, and logged nothing. The refusal now rides failed[] as the source's package item (quoted when it declares a client status) and is logged once at warn with the target id and the refusal's code and sentence. The copy still proceeds. Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
…ave spies Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
…d install refusal Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 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 84c5b781b5fa7d2e9c5d6aee86def481eb990b18 && git checkout 84c5b781b5fa7d2e9c5d6aee86def481eb990b18
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8a83cebc15a5efedff290e2c208abfba08fca9d6 de14c8521a5d09a39d85476fa8434f9632b5be5a && git checkout -B drift-repro 8a83cebc15a5efedff290e2c208abfba08fca9d6 && git merge --no-ff de14c8521a5d09a39d85476fa8434f9632b5be5a
node scripts/docs-audit/affected-docs.mjs --json 8a83cebc15a5efedff290e2c208abfba08fca9d6
|
Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
…d refuses the duplicate duplicatePackage now rethrows installPackage's refusal unchanged, before any item is copied, so the door answers the refusal's own status (422 NAMESPACE_CONFLICT with the registry's remedy, or the primitive's 500 for a store fault) and nothing is minted or copied. The install is the duplicate's first mutation and a refused install leaves nothing behind, so no rollback is needed. The refusal is logged once at warn; a store fault's error is attached so its cause is not lost. The failed[] package entry is retired. Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
…or, with its migration prose Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Read at 2026-10-17, against the head named above. Inputs: card #24041's body and its five comments ( ① Derived judgmentsThe one accept-set change, and it is right. Which duplicates that succeeded on
Nothing left behind (premise 1): holds, by code read. From the method head to the install call:
The door: every refusal reaches the caller with the right status.
The retired round-1 surface: gone. No The four comment edits are true at this head. The docblock sentence; comment ① (a rethrown install refusal names The pins hold the narrowing on both sides. Case 1 asserts the thrown object is the registry's ( ② Semver level
③ Boundary flags
Check-runs on the head: 47, every one completed: 39 success, 8 skipped ( Implemented-by: VERDICT: PASS |
席位核对 · ✅ ACCEPT: PR #24081 at
|
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 throughinstallPackagebefore it copies any item. That call sat in a barecatch {}. 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 inSchemaRegistry.installPackage). That covers an explicittargetNamespaceand one derived from the last segment oftargetPackageId.500, the install's own sentence, when thesys_packageswrite fails.The refusal is logged once at
warnby[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 itscause. A client refusal is not attached; its sentence is already the whole story.This keeps the rule the sibling verb
importUnmanagedPackagestates: "There is no half-copied app." The first round of this PR (afailed[]entry of typepackageon 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)registry.getPackage(read),findEnvironmentRows('sys_metadata', …)(engine.findplus the legacy-row read, both reads), and in-memoryMaps. Measured with a one-off probe (not committed) over the realObjectKernel,ObjectQLPluginandSchemaRegistry, 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, atinstallPackageentry, 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.HttpDispatcher.handlePackagesforPOST /:id/duplicateover a realSchemaRegistry, withduplicatePackagethrowing the exact objects the realinstallPackagethrows. The namespace conflict answered422witherror.codeNAMESPACE_CONFLICTand the registry's full sentence, remedy included. The store fault answered500INTERNAL_ERRORwith the primitive's own sentence, which quotes only the package id. The door'scatchisdeps.errorFromThrown(e, 500)(packages/runtime/src/domains/packages.ts). Neither the door nor the primitive logged either refusal..objectui-shaf8267df93:packages-io.tsduplicatePackagethrowsreadEnvelopeFailureText(payload)on any non-OK answer (lines 278-289), and both callers show the thrown message:PackagesPage.tsx584-588 (an error banner) andBuilderLanding.tsx140-141 (setDupErr). Studio sends notargetNamespace, so from Studio the conflict comes only through a derived namespace. In this repo, the SDK'sclient.packages.duplicategoes throughunwrapResponse, 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
NAMESPACE_CONFLICT, 422SchemaRegistry.installPackage's namespace gatetargetNamespaceverbatim. 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=warndowngrades it to a warning, and the duplicate then proceeds.sys_packageswrite failed, 500installPackage(packagePersistFailureError, after its undo)installPackage's id legduplicatePackageparsestargetPackageIdwith the same declaration first.assertProtocolCompatengines, which this runtime already admitted.requireDuplicableSourcePins
packages/metadata-protocol/src/protocol.duplicate-package-install-refusal.test.ts, three cases:SchemaRegistrycannot be imported here, because@objectstack/objectqldepends on this package. So the harness registry refuses exactly when another package owns the namespace, throwingNamespaceConflictError's envelope (code,status422, its sentence), and the realinstallPackagemeets it. It asserts that the duplicate rejects with that very object (toBe), thatresolveThrownHttpErrorresolves it to422andNAMESPACE_CONFLICT, thatsaveMetaItemwas 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.sys_packageswrite, with no stub on the install path. Thepackageservice answers{ success: false, driverFault }, and the real primitive undoes its registry write and throws its 500. It asserts the rejection resolves to500, 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, whosecauseholds the driver text.success: true, the item copied asleave_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
68429a829:typecheckexit 0, and the three filesTest Files 3 passed (3),Tests 35 passed (35).node scripts/ablation-replace.mjsin WRAP mode:throw e;replaced by a comment (anchor x1 to x0, marker x0 to x1, blobbf8238167abato2ea0aabb7b15). The pin readTests 2 failed | 1 passed (3): both refusal cases failed withError: expected the duplicate to be refused, but it resolved, and the CONTROL stayed green. The restore is proven: blob equals HEAD (bf8238167aba),git diff HEADis empty, and the marker count is 0. Predicted direction red, observed red. The test imports the source by relative path, so nodist/leg applies.Tests and gates
pnpm --filter @objectstack/metadata-protocol exec vitest runatde14c8521:Test Files 243 passed | 3 skipped (246),Tests 20611 passed | 19 skipped (20630).duplicatePackage, against a rebuilt metadata-protocoldist/that carries the rethrow:Test Files 5 passed (5),Tests 29 passed (29).de14c8521:check-changeset-no-major --base origin/mainexit 0 (nomajor);check-adr-0087-registration --base origin/mainexit 0, reading[clause-②-narrowing] not-required (no-migration-prescription);check-empty-changeset --base origin/mainexit 0. The changeset isminor, as a narrowing ships during the launch window.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackatde14c8521(0 commits behindorigin/main) derived 65 families, each run with its exit captured before any pipe: 64 exit 0, among themcheck: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-closureandcheck:published-files.pnpm check:dual-build-cjs-loadsexit 3 (PREREQUISITE NOT MET: it needs every package'sdist/), 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 shipsminor, exit 0.Review owed
A narrowing: one
CONTRACT_REVIEW_TIERreview is owed before the queue (claim amendment 6227008947).Acceptance notes
srcPkgundefined), the install is skipped without a word, and that copy has no package record. The runtime door'srequireDuplicableSourcepasses an unknown id on. This is outside the card by the claim; not measured; noted, not filed. Carrier: none.Generated by Claude Code