Skip to content

fix(metadata-protocol): duplicatePackage reports a refused install of the copy's package record - #24081

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24041-duplicate-install-refusal
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24041-duplicate-install-refusal

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

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 Maps. 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

… 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>
…d install refusal

Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 2 documentable anchor(s).

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

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

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

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

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
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 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 8a83cebc15a5efedff290e2c208abfba08fca9d6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 84c5b781b5fa7d2e9c5d6aee86def481eb990b18 — the merge of head de14c8521a5d09a39d85476fa8434f9632b5be5a into base 8a83cebc15a5efedff290e2c208abfba08fca9d6, 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 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

⚠️ 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 8a83cebc15a5efedff290e2c208abfba08fca9d6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: de14c8521a5d09a39d85476fa8434f9632b5be5a
Local-runs: none
Scope: packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol.duplicate-package-install-refusal.test.ts, packages/metadata-protocol/src/protocol.duplicate-package-target-namespace.test.ts, packages/metadata-protocol/src/protocol.install-manifest-id.test.ts, .changeset/24041-duplicate-install-refusal.md + packages/objectql/src/registry.ts (SchemaRegistry.installPackage, NamespaceConflictError, unregisterItem, unregisterNamespace), packages/objectql/src/security-catalog-namespace.ts, packages/objectql/src/default-record-page.ts, docs/adr/0048-cross-package-metadata-collision.md (Phase 1), packages/runtime/src/domains/packages.ts (requireDuplicableSource, the duplicate door), packages/runtime/src/http-dispatcher.ts (errorFromThrown), packages/types/src/thrown-http-error.ts (resolveThrownHttpError), packages/spec/src/api/package-lifecycle.zod.ts (DuplicatePackageResponseSchema), packages/services/service-package/src/index.ts (publish), packages/metadata-core/src/protocol-handshake.ts (assertProtocolCompat), packages/client/src/index.ts (packages.duplicate), scripts/check-durability-degradation-log-level.mjs (FAILURE_PROPAGATION_SITES), scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs, and objectui at the pinned f8267df93 (read only): packages-io.ts, PackagesPage.tsx, BuilderLanding.tsx.

Read at 2026-10-17, against the head named above. Inputs: card #24041's body and its five comments (6225499409, 6226147624, 6226977409, 6227008947, 6227604127); contract record 6224173098 on PR #24028 (flag 3); PR #24081's body (the round-2 rewrite), its 5-file list and the net diff 8a83cebc1..de14c8521 (+274/-14; main has not touched any of the five files since the merge base); and the 47 check-runs on the head, read last. Code read with git show at the head; line numbers below are the head's. Nothing built, run or re-run.

① Derived judgments

The one accept-set change, and it is right. duplicatePackage (protocol.ts:27440-27489) installs the copy's package record through installPackage before its copy loop. On main the call sat in catch {} and the loop ran regardless. At this head the catch logs once and rethrows e unchanged, so every duplicate whose installPackage throws is refused before any item is written, with the install's own object. No new judge is added, no sentence is reworded, and the try body (the dupManifest spread, delete dupManifest.scope, the call) can throw nothing but the install's refusal, so the accept set shrinks by exactly installPackage's refusal set. No false refusal.

Which duplicates that succeeded on main are refused now, and with what:

  • An explicit targetNamespace that a different installed package owns (shareable base/system/sys exempt; the ADR-0130 co-owner exemption never applies, there is no artifact scope): 422 NAMESPACE_CONFLICT, NamespaceConflictError's full sentence with its remedy (registry.ts:1590-1620, thrown at :5494-5509). Right: ADR-0048 Phase 1's install-time gate refuses exactly this, and on main the copy's objects were planted in the other package's namespace (the dev's round-1 probe).
  • A derived namespace (deriveNamespaceFromPackageId(targetPackageId), :27136) equal to another installed package's, the source's own included (com.a.crm duplicated to com.b.crm): the same 422. Right: on main that duplicate left a record-less copy plus per-object ADR-0029 D9.9 failures.
  • A failed sys_packages write: 500, packagePersistFailureError's sentence (:31628-31640), which quotes only the package id and carries the store's answer on cause. Right: on main the items were copied for a package whose record never persisted.
  • Reachable only by an in-process caller handing a collection-bearing record: the security-catalog and default-record-page conflicts (422, the registry's own sentences) and the ADR-0087 D1 handshake (ProtocolIncompatibleError, 422, protocol-handshake.ts:122-128). Each is a refusal installPackage already makes; rethrowing it is right. The manifest.id leg is unreachable (duplicatePackage parses targetPackageId with the same declaration first, :27079-27085).
  • Unchanged, as the amendment ordered: a source with no registry record (srcPkg undefined) skips the install silently (:27440).

Nothing left behind (premise 1): holds, by code read. From the method head to the install call: refuseOrganizationScopedWrite (throws only, :27050, :17362-17365); two safeParse declarations (:27079, :27136); registry.getPackage (a read, :27089); findEnvironmentRows('sys_metadata', …) (:27158), which is engine.find plus legacyOrganizationRowsOf, a driver read (:10432-10447); then in-memory Maps and closures only (renameMap, claimedBy, refusedObjects, viewItemRenames, deepRewrite, rewriteFieldRuleKeys, :27163-27437). copied/failed and resolveFlowCanonicalizer sit after the install (:27492-27497). No mint, no sys_metadata row, no registry entry and no namespace owner precedes the call, so a rethrow needs no rollback.

  • Inside installPackage on the 422 path: the registry's four judges (namespace gate, co-owned object names, security catalog, default record page) all run ahead of registerNamespace, collection.set and the two claim recorders (registry.ts:5494-5611), so a refused install leaves nothing. On the 500 path undoInstall() (:31528-31551) withdraws the row (unregisterItem('package'), registry.ts:4880) and releases the namespace only when this install registered it; the duplicate passes no enableOnInstall, so no lifecycle write happens.
  • One residue, pre-existing and outside the claim: the undo does not call forgetSecurityCatalogClaims/forgetDefaultRecordPageClaims, so a 500 after recordSecurityCatalogClaims/recordDefaultRecordPageClaims (:5607-5609) leaves those claims. They read positions/permissions/capabilities/pages arrays (security-catalog-namespace.ts:182, default-record-page.ts:109), which a duplicable base's record manifest does not carry (the strict ManifestSchema at POST /packages, Studio and this door never write them), so from the HTTP door nothing is left. Reachable only in-process with a collection-bearing record and a failing sys_packages write. A On MySQL, sys_packages is never created (its raw DDL fails three ways), so package publish answers 500 and an installed or edited package silently disappears on restart while install and PATCH answer success #21243 seam in installPackage, which the claim bans editing; escalated in ③, not a FAIL here.

The door: every refusal reaches the caller with the right status. POST /packages/:id/duplicate (runtime/src/domains/packages.ts:2152-2182): requireManageMetadata, the walled-catalog refusal, requireDuplicableSource (422 DUPLICATE_SOURCE_NOT_A_BASE before the protocol call, :628-660), then deps.errorFromThrown(e, 500) on any throw. errorFromThrown (http-dispatcher.ts:1237) is resolveThrownHttpError (thrown-http-error.ts): it reads .status/.statusCode/.code, so NamespaceConflictError (status 422, registered code NAMESPACE_CONFLICT) answers 422 NAMESPACE_CONFLICT with the registry's sentence and remedy intact, and the persist failure (status 500, no catalogued code) answers 500 INTERNAL_ERROR with the primitive's sentence. details carries only a non-string code, issues or validation fields, never cause, and this.error withholds a leaky 5xx message besides (#3867): no driver prose in any body. The warn line is console.warn on the server; its string is the refusal's own sentence (a registry sentence, or the primitive's id-only sentence), and the error object is attached for a 5xx alone. Nothing a caller reads carries store prose.

OS_METADATA_COLLISION=warn: the duplicate still proceeds, and that is right. The gate is the registry's (registry.ts:2328, :5498-5505): under warn it logs and installs, installPackage returns, the copy becomes a second owner of the namespace and the loop runs. ADR-0048 Phase 1 names that downgrade as the documented deliberate-migration hatch; the PR inherits the gate's verdict instead of judging the namespace a second time, and the changeset says so under Remedy.

The retired round-1 surface: gone. No type: 'package' entry remains in duplicatePackage (the method's only clientFacingFailureText(e, …) is the per-item one, :27657); copied/failed are declared where main declares them (:27492-27493); the test file is rewritten to the refusal envelope; the changeset describes the refusal only. FAILURE_PROPAGATION_SITES still names failed.push as this function's declared sink (check-durability-degradation-log-level.mjs:574-583) and the diff leaves that sink exactly as declared; the gate concluded green in Lint · docs & code guards.

The four comment edits are true at this head. The docblock sentence; comment ① (a rethrown install refusal names manifest.id: installPackage:31306-31312 throws manifestIdRefusal('manifest.id', rawId)); the target-namespace test ("the manifest write does not judge a namespace's charset at all": protocol.installPackage only derives a namespace when absent, :31335-31338; SchemaRegistry.installPackage carries no charset parse; service-package.publish serialises without parsing, :479-495); the manifest-id test ("would refuse the duplicate too, but under manifest.id").

The pins hold the narrowing on both sides. Case 1 asserts the thrown object is the registry's (toBe), resolves it through the door's own resolver to 422/NAMESPACE_CONFLICT, and asserts saveMetaItem never called, no target record, owners unchanged, one warn line with no error object. Case 2 stubs nothing on the install path: the package service returns { success: false, driverFault }, persistPackageManifest reports it (:31575-31590), the real undoInstall() removes the row and the real 500 names the target and quotes nothing. The CONTROL copies and logs no refusal. The harness envelope matches the real class (name, code, status and httpStatus 422, the remedy clause). The dev's ablation (throw e; removed) reads both refusal cases red and the CONTROL green, the right direction.

② Semver level

  • Clause-②: no (narrowing) on its own line in both the PR body and the changeset matches the diff: a published verb of @objectstack/metadata-protocol and the HTTP route built on it now refuse duplicates they accepted. AGENTS.md: (narrowing) is BREAKING; check-changeset-no-major (its docblock, :58-96, and the arm reading at :1631-1670): during the launch window a breaking change ships minor. "@objectstack/metadata-protocol": minor is the right grade. Bumping only that package is right: the runtime door's code is unchanged (its behaviour moves through the dependency) and DuplicatePackageResponseSchema is untouched.
  • The ADR-0087 marker adr-0087: not-required (no-migration-prescription) is right: nothing stored, no spec key and no published type changes, so objectstack migrate meta has nothing to rewrite, and the body carries no FROM to TO prescription, the only thing the gate refuses that category on (check-adr-0087-registration.mjs:147-155). Check Changeset concluded success on both runs, the second after the body rewrite.
  • The migration prose is true as read. Refused now: both legs, with "last segment of targetPackageId" a fair gloss of deriveNamespaceFromPackageId. Unchanged: an accepted install copies as before, per-item failures still ride failed[], a record-less source still copies without one. Remedy: targetNamespace or another id, and the warn downgrade. Logged: as the code does.

③ Boundary flags

  • open_questions: empty in round 2; round 1's question was answered by the amendment (ruling B) and built as ruled.
  • The srcPkg-undefined silent skip: out of scope by the claim and kept out by the amendment ("The out-of-scope note stays out of scope"); the dev's "carrier: none" is right for this PR. Agreed it is a silent partial state of the same family: a copy with no package record, absent from GET /packages, which requireDuplicableSource lets through for an unknown id. ESCALATED to the seat as a candidate card (class b, reach unmeasured), the carrier the dev's note lacks.
  • The 4xx pin's harness stub at the registry boundary: accepted. @objectstack/objectql depends on this package, so the real gate cannot be imported in-package; the harness throws the real class's envelope and the real installPackage meets it; objectql's five real-registry files that drive duplicatePackage passed in Test Core against the rebuilt dist/. The real-registry probes are cited, not kept, which the dependency cycle justifies.
  • The warn line attaching the error object for a 5xx: right to keep the line, and attaching is harmless, but the stated reason is inaccurate at this head. For the real package service, publish logs the raw driver error itself at error (service-package/src/index.ts:505) and returns a constant sentence as driverFault.message (PACKAGE_PUBLISH_DRIVER_FAULT_MESSAGE, :50-52), so the thrown 500's cause carries no driver text; what the attachment adds is the duplicate's context, not the driver's words. The ruling's condition (keep the warn if the refusal is otherwise unlogged) is met: nothing else logs that the duplicate was refused. The pin's DRIVER_TEXT on driverFault.message models a shape the real service never emits; the assertion it carries (the primitive's sentence quotes nothing) still holds. Not a FAIL. A sibling asymmetry, noted not filed: declaresClientRefusal reads status alone (:3597-3600) while packagePersistFailureError passes through a 4xx declared by statusCode; such a refusal would get the error object on the server log line only.
  • The PR-body conflict the dev raised: resolved. The body at review time carries Clause-②: no (narrowing) and ruling B (updated_at 2026-10-17T00:53:54Z, after the round-2 report), and the Check Changeset run that edit triggered concluded success. Nothing to escalate.
  • The dev's "ADR-0087 D1 not reachable in practice": agreed; were it reached, the rethrown 422 OS_PROTOCOL_INCOMPATIBLE with its diagnostic would be the right answer.
  • installPackage's undo and the two claim recorders (① above): ESCALATED to the seat as a candidate card, class b, reach unmeasured and in-process only; it belongs to installPackage (On MySQL, sys_packages is never created (its raw DDL fails three ways), so package publish answers 500 and an installed or edited package silently disappears on restart while install and PATCH answer success #21243), which this claim bans editing.
  • Studio (premise 3): holds at the pin. packages-io.ts duplicatePackage throws readEnvelopeFailureText(payload) on any non-OK answer (:278-289), and both callers show that message (PackagesPage.tsx:584-588, BuilderLanding.tsx:140-141); Studio sends no targetNamespace, so from Studio the conflict arrives only through a derived namespace, with the registry's remedy in the banner. The SDK's packages.duplicate goes through unwrapResponse (client/src/index.ts:2987-3003). No caller reads the partial copy.

Check-runs on the head: 47, every one completed: 39 success, 8 skipped (Build Docs, Console Pin Gate, the Dogfood shard matrix, Dogfood Verify CLI, Packed-tarball smoke (opt-in), Temporal Conformance (live PG + MySQL), and the second event run's Auto Label and Check PR Size). Build Core, Test Core and its six shards, TypeScript Type Check, Lint & Repo Gates and every Lint · job, Check Changeset (twice), and Governed Surface Queue Guard are green. None is still running.

Implemented-by: claude/issue-24041-duplicate-install-refusal
Reviewed-by: session_01TQLy3Yq6vF4SDpTXdr3Uwi

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ✅ ACCEPT: PR #24081 at de14c8521a · domain:engine seat 2 · session_01TQLy3Yq6vF4SDpTXdr3Uwi · 2026-10-17T01:08Z

Basis:

Checklist

  • Shape. Draft, base main, Fixes #24041 on line 1. Clause-②: no (narrowing) is on its own line, in the body and the changeset. No other number carries a closing verb. The round-2 body was written by the seat from the dev's staged text, because the dev definition forbids a dev from editing a PR body. It was read in full first.
  • Ruling B, as built. When the copy's installPackage is refused, duplicatePackage logs once at warn and rethrows the refusal unchanged, before any item is copied. The door answers 422 NAMESPACE_CONFLICT with the registry's remedy, or 500 for a store fault, with no driver prose in the body. The round-1 failed[] package entry is gone. The accept set shrinks by exactly installPackage's refusal set, so there is no false refusal (record ①).
  • The ban's premises all held, each measured before the edit:
    1. No write precedes the install call (code read, plus a snapshot probe of every store): no rollback is needed.
    2. The door maps the status through errorFromThrown.
    3. Studio's callers at the pinned .objectui-sha show the thrown message, and no caller reads a partial copy.
  • Semver. @objectstack/metadata-protocol goes minor (a narrowing ships minor in the launch window), with ADR-0087 not-required (no-migration-prescription) and migration prose (refused now / unchanged / remedy). Check Changeset passed on the push run and again on the body edit.
  • Evidence. The rethrow ablation turned both refusal pins red while the CONTROL stayed green, and the restore was proven. The full metadata-protocol suite passes 20611, and objectql's five real-registry files pass 29 against a rebuilt dist/.
  • Gates. dispatch-gates --commands at the head: 65 derived, 64 exit 0. check:dual-build-cjs-loads is NOT MEASURED locally (it needs every package's dist/), and CI's Lint & Repo Gates concluded success.
  • Landing checks. check-governed-merges --pr 24081: NOT governed, 288 changed lines. git merge-tree against current origin/main (b75129fba7) is clean.

Out of scope, one line each (carrier: none, noted, not filed). Neither escalated finding meets the filing gate: both are reach unmeasured, so they ride here:

Gate on the head: ci-failure.mjs --pr 24081: GREEN, 40 of 40. The six skips (Console Pin Gate, Temporal Conformance (live PG + MySQL), Dogfood Verify CLI, the Dogfood Regression Gate matrix template, Packed-tarball smoke (opt-in), and Build Docs, since no docs path changed) are the standard roster. Auto Label and Check PR Size read skipped only on the body-edit run, and success on the push run of the same head.

Next: ready, then auto-merge through the queue.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 17, 2026 01:09
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 17, 2026 01:09
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 17, 2026
Merged via the queue into main with commit d6bd98a Oct 17, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24041-duplicate-install-refusal branch October 17, 2026 01:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants