Skip to content

fix(cloud-connection)!: install-local's cloud branch refuses a package declaring a bare name another package holds - #31388

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-f6p2x0
Nov 8, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-f6p2x0

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

domain:server#5 · os-project-manager · session_01FEkQ5ktJb5rQ5n2GUXSQMR

Fixes #31296

Clause-②: no (narrowing) — a cloud-branch install that answered success (hotLoaded: false) is now refused 422; one contract-review-tier review owed before the queue
Metadata-protocol: none

维护者速读

  • 改了什么:POST /api/v1/marketplace/install-local 的云端分支(从控制面下载的包)在注册被「同名只能一个包持有」规则(Bare-name phase 1: refuse a second package holding a non-object name whose consumers resolve without package context (ruling C on #30798) #30803 系列:flow、webhook、dataset 等)拒绝时,现在和内联分支一样直接回 422 PLUGIN_REGISTER_FAILED,消息点名两个持有者和建议的改名,不写安装记录、不注册。
  • 为什么改:之前云端分支只认「环境已持有的安全目录名」这一种拒绝,其余都走宽容路径:写安装记录、回 200 和 hotLoaded: false。下次重启时这个包在封印之前就被恢复,冷启动只告警一次就接受了冲突,同名的使用方按注册顺序拿到其中一个包的定义。
  • 风险与回滚:只收窄一个入口的一种拒绝;其它注册失败照旧走宽容路径,环境持有的目录名照旧 422。识别靠错误自身的 code + name,不列类型,Bare-name phase 1: refuse a second package holding a non-object name whose consumers resolve without package context (ruling C on #30798) #30803 后续新增的类型自动覆盖。回滚即还原本 PR。
  • 先看:packages/cloud-connection/src/marketplace-install-local-plugin.ts 新函数 refusesBareNameHeldElsewhere 和 catch 分支里那一行 if (...);然后 marketplace-install-local-bare-name-conflict.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/cloud-connection exec vitest run src/marketplace-install-local-bare-name-conflict.test.ts,预期 4 个用例全绿。
  • 需要你做的:无,席位落地。

What changes

packages/cloud-connection/src/marketplace-install-local-plugin.ts, the cloud-fetched branch of POST /api/v1/marketplace/install-local (a packageId + versionId body):

  1. One new predicate, refusesBareNameHeldElsewhere(err). It recognises the registry's bare-name refusal (BareNameConflictError, registry.ts in @objectstack/objectql) by the error's own brand: the code it shares with its siblings (NAMESPACE_CONFLICT_CODE, already imported here) plus its name. This is the code + name pair isGuestOrganizationUnresolvedError (@objectstack/core) and the DuplicateRecordError readers (types, metadata-protocol) use for an unexported class. It never reads the error's types or holders: which types the rule covers and who holds a name stay the registry's.
  2. The register catch asks it beside fix(cloud-connection): a cloud-fetched marketplace install of a package shipping a catalog name an environment-authored item holds writes its ledger entry, and the next cold boot refuses; refuse it before the first write #25366's predicate. When either answers yes, the cloud branch answers 422 PLUGIN_REGISTER_FAILED with the registry's sentence (both holders, the name, the rename), before the ledger write and before anything else of this door's is written. The registry's package door already refused before its own writes.
  3. Doc comments. The module header's install paragraph, the import comment and the catch's comment say why the lenient answer is wrong here: the ledger entry is rehydrated at the next boot before the cold boot seals the holders, so the boot admits the clash with one warning (BARE_NAME_COLD_BOOT_MODE = 'warn').

Not changed: any other register failure on the cloud branch keeps #22695's lenient answer (200, hotLoaded: false, hotLoadError); the inline branch; the environment-held security catalog refusal.

Parallel work. The #30803 PR 4 thread (agent + skill) adds two more types to the same one-holder list. This predicate does not enumerate types, so those types are refused here without a change once they land.

Tests

New packages/cloud-connection/src/marketplace-install-local-bare-name-conflict.test.ts, over a real ObjectKernel + ObjectQLPlugin booted to kernel:bootstrapped (holders sealed), the holding package installed through the same door, the cloud leg a stubbed fetch:

  • a fresh cloud-branch install of a package declaring a flow name another package holds: 422 PLUGIN_REGISTER_FAILED in the declared envelope (BaseResponseSchema, ApiErrorSchema, envelopeViolations empty), the message naming both packages, the flow and the rename routing_assign_technician; the package not registered; the ledger equal to before; the holder's flow still the holder's;
  • the same for an upgrade over an installed version 1: version 1 still registered and ledgered;
  • CONTROL: an environment-held security catalog name keeps its 422 naming the environment catalog (the full matrix stays in marketplace-install-local-catalog-name-collision.test.ts);
  • CONTROL: the same package declaring a flow name nobody holds installs, 200, hotLoaded: true, registered and ledgered.

The lenient answer for a non-registry register failure stays pinned by marketplace-install-local-hot-loaded.test.ts.

Red on main (the new test run against the unfixed plugin at base d0512c2, before the fix was written)
× a fresh install is refused: 422 naming both holders and the rename, nothing registered or ledgered
× an upgrade is refused: 422, the installed version still registered and ledgered
AssertionError: {"success":true,"data":{"manifestId":"com.acme.routing","version":"1.0.0","versionId":"v1-held",…,"hotLoaded":false,"hotLoadError":"Bare-name conflict: package \"com.acme.routing\" cannot register a name another holder already holds — package \"com.acme.routing\" declares the flow \"assign_technician\", already held by package \"com.acme.dispatch\" — rename it to \"routing_assign_technician\" in \"com.acme.routing\". …"}}: expected 200 to be 422
Tests  2 failed | 2 passed (4)
Ablation (fix committed, predicate removed from the catch, restored)

node scripts/ablation-replace.mjs --file packages/cloud-connection/src/marketplace-install-local-plugin.ts --anchor "if (refusesEnvironmentHeldCatalogName(err) || refusesBareNameHeldElsewhere(err)) {" --replacement "if (refusesEnvironmentHeldCatalogName(err)) {" -- …vitest run src/marketplace-install-local-bare-name-conflict.test.ts

ablation-replace: ok mutation landed: anchor 1 -> 0, blob 09ac8e820728 -> 2268f74afe54
× a fresh install is refused: 422 naming both holders and the rename, nothing registered or ledgered
× an upgrade is refused: 422, the installed version still registered and ledgered
Tests  2 failed | 2 passed (4)
ablation-replace: ok restored: blob == HEAD (09ac8e820728) and `git diff HEAD` is empty

The suite imports the plugin from src/ relatively, so the mutation needs no build.

Verification

All on head 7b59e006 (the fix, the changeset, and one merge of origin/main 8b90442), exit codes captured before any pipe:

  • Derived gate union, node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 69 families, every one exit 0. --ran: "69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED (a DERIVED zero — all 69 recorded an exit code and none of them is 3)". Four first answered a prerequisite and were rerun once it held: check-plugin-teardown-shape --self-test (3, shallow clone; 0 after git fetch --depth=1 origin 621a4876), check:dts-closure (1) and check:dual-build-cjs-loads (3), both 0 after pnpm turbo run build --concurrency=2 (VERDICT command-exit 0), and check:type-check-debt (3, lock queue behind the dogfood run; 0 on rerun).
  • Package: pnpm --filter @objectstack/cloud-connection test: Test Files 74 passed, Tests 973 passed (VERDICT 0). pnpm --filter @objectstack/cloud-connection typecheck (src and tsconfig.test.json, which lists the new test file): exit 0.
  • Lint: eslint --no-inline-config on the two touched .ts files: exit 0.
  • Full dogfood battery ((narrowing) arm), pnpm --filter @objectstack/dogfood test after the @objectstack/dogfood^... closure build, under the verify lock: VERDICT command-exit 0, Test Files 437 passed / 3 skipped (440), Tests 3618 passed / 41 skipped (3659).
  • CI-only families (Test Core shards, the type-check lanes, Build Docs) are read from this PR's check-runs, not re-run here.

Acceptance notes

  • Security catalog, package or built-in holder (not fixed here). The same lenient path still takes the security catalog's refusal when the holder is another package or a built-in. Measured with this PR's harness on a throwaway probe: a cloud-branch install of a package shipping a permission set another installed package ships answers 200, hotLoaded: false, not registered, ledgered. Read from code: the package door's security catalog check has no seal, so the rehydrate at the next boot is refused the same way and the package never loads. Outside this card's claimed predicate; reported to the seat for filing.
  • Old ledger entries. An entry an earlier build ledgered this way keeps loading at boot beside the cold boot's warning; this PR refuses new ones only.
  • Governed surface. None: the diff is one source file, one test file and one changeset.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5ktJb5rQ5n2GUXSQMR


Generated by Claude Code

… declaring a bare name another package holds

The cloud-fetched branch recognised only the security catalog's
environment-held refusal and took the lenient path for the registry's
bare-name refusal: it ledgered the package and answered hotLoaded false,
and the next boot admitted the clash before the seal. It now refuses it
422 PLUGIN_REGISTER_FAILED before any write, as the inline branch does,
recognising the error by its own brand (code + name), never by its types.

Co-Authored-By: Claude <noreply@anthropic.com>
…e refusal on the cloud branch

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 8, 2026
@github-actions

github-actions Bot commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 64 of 227 client-bound route-ledger rows — the other 163 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 163: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 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 — 6 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 44a030bfc8b1a3e1b5b6a4518e89831858b9c452 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6c77f9a8eac6321c65f17294f649f55b7543b411 — the merge of head 7b59e0068d1b817ad06c26be00fc06a5551afc1b into base 44a030bfc8b1a3e1b5b6a4518e89831858b9c452, 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 6c77f9a8eac6321c65f17294f649f55b7543b411 && git checkout 6c77f9a8eac6321c65f17294f649f55b7543b411
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 44a030bfc8b1a3e1b5b6a4518e89831858b9c452 7b59e0068d1b817ad06c26be00fc06a5551afc1b && git checkout -B drift-repro 44a030bfc8b1a3e1b5b6a4518e89831858b9c452 && git merge --no-ff 7b59e0068d1b817ad06c26be00fc06a5551afc1b

node scripts/docs-audit/affected-docs.mjs --json 44a030bfc8b1a3e1b5b6a4518e89831858b9c452

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

Contract-review-claim: session_01FEkQ5o8PE7ZtVjcor51wBZ · head 7b59e00 · 2026-11-08T05:30Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7b59e0068d1b817ad06c26be00fc06a5551afc1b
Local-runs: none
Scope: packages/cloud-connection/src/marketplace-install-local-plugin.ts, packages/cloud-connection/src/marketplace-install-local-bare-name-conflict.test.ts + packages/objectql/src/registry.ts, packages/objectql/src/bare-name-one-holder.ts, packages/objectql/src/plugin.ts, packages/objectql/src/engine-registration.ts, packages/core/src/kernel.ts, packages/core/src/security/guest-organization.ts

Reviewed on the narrowing face (Clause-②: no (narrowing) on the PR body and the changeset; no published schema or governed path in the tree). Inputs: card #31296 with comments 6714782060, 6717053629, 6717747337; PR #31388 (body, three files, three commits aecae3b / fa46601 / 7b59e00), the net diff against main (merge-base 8b90442; origin/main at 44a030b, 12 commits past it, none touching a cited file); the #30803 series on main (PRs #30836, #31006, #31093 merged; #31375 open at a9d8e39); the check-runs on this head. Read with git show / git diff; the checkout's ref was not moved; nothing was built, run or re-run.

Check-runs on 7b59e00 at record time: 47 runs — 30 success, 13 skipped, 4 still in_progress (Test Core 1/8, 2/8, 3/8, 4/8). None failed. Complete and green: Check Changeset, Governed Surface Queue Guard, PR-body guards, Check PR Size, Build Core, Dogfood Regression Gate, Plan Test Core shards, Test Core 5/8–8/8, all four Type Check lanes (workspace, source gates, consumer gates, debt ledger), every Lint lane (ESLint; docs & code guards, which carries the ADR-0087 registration check; CI, release & engine gates; repo & tooling gates; PM dispatch-gates; the three scoped lanes), Test UI, Live E2E UI, Flag docs affected. The four open shards are the queue guard's to wait on; this record does not pre-empt them.

① Derived judgments

The accept-set change, read in the catch (plugin.ts, step 3 of the cloud branch, pr/31388 lines 2903–2941). When manifestService.register(...) throws and inlineManifest is false, the catch now asks refusesEnvironmentHeldCatalogName(err) || refusesBareNameHeldElsewhere(err). When the second arm answers yes the door returns 422 with code: 'PLUGIN_REGISTER_FAILED' and message: Package '…' cannot be installed: plus the registry's own sentence — the same body shape and status the #25366 arm already returned. Before this head that refusal fell through to #22695's lenient path: hotLoadError set, ledger entry written at step 4 (this.ledger.write(entry), line 2964), 200 with hotLoaded: false. So the cloud-branch installs now refused are exactly those whose register the registry refuses with BareNameConflictError; every other register failure on that branch (including NamespaceConflictError, which shares the code but not the name) keeps the lenient answer, as the PR states.

Both holders and the rename are in the answer. The message is the registry's: BareNameConflictError's constructor (registry.ts 2843–2880) joins one bareNameConflictLine per conflict — package "incoming" declares the flow "name", already held by package "holder" — rename it to "ns_name" in "incoming" — followed by bareNameReason and the remedy (rename in either package, or uninstall one; for a built-in holder, rename only). The test's expectBareNameRefusal asserts both package ids, the name and routing_assign_technician in the message, plus BaseResponseSchema / ApiErrorSchema / envelopeViolations on the envelope.

Nothing is written or hot-loaded on refusal. The return is before step 4 (ledger write, 2964), 4b (syncSchemas), 4b′ (datasources), 4c (bindArtifactHandlers, 3000) and 5 (applySideEffects, 3007). Steps 2c–2e before the register are gates that return refusals; the cloud fetch (line 2249) is a read; the door writes nothing of its own before step 3. On the registry's side installPackage (registry.ts 8655–8690) runs refuseBareNameConflicts ahead of const now = … and every record write, so a refused package leaves no package record, no claim and no items — the PR's "the registry's package door already refused before its own writes" holds. The test pins ledger equality before/after for both ids, getPackage(INCOMING_ID) undefined (fresh) or still 1.0.0 (upgrade), and the holder's flow label unchanged.

An environment-held catalog name keeps its answer. The #25366 predicate is unchanged (lines 379–384) and sits first in the ||; the new CONTROL registers a bare-slot permission set via registry.registerItem and asserts the 422 naming "the environment catalog". installPackage runs refuseSecurityCatalogNameConflicts (8671) before refuseBareNameConflicts (8690), so a package carrying both an environment-held catalog name and a held bare name is refused by the first throw, which the first arm recognises — 422 either way.

A fresh name installs. CONTROL: v1-free answers 200, hotLoaded: true, no hotLoadError, registered and ledgered, the flow served under the incoming package's label, the holder's flow kept.

The predicate is the error's brand, not a copy of the rule. refusesBareNameHeldElsewhere reads e?.code === NAMESPACE_CONFLICT_CODE && e.name === 'BareNameConflictError' and nothing else: no conflicts, no itemType, no existingHolder, no list of types. BareNameConflictError sets readonly code = NAMESPACE_CONFLICT_CODE and this.name = 'BareNameConflictError' (registry.ts 2844, 2875); the class is exported from registry.ts but not from the package barrel (index.ts re-exports NAMESPACE_CONFLICT_CODE alone, line 83), so "unexported" and "compared rather than instanceof-tested" are accurate. The pair is the shape isGuestOrganizationUnresolvedError uses (core security/guest-organization.ts 92–96) and the DuplicateRecordError readers in types/data-error-classification.ts 820 and metadata-protocol/protocol-client-failures.ts 203. The siblings are told apart by name: NamespaceConflictError (same code, no match → lenient, as before) and SecurityCatalogNameConflictError (same code, matched only through the #25366 arm's existingHolder.kind === 'environment').

The brand reaches the catch unwrapped. Traced on main: objectql plugin.ts register (883–980) converts, judges residuals, then calls ql.registerApp(manifest, scope, source) per entry with no try/catch; engine.ts 2168 delegates to RegistrationFacet.registerApp; engine-registration.ts registerPackageBody (233–240) calls this._registry.installPackage(manifest, source, undefined, scope) first, no try/catch; the facet's only try/catch (573) is the file-columns resolver, unrelated. The PR's own red run shows the un-wrapped sentence (hotLoadError: "Bare-name conflict: package …") at the door.

#31375 is covered without a type list. Its patch to bare-name-one-holder.ts adds 'agent' | 'skill' to BareNameRegistryType and the ordered list; its registry.ts hunks change neither readonly code nor this.name. Nothing here enumerates types or holders, so when #31375 lands the cloud branch refuses an agent or skill clash through the same arm.

The doc-comment claims the hunks make, checked in the cited code. (a) "rehydrated at the next boot BEFORE the cold boot seals the holders": core/kernel.ts BOOT_PHASE_HOOKS = ['kernel:ready', 'kernel:bootstrapped', 'kernel:listening'], triggered in that order (614, 626); install-local rehydrates inside its kernel:ready hook (plugin.ts 1551–1567); objectql seals in kernel:bootstrapped (plugin.ts 1228–1239, sealBareNameHoldersAtColdBoot(registry, BARE_NAME_COLD_BOOT_MODE, …)), whose own comment names "the install-local rehydrate at kernel:ready" as one of the registrations the seal waits for. Holds. (b) BARE_NAME_COLD_BOOT_MODE = 'warn' (bare-name-one-holder.ts 302) — the boot logs bareNameColdBootWarning once and "changes nothing about which holder each caller is served". Holds. (c) The module-header hunk's "another package (or the platform) holds a bare name" matches BareNameHolder = package | built-in (305–308). Holds.

② Semver level

Changeset .changeset/31296-install-local-cloud-bare-name-refused.md: '@objectstack/cloud-connection': patch; Clause-②: no (narrowing) with the narrowed door, status and old answer spelled out ("Nothing is widened"); Metadata-protocol: none; the **BREAKING** banner; a Before / Now / Unchanged / Remedy body; adr-0087: not-required (no-migration-prescription) with the reason (no schema key, no stored row read differently; the remedy is the rename the refusal names). The PR body carries the same Clause-② and Metadata-protocol lines and the ! on the title. Under check-changeset-no-major.mjs (ruling A, comments 895–925) Metadata-protocol: none grades every introduced entry patch; the (narrowing) arm keeps the BREAKING banner and the ADR-0087 disposition and carries no level coupling; narrowed is not declared, so neither cross-check contradiction applies. The shape matches the 25921-* narrowing changesets on main. Check Changeset, Governed Surface Queue Guard and Lint · docs & code guards (the ADR-0087 registration check) are complete and green on this head. The narrowing is honestly declared: #22695 left the lenient path lenient on purpose ("whether it should is a separate decision, ⛔ not made here"), so a 200 consumer could have relied on it; patch + (narrowing) + BREAKING is the correct level and declaration.

③ Boundary flags

Every flag in report 6717747337, answered or escalated:

  • out_of_scope_findings[0] — the security catalog's refusal with a package or built-in holder still takes the lenient path on the cloud branch. Confirmed in the cited code: refusesEnvironmentHeldCatalogName keeps existingHolder.kind === 'environment' (plugin.ts 379–384); refuseSecurityCatalogNameConflicts (registry.ts 4423–4440) is guarded by selfId === undefined alone, no seal, and reads holders with { builtIns: true, environment: true }; so the ledger entry written on the lenient path meets the same refusal at every rehydrate and the package never loads, while the answer said 200 "loads at the next restart" — and (per fix(cloud-connection): a cloud-fetched marketplace install of a package shipping a catalog name an environment-authored item holds writes its ledger entry, and the next cold boot refuses; refuse it before the first write #25366's own comment in the catch) the earlier version's ledger entry it overwrote is lost too. Outside cloud-connection: install-local's cloud branch accepts a package whose flow, webhook or other bare name another package holds #31296's claimed predicate (the card's Expected names the bare-name refusal alone; the seat claim's file surface is the cloud branch's refusal predicate for it). Escalated: a new card for the server lane with the dev's dedupe words (install-local security catalog package holder lenient, refusesEnvironmentHeldCatalogName package built-in, SecurityCatalogNameConflictError hotLoaded false). Not a finding against this PR.
  • Of the same shape, not in the report, read from code and not measured: NamespaceConflictError (registry.ts 8653, inside installPackage; same code, name NamespaceConflictError, no conflicts) also takes the lenient path on the cloud branch, and the namespace's owner is still installed at the next rehydrate, so that entry is refused at every boot too. Escalated for the same card or a sibling; outside this card's predicate.
  • deviations: one merge of origin/main (8b90442) before the PR; main has moved 12 commits since. None touches marketplace-install-local-plugin.ts, registry.ts, bare-name-one-holder.ts, objectql plugin.ts or kernel.ts (checked with git log 8b904420..origin/main -- …). The PR reports mergeable: true. No re-merge owed for this review.
  • "PR feat(objectql)!: an agent and a skill hold one name per package (bare-name phase 1, 4/4) #31375 had not landed … no AI-family pin (seat note: not owed)": agreed — the predicate reads no type, feat(objectql)!: an agent and a skill hold one name per package (bare-name phase 1, 4/4) #31375 leaves code and name as they are, and the AI family's own install-local pin is that PR's dogfood file to carry.
  • premise_still_valid: true: confirmed on origin/main — the catch asks only refusesEnvironmentHeldCatalogName(err) (the diff's - line).
  • security_findings: none, mcp_calls: 0, api_writes: 2, the card assignee untouched: no contract question here; not re-judged.
  • The card's constraint "one predicate on that branch … never a copy of the holder rule": met (①).

④ Design judgment

Does the narrowing take a declared capability away? No. The capability the lenient path declares is "installed; the running kernel does not hold it; it loads at the next restart" (hotLoaded: false + hotLoadError, #22695). For a BareNameConflictError that promise is not a deferral but an evasion of the rule: the entry is rehydrated at kernel:ready, before the kernel:bootstrapped seal, and the boot under 'warn' admits both holders and serves the name by registration order — the condition ADR-0048 §3.4 (narrowed, ruling C on #30798) and PRs #30836 / #31006 / #31093 were accepted to refuse. The inline branch of the same door already answers that refusal 422 PLUGIN_REGISTER_FAILED (pinned on main in packages/qa/dogfood/test/bare-name-one-holder.dogfood.test.ts 163–181 and …-flow-family.dogfood.test.ts 205–220: "install-local of a third package declaring a held … name is 422 … nothing is installed"). The narrowing makes the series' own refusal real on the one door that lost it, with the registry's sentence and remedy, and leaves old ledger entries and every other register failure as they were. The remedy is the one the series published (rename to the namespaced name, or uninstall the other holder), so a consumer loses no path it was promised.

Attempts to break it, each read in the tree:

  • A refusal with both a bare-name and an environment holder. An environment registration (no packageId) is never a bare-name holder (BareNameHolder is package | built-in; the item seam skips packageId-less registrations), so "both" means a package whose manifest trips both rules. installPackage throws the security catalog's refusal first (8671 before 8690); the first arm recognises it; 422, nothing written. If a later reorder put the bare-name check first, the second arm recognises that throw instead. Holds either way.
  • A bundled member key (an email_template locale). declaredBareNames declares a bundled type by its NAME ("two members of one bundle are one declaration, read twice"); the item seam judges baseName. The predicate reads neither names nor types, so whatever the registry decides arrives with the same brand. Holds.
  • A built-in holder. BUILT_IN_BARE_NAMES is empty today; a future built-in produces the same class with existingHolder.kind === 'built-in' and the "a built-in name is never available to a package" remedy. The predicate does not read the holder. Holds.
  • An upgrade of the same package that already holds the name — must NOT be refused. bareNameHoldersOtherThan (registry.ts 4678–4690) answers [] when incomingPackageId is already among the holders ("a reload, a re-install or an upgrade … is not a new holder"); the install-local upgrade path never calls uninstallPackage before the register (that verb is the uninstall route's, 3591), so the claims survive and no error is thrown — the predicate has nothing to recognise. Holds. The test's upgrade case re-declares the v1 flow the package already holds and is refused for the OTHER name only, consistent with this; it does not assert the kept name is absent from the message — a nit, the rule is the registry's and pinned there.
  • The error thrown from a nested registration. A nested plugin's collection reaches the item seam (registerItem, registry.ts 7647) and throws the same class; the path registerPlugin → registerPackageBody → registerApp → manifest.register has no catch, so the door answers 422 with nothing of its own written. The package door has already judged plugins[] one level deep (declaredBareNames), so the seam is a backstop; if it fires, the registry's record write (step 1 of registerPackageBody) has already happened — a registry-side concern that predates this PR and is identical on the inline branch; not this door's to fix.
  • Wrapped errors. No wrapper on the path (①). A future new Error(message) wrap anywhere in it would drop code and name and silently restore the lenient answer — the exposure the fix(cloud-connection): a cloud-fetched marketplace install of a package shipping a catalog name an environment-authored item holds writes its ledger entry, and the next cold boot refuses; refuse it before the first write #25366 arm already carries, and one the new test (a real ObjectKernel + ObjectQLPlugin, not a mocked manifest) would turn red.
  • A function clash (PR feat(objectql)!: an API, an object-less action and an engine function hold one name per package (bare-name phase 1, 2/4) #31006's third type). Not reachable at this door: a cloud-fetched manifest is JSON and carries no code functions (runtime app-artifact-handlers.ts 8; the request-family dogfood says so at line 26), and the hook binder's bind runs at step 4c after the ledger write, inside the binder's own try/catch, on both branches. Outside the card.
  • A NamespaceConflictError. Same code, different name: not matched, lenient as before — correct for this card, escalated in ③ as the same class of false promise.

Findings. Must-fix: none. Should-fix: none. Nit (no effect on correctness or the contract): the new suite does not assert that the upgrade's kept name (reassign_technician, held by the package since v1) is absent from the 422 message; the registry's own suite pins that an upgrade is not a new holder.

Implemented-by: claude/project-thread-f6p2x0
Reviewed-by: session_01FEkQ5o8PE7ZtVjcor51wBZ

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

席位意见:接受。本地安装的云端分支遇到 #30803 系列「同名只能一个包持有」的冲突时,与内联分支一样直接 422 拒绝,点名持有者并给出改名,不写安装记录、不热加载;环境已持有目录名的旧回答和新名字的安装照旧。契约复审 PASS,无必改、无应改项。

席位核对 — ACCEPT · domain:server#5 · Head-sha 7b59e0068d1b817ad06c26be00fc06a5551afc1b · 2026-11-08T05:56Z

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.


Generated by Claude Code

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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cloud-connection: install-local's cloud branch accepts a package whose flow, webhook or other bare name another package holds

1 participant