Skip to content

harden(metadata): a permission set's name is unique across packages - #27535

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/harden-permission-set-name-unique-across-packages
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/harden-permission-set-name-unique-across-packages

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

harden(metadata): a permission set's name is unique across packages

Dispatched by the W3-C3b QA run security path (detail withheld). Reworked on the contract-tier review FAIL (PR comment 6497768595): the must-fix and all five should-fixes are taken, each listed under What changes.

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — the published skills teach that a grant references a permission set by its machine name (skills/objectstack-data/rules/security.md); none claims a name may be reused across owners, so this hardening contradicts nothing, and Metadata-protocol: none means there is no new authorable capability to teach.

This narrows a published accept set (a metadata-door write that answered 200 is now refused), so it owes one contract-review-tier review before the queue.

维护者速读

  • 改了什么:权限集(permission set)在元数据的保存门(草稿/直接发布)、"草稿→发布"提升门、以及恢复门(回滚 rollback / 提交回退 revert)上,若同名权限集已被另一个所有者(另一个包,或环境本身)持有,一律拒绝写入,且什么都不落库;错误信封与注册表在安装、冷启动时对同一规则的答复一致:NAMESPACE_CONFLICT / 422。
  • 为什么改:授权按名字引用权限集(一个名字只能对应一套权限,ADR-0131 D4;裁决 feat(core,objectql,plugin-security,plugin-sharing): the catalog is read from the registry; assignment tables reference it by name (ADR-0131 D2/D3/D4) #15196 Q4=A、[decision] cold boot admits a package whose permission set or position name the environment catalog already holds (package registration runs before sys_metadata hydration), while a hot install of the same package is refused #22307 A:"一个名字一个持有者,每条路径都如此")。安装与冷启动早已拒绝第二个持有者,元数据入口没有,留下一个与已声明契约不符的缺口。本次返工修正了复审指出的问题:所属包自己用"不带包名的草稿保存"编辑自家权限集时被误拒。属安全加固。
  • 风险与回滚:只拦"用别人已占用的名字再写一份"这类本就非法的写入;同一所有者改自己的集照常(包括不带包名的草稿保存、以及历史遗留的"包行旁的无包行");已有两个所有者同名的部署,在改名或删除其中一份之前,对该名字的写入(含 os migrate meta --stored --apply 重存、包复制 duplicate 中未带前缀的集)会报失败。回滚 = 还原本 PR。
  • 先看:packages/metadata-protocol/src/protocol-save-door-refusals.ts(判据 permissionSetNameCollisionRefusal 与注册表读法 registryPackageHoldersOf);packages/metadata-protocol/src/protocol.ts 保存门里计算 landsIn 的那段(按写入真正落入的包判定,风险最高);packages/metadata-protocol/src/protocol-authoring-gate.ts 恢复门的一行调用。
  • 两分钟验证:pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.permission-set-name-cross-package.test.ts → 预期 17/17 绿(三道门的拒绝 + 各自对照,含"所属包不带包名的草稿编辑可保存"一条)。
  • 需要你做的:无 — 席位落地;入队前需一次契约复审档(contract-review)复核,因为本卡 Clause-②: no (narrowing)。

What changes

A permission set's name is its identity across the whole deployment: a grant (sys_user_permission_set.permission_set) resolves its set by that name alone (ADR-0131 D4), and positions, permission sets and capabilities each hold one name per deployment (ruling Q4 = A on #15196, record 6050490870). The registry already refuses a second holder at install and at cold boot with NAMESPACE_CONFLICT / 422 (ruling A on #22307, record 6063176077: one name, one holder, on every path). The metadata doors keyed a permission-set write by (type, name, package) and so let a second owner write a name already held. This PR closes those doors, pulling them back to the already-declared contract (ADR-0086 D4: a package never writes into a record another owns).

permissionSetNameCollisionRefusal refuses a permission write whose name a DIFFERENT owner already holds, nothing written. It is asked at every door a body reaches active through:

  • the save door (saveMetaItem, draft and publish mode), right after the sibling owner judge;
  • the draft→active promotion (promoteDraftForPublish, shared by the per-item publish and publishPackageDrafts), on promoteInto — a batch publish aborts on it;
  • the restore doors (review should-fix 2): first in refuseRestoredBodyTheSaveDoorRefuses, under rollbackMetaItem and revertCommit, on the binding the restore lands in (the active row's package_id, which put keeps; null when no active row exists and the package-unbound row is written). A version of a deleted set cannot come back as a second holder of a name a package has taken since.

The save-door check runs before the create precondition, so If-None-Match: * cannot smuggle a cross-owner create.

The owner is the binding the write LANDS IN (review must-fix 1). The first cut judged the owner judge's binding (saveWriteBinding), which reads the store only when the body carries a packageId, so the owning package's own plain ?mode=draft edit (body without packageId, no package named) was judged as the environment and refused, although the repository writes that draft into the inherited package (resolveWriteHead). The save door now hands the predicate: the named package (for a publish-mode save naming none, the package the package-less address resolved, already on the request); for a draft save naming none, the into of the draft address packagelessDraftAddress names — the address the door already reads, read here too for the server-stated rewrites it is not read for — and an ambiguous address is refused with the refusal the repository answers it with (RESOURCE_CONFLICT / 409, "name the package"); otherwise the package-unbound row (null).

The holders: two readings, met. The stored active rows by package_id (a set published through the metadata door is stored and registered in the registry's bare slot without a package, so only the row says whose it is), and the registry's OWN package-held reading for a set a package ships in code or declares in its install claim (review should-fix 5; ruling letter B on #22371, record 6074838935: one reading of who holds a name). @objectstack/objectql exports that reading as findPackageHeldSecurityCatalogNames(registry); metadata-protocol cannot import objectql (objectql depends on it), so the predicate asks the same registry member the export asks, as it already asks the registry's defaultRecordPageHoldersOf for the default-record-page rule. A new objectql suite drives the doors over the real ObjectQL + SchemaRegistry composition, so a rename of that member reds there. The first cut's getItem reading (one item, no install claims, a bare-slot stamp read as a package) is gone.

A package-less row beside exactly one package's row is that package's (review should-fix 6). #23165's rule for the write address, applied to ownership: such a row is the residue of the direct save before it was re-addressed, so it is not a second owner. The owning package's edit and a write landing in the residue row are accepted; another package is still refused, and its refusal names the package alone.

Envelope (review should-fix 4). NAMESPACE_CONFLICT / 422, worded as the registry's SecurityCatalogNameConflictError ("Security catalog name conflict: … is already held by …", the same rationale sentence), prescription first because a 4xx message is truncated at 500 characters at the REST boundary. The rulings fix this rule's envelope at that code and status on every path, so a client reads one code for the rule whichever door it met. The code is @objectstack/objectql's; @objectstack/spec's ERROR_CODE_LEDGER now lists it under @objectstack/metadata-protocol too (the DEFAULT_RECORD_PAGE_CONFLICT precedent); the ErrorCode union is unchanged and no code is minted.

Faces declared (review should-fix 3). The changeset's "Who sees it" now names POST /api/v1/packages/:id/duplicate (a copied set whose name lacks the source namespace prefix is reported in failed[]; remedy: prefix it with your own module prefix) and os migrate meta --stored --apply (a re-saved row of a name two owners hold is reported failed until one set is renamed or deleted).

A patch changeset ships it for @objectstack/metadata-protocol and @objectstack/spec.

Tests

  • packages/metadata-protocol/src/protocol.permission-set-name-cross-package.test.ts — 17 cases on the storing engine double (doubles recorded in scripts/engine-double-contract.pinned.json), each refusal paired with a control through the same door: the save door (both modes, the environment author, the create precondition, an install-claimed name); the owner as the binding the write lands in (the must-fix pin: owner B holds the active set, body without packageId, mode: 'draft', no package on the request → success: true, draft bound to B; the publish-mode control; the ambiguous draft address); the package-less residue; the promotion (per-item and batch, failed[].code = NAMESPACE_CONFLICT); the restore door through rollbackMetaItem and its control. Shapes a deployment can only hold from before this hardening are staged through the real write path with the predicate switched off for the staging writes alone.
  • packages/objectql/src/protocol-permission-set-name-registry-reading.test.ts — 4 cases over the real composition (ObjectQLPlugin binding the real protocol to a real ObjectQL and its SchemaRegistry, an in-memory driver): the exported reading names the claimant; another installed package and the environment are refused NAMESPACE_CONFLICT / 422 naming it, nothing stored; control.
Touched packages — typecheck and full suites (verify-lock, exit codes captured before any pipe)
pnpm --filter @objectstack/metadata-protocol typecheck && build    -> VERDICT command-exit 0
pnpm --filter @objectstack/metadata-protocol test                  -> Test Files 280 passed | 4 skipped (284) · Tests 21798 passed | 26 skipped (21824) · VERDICT command-exit 0
pnpm --filter @objectstack/objectql typecheck (incl. check:test-typecheck) && test (project local)
                                                                   -> Test Files 487 passed (487) · Tests 9132 passed (9132) · VERDICT command-exit 0
pnpm --filter @objectstack/spec typecheck && test                  -> Test Files 752 passed (752) · Tests 22343 passed | 1 todo (22344) · VERDICT command-exit 0
new files alone: metadata-protocol 17/17 · objectql 4/4

tsc --listFilesOnly counts the metadata-protocol test in that package's program (1) and the objectql test in tsconfig.test.json's (1), which check:test-typecheck compiles.

Ablation of must-fix 1 (the draft-address owner branch disabled) — src-resolved, no rebuild

The subject resolves through the package's own src (the test imports ./protocol.js), so the mutation is seen without a rebuild. The fix was committed first; scripts/ablation-replace.mjs under a script with trap restore against HEAD.

before: anchor=1 marker=0
ablation-replace: anchor x1 -> x0 · replace x0 -> x1 · blob 00b7328d66f7 -> 9092f29eaeb8 (mutation landed)
during: anchor=0 marker=1
  × a draft save naming no package, body without packageId, over package B's active set is B's own edit
      Error: Security catalog name conflict: this environment cannot save the permission set "project_editor"
             — it is already held by package "app.owner_b". …   (the reviewed defect, reproduced)
  × a draft save naming no package over a name two packages already hold … (422 instead of the repository's 409)
  Test Files 1 failed (1) · Tests 2 failed | 15 passed (17)
ablation-replace: ok restored: blob == HEAD (00b7328d66f7) and `git diff HEAD` is empty
trap: blob 00b7328d66f7… == HEAD, git diff HEAD empty

Direction observed: red (the expected one); the other 15 cases, controls included, stay green.

Acceptance notes

  • Metadata-protocol: none: no authorable key, value or type changes; a cross-owner second set was never an authorable capability (the install and cold-boot doors and the Studio dialog already refuse it). The accept-set narrowing is at the metadata HTTP doors, which is why Clause-② carries the (narrowing) arm while the kind stays none and the level patch. Re-checked after the envelope change: relative to main the doors still go from accepting to refusing, and the ledger row adds no member to the ErrorCode union, so the declaration is unchanged.
  • The registry seam is structural by necessity (the dependency runs objectql → metadata-protocol). A host registry without the reading member answers no package-held name, as a registry without getItem did before; on the one production host the objectql suite above holds it.
  • security-catalog-namespace.ts (objectql) states the registry does not judge "an environment-catalog save over a package-held name — that path keeps its own answers". Still true: the metadata doors now give that answer, with the registry's envelope. Its header could point at the metadata-protocol predicate; not touched here (no behaviour, outside this diff's files).
  • os migrate security-catalog-overlays and the cold boot read the bare slot as the environment's; this door reads stored rows by binding. The two meet on the package half (one reading) and differ only on the stored half, which the cold boot cannot see by construction.

Verification

Local gate subset on the diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no paths; change set from the merge base, 8 paths): 93 derived, every one run with its exit code captured before any pipe; --ran reconciliation: 93 accounted — 92 run, 1 NOT-MEASURED (check:dual-build-cjs-loads, exit 3, PREREQUISITE NOT MET: it reads the built output of 72 packages outside this diff's closure; Build Core answers it in CI). check:lean-entry-closure first exited 3 for the same reason; after pnpm --filter @objectstack/objectql build it measured exit 0 (2 published conditions from a real load, admitted set held exactly). Every family CI runs is read from the PR's check-runs, not re-run here; the repo-wide pnpm lint and the full-build gates are CI's. On head 585e6198 CI reads 49 check-runs, all completed: 39 success, 10 skipped (path-filtered or opt-in), 0 failure — Lint & Repo Gates, TypeScript Type Check, Test Core (8/8 shards), Build Core, Governed Surface Queue Guard, Check Changeset, Dogfood Regression Gate, Type Check UI, Test UI, Build & E2E UI and Live E2E UI success; Lint UI skipped.

Key gate verdict lines
check-adr-0087-registration --base origin/main   -> exit 0 · 1 declared-breaking changeset, ADR-0087 exemption (no-migration-prescription)
check-changeset-no-major --base origin/main      -> exit 0 · no major bump
check-empty-changeset --base origin/main         -> exit 0 · 1 declaring changeset added; none modified or deleted
check:engine-double-contract                     -> exit 0 · OK — 1123 pinned, 120 in the DEBT ledger, 3 exempt
check:dispatcher-error-vocabulary                -> exit 0 · OK — 56 unregistered code-stamping sites, all classified
check:error-status-conformance                   -> exit 0 · every derivable runtime status is documented, and every documented status is reachable
check:error-code-casing · check:doc-authoring · check:issue-citations · check:nul-bytes            -> exit 0
check:test-source-alias · check:cross-package-test-inputs · check:durability-log-level             -> exit 0
spec: check:api-surface (unchanged) · check:docs (232 generated files in sync) · check:authorable-surface · 14 more spec families -> exit 0
local-only: check:skill-frame-freshness -> exit 0 · check:dev-prereqs -> exit 1 (this worktree is built only for the diff's closure: 35 of 105 packages have no dist; a local-state reading, not a finding)
Dogfood battery (Clause-② carries the narrowing arm) — final head
head 585e6198 · closure built first: pnpm --workspace-concurrency=2 --filter "@objectstack/dogfood^..." build -> VERDICT command-exit 0
pnpm --filter @objectstack/dogfood test (no OS_TEST_SHARD, no path) -> VERDICT command-exit 0
  Test Files 350 passed | 3 skipped (353) · Tests 2817 passed | 20 skipped (2837) · no failure

Generated by Claude Code

Body refreshed by the PM seat 2026-10-29T15:22Z from the os-dev rework report.

Dispatched by the W3-C3b QA run security path (detail withheld).

Claude-Session: https://claude.ai/code/session_017xRBcVkGJizGH7raJHhAvL
Co-authored-by: Claude <noreply@anthropic.com>
The metadata save door and the draft->active promotion now refuse a
permission set whose name an active set of a different owner already
holds (RESOURCE_CONFLICT / 409), with nothing written. A permission
set's name is its identity across the deployment (ADR-0131 D4) and no
package writes into a record another owns (ADR-0086 D4).

Dispatched by the W3-C3b QA run security path (detail withheld).

Claude-Session: https://claude.ai/code/session_017xRBcVkGJizGH7raJHhAvL
Co-authored-by: Claude <noreply@anthropic.com>
Clause-②: no (narrowing)
Metadata-protocol: none

Dispatched by the W3-C3b QA run security path (detail withheld).

Claude-Session: https://claude.ai/code/session_017xRBcVkGJizGH7raJHhAvL
Co-authored-by: Claude <noreply@anthropic.com>
…ledger

check:engine-double-contract learns the permission-set name test file's
delete/findOne/update doubles so it protects that file.

Dispatched by the W3-C3b QA run security path (detail withheld).

Claude-Session: https://claude.ai/code/session_017xRBcVkGJizGH7raJHhAvL
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 1ed33609731b0bc3b2da81c97242e9c8582d003d.

⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 58 of 210 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 — 167 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 1ed33609731b0bc3b2da81c97242e9c8582d003d → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 14013912b9242a21ff32eda2498084c96887e410 — the merge of head 585e61981375b2d04780975e135e3dc6fba4d98f into base 1ed33609731b0bc3b2da81c97242e9c8582d003d, 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 14013912b9242a21ff32eda2498084c96887e410 && git checkout 14013912b9242a21ff32eda2498084c96887e410
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1ed33609731b0bc3b2da81c97242e9c8582d003d 585e61981375b2d04780975e135e3dc6fba4d98f && git checkout -B drift-repro 1ed33609731b0bc3b2da81c97242e9c8582d003d && git merge --no-ff 585e61981375b2d04780975e135e3dc6fba4d98f

node scripts/docs-audit/affected-docs.mjs --json 1ed33609731b0bc3b2da81c97242e9c8582d003d

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

os-dev-report

{
"issue": null,
"status": "done",
"branch": "claude/harden-permission-set-name-unique-across-packages",
"pr": "#27535",
"session": "session_017xRBcVkGJizGH7raJHhAvL",
"premise_still_valid": true,
"summary": "Hardening (detail withheld per the W3-C3b QA run security path). The metadata door and the draft-to-active promotion now enforce that a permission-set identifier is unique across packages: a permission-set write whose name a different owner already holds is refused with RESOURCE_CONFLICT / 409 and nothing is written, the uniqueness the package-install door and the Studio dialog already enforce (ADR-0086 D4, ADR-0131 D4). Reuses the existing ledgered RESOURCE_CONFLICT code. Contract-first; the same-owner edit path and the original holder access are unchanged.",
"premise_measurement": "Re-verified on current main (ded67bf). Measurement detail withheld; delivered on the dispatch report channel.",
"files": "packages/metadata-protocol/src/protocol-save-door-refusals.ts (new permissionSetNameCollisionRefusal body plus its SaveDoorRefusalHost pick), packages/metadata-protocol/src/protocol.ts (import, private method delegator, two call sites: saveMetaItem save door and promoteDraftForPublish publish door), packages/metadata-protocol/src/protocol.permission-set-name-cross-package.test.ts (new, 10 cases), .changeset/harden-permission-set-name-cross-package.md, scripts/engine-double-contract.pinned.json (ledger: the new test file doubles).",
"tests": "typecheck: pnpm --filter @objectstack/metadata-protocol typecheck → exit 0. New test packages/metadata-protocol/src/protocol.permission-set-name-cross-package.test.ts: Test Files 1/1, Tests 10/10 pass (the two doors plus their controls: a same-owner edit, a fresh name, a create precondition, a code-shipped holder). Siblings: 10 save/publish/permission files, 236/236 pass. Ablation (subject src-resolved via a relative import, no rebuild) with scripts/ablation-replace.mjs: mutation proved on disk (anchor 1→0, blob d2c3e2e8831c→3de10b596505); with the predicate neutralised 7 of the 10 pin cases went red and the 3 controls stayed green; restored, blob == HEAD and git diff HEAD empty.",
"gates": "Local derived subset reconciled with dispatch-gates --ran: 69 derived, 66 run all exit 0, 3 NOT-MEASURED (exit 3 PREREQUISITE NOT MET: check:dual-build-cjs-loads and check:lean-entry-closure need a full dist build; check-plugin-teardown-shape --self-test needs an unshallowed clone), 0 UNRUN. check:engine-double-contract went red then green after recording the new test doubles with --write. Key validators green: engine-double-contract, error-status-conformance, dispatcher-error-vocabulary, nul-bytes, doc-authoring, issue-citations, objectql-double-limit, test-source-alias, cross-package-test-inputs, and the changeset gates: check-empty-changeset, check-changeset-no-major, check-adr-0087-registration (recognised BREAKING plus clause-2-narrowing, disposition not-required no-migration-prescription). CI on head 4cd524b (PR check-runs, not re-run locally): Lint & Repo Gates, TypeScript Type Check, Build Core, Test Core (8/8 shards), Test UI, Dogfood Regression Gate, Auto Label all success; UI build/E2E skipped (no UI diff); no non-success non-skipped check.",
"dogfood_battery": "4cd524bf · VERDICT command-exit 0 · Test Files 348 passed / 3 skipped (351) · Tests 2806 passed / 20 skipped (2826) · no failures. Ran because Clause-2 carries the (narrowing) arm; full battery, no OS_TEST_SHARD, no paths; lock held 22m10s (flagged holder-side starvation, which is the mandated un-sharded battery).",
"mcp_calls": "0 — no MCP GitHub tool calls; all GitHub writes went through the scripts/pm/fleet-write relay as objectstack-fleet[bot].",
"api_writes": "2 — relay pr_create (POST /repos/objectstack-ai/objectstack/pulls, draft PR #27535, body read back byte-identical) and relay comment (POST /repos//issues/27535/comments, this report). git push is not a REST write; the engine-double ledger --write was a local file edit.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none · optional one-line note in skills/objectstack-data/rules/security.md that a permission-set name is unique across the deployment; not filed because Metadata-protocol: none and the skill already teaches by-name grants and never claims a name may be reused across packages · noted, not filed"
],
"deviations": "PR body did NOT carry the session-URL attribution footer at create time; durable attribution is in the commit trailers and in this report comment, and it was not patched afterwards per the no-PATCH-after-create rule (seat may add it). No PR assignee set: there is no GitHub card/issue whose assignee to mirror (this dispatch is the card). Zero labels written: the dispatch named no label and skip-changeset does not apply (a patch changeset ships). Owes one contract-review-tier review before the queue (Clause-2: no (narrowing)); dev does not land, and needs:contract-review is the dispatch seat's row, not the dev's."
}

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 4cd524bf0bb0d435dcee24caa459298cd6b585bb · 2026-10-29T13:01Z

Triage seat, under gap rule (b) (#27062): this head is over 60 minutes old with no record and no claim. The review runs at CONTRACT_REVIEW_TIER. If the lane claims or posts a record on this head first, triage withdraws.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4cd524bf0bb0d435dcee24caa459298cd6b585bb
Local-runs: none
Scope: packages/metadata-protocol/src/protocol-save-door-refusals.ts, packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol.permission-set-name-cross-package.test.ts, scripts/engine-double-contract.pinned.json, .changeset/harden-permission-set-name-cross-package.md + packages/metadata-protocol/src/sys-metadata-repository.ts (resolveWriteHead, restoreVersion), packages/metadata-protocol/src/protocol-stored-overlay-reads.ts, packages/objectql/src/registry.ts (getItem, registerItem, SecurityCatalogNameConflictError, findPackageHeldSecurityCatalogNames), packages/objectql/src/security-catalog-namespace.ts, packages/spec/src/security/permission-set-owner-binding.ts, packages/spec/src/kernel/metadata-plugin.zod.ts (the permission entry), packages/rest/src/meta-item-write-routes.ts, docs/adr/0005, docs/adr/0086 D4, docs/adr/0131 D4, skills/objectstack-data/rules/security.md, ui/packages/i18n/src/namespaces/studio/en.ts (access.createTakenElsewhere); rulings 6050490870 (#15196 Q4 A), 6063176077 (#22307 A), 6074838935 (#22371 B), read with gh api GET.

Inputs: no card exists (the dev report's "issue": null; the dispatch is the card), so the PR body and its three comments are the whole prose input. Check-runs on the head: 48, every one completed; every non-skipped run success (Lint & Repo Gates, Check Changeset, Governed Surface Queue Guard, TypeScript Type Check, Build Core, Test Core 8/8, Test UI, Dogfood Regression Gate, PR-body guards); the skipped ones are the UI/docs/opt-in families with no diff in their paths. None pending.

① Derived judgments

Accept-set changes the diff implies, each judged against the live tree:

  1. PUT /api/v1/meta/permission/:name (draft and publish mode) now answers 409 RESOURCE_CONFLICT, nothing written, when an ACTIVE set of the name is held by a different owner. Placement is right: protocol.ts:21007 sits after refusePackagedBaseOverride (:20535, so the packaged-lock 403 NOT_OVERRIDABLE is not pre-empted), after the metadata-protocol: a package-less direct save of an item a package owns answers 200 and writes a package-less row the item read never serves; the draft save already resolves the owning package (#11087 / #22128) #23165 packageless-address rewrite (:20599–20615), and before the schema gate, the lock precondition (:21670 repo.put) and every write. The store read propagates every non-benign failure (rethrowUnlessMetadataStoreUnprovisioned), so the predicate fails closed. Right, with the one exception judged in ④ must-fix 1.
  2. POST /api/v1/meta/permission/:name/publish and POST /api/v1/packages/:id/publish-drafts: the promotion is judged on promoteInto before repo.promoteDraft (:23081); the batch tags the causal item and rolls back (:24245–24300), failed[] carries code: RESOURCE_CONFLICT. Right.
  3. POST /api/v1/packages/:id/duplicate is a third face of the same door: duplicatePackage saves every copied set through saveMetaItem(mode: 'publish', packageId: target) under renameName(row.name) (:25677–25680, :25768), which swaps the source namespace prefix only when the name carries it. An unprefixed set name is now reported in failed[] with this refusal instead of being copied as a second holder. Right behaviour, undeclared — see ② and ④ should-fix 3.
  4. migrateStoredMetadata re-saves stored rows through the same door ("records the refusal as the row's failure"): a deployment already holding one name under two owners now has both rows reported failed on os migrate meta --stored. Right behaviour, undeclared.
  5. No public surface widens: permissionSetNameCollisionRefusal is not re-exported from packages/metadata-protocol/src/index.ts; the private method and the SaveDoorRefusalHost pick are internal. No error code is minted (RESOURCE_CONFLICT/409 is ledgered). Right.
  6. Holder reading: stored active environment rows by package_id ?? null (storedOverlayEntries never writes the 'sys_metadata' sentinel — withRowBinding goes through boundPackageOf, which drops it), plus registry.getItem(type, name) counted only with a string _packageId. Boot hydration and the write-through register package-less rows with no _packageId (protocol.ts:15454–15463, plugin.ts:3870), so an environment-owned set is never mis-read as a package's. Right.
  7. Wrong: the owner the save door hands the predicate is saveWriteBinding's answer, which is the OWNER JUDGE's binding, not the write's address. For a draft save that names no package, request.packageId stays empty by design (protocol.ts:20622–20633, metadata-protocol: over legacy object rows [package-less, Q], a draft save naming no package answers 200 into Q's draft, which no publish can serve: the save door's D9.9 check judges the request's package before the draft address resolves to Q #23712) and the repository inherits the overlaid active row's package (SqlDriver org auto-scope makes SysMetadataRepository.listDrafts blind to env-wide drafts (organization_id IS NULL ∧ injected org = always empty); mode=draft saves drop package_id #11087, sys-metadata-repository.ts resolveWriteHead); saveWriteBinding returns that inherited package only when the body carries a string packageId, else null (protocol-save-door-refusals.ts:578–603). A body with no packageId is legal ("absent: an absent key declares nothing", permission-set-owner-binding.ts verdict table). So the owning package's own draft edit through the console's plain ?mode=draft save — the exact caller SqlDriver org auto-scope makes SysMetadataRepository.listDrafts blind to env-wide drafts (organization_id IS NULL ∧ injected org = always empty); mode=draft saves drop package_id #11087 names — is judged owner null against holder P and refused as a foreign write, while the identical save in publish mode is accepted (the metadata-protocol: a package-less direct save of an item a package owns answers 200 and writes a package-less row the item read never serves; the draft save already resolves the owning package (#11087 / #22128) #23165 rewrite names P). The test's same-owner CONTROL passes packageId: OWNER_B explicitly and does not reach this path.

② Semver level

.changeset/harden-permission-set-name-cross-package.md: '@objectstack/metadata-protocol': patch, carrying Clause-②: no (narrowing), Metadata-protocol: none, the adr-0087: not-required (no-migration-prescription) marker and a BREAKING before/now/owner/who-sees-it/remedy block. The PR body carries the same Clause-②: no (narrowing) line. Check Changeset and Governed Surface Queue Guard are green on the head, so the level stands as the gate's verdict (a REST accept-set narrowing that adds no protocol capability ships patch). Metadata-protocol: none is correct: no authorable key, value or type changes; the permission entry keeps supportsOverlay: true, allowEnvironmentOverlay: false, allowRuntimeCreate: true (metadata-plugin.zod.ts:1206), which is the ADR-0005 premise the body cites. The "Who sees it" paragraph names the PUT and publish-drafts faces only; the duplicate face (①.3) and the stored-migration face (①.4) are consumers of the same door and are not named (④ should-fix 3) — a wording gap, not a level change.

③ Boundary flags

  • Dev report open_questions: [] — nothing to answer.
  • out_of_scope_findings: an optional one-line note in skills/objectstack-data/rules/security.md that a set name is unique across the deployment; not filed. Agreed not owed: the skill teaches grants by machine name and says nothing a cross-package name could rely on; Metadata-protocol: none means no new authorable capability to teach. ADR-0131 D4 already states "Studio refuses a name a managed package holds".
  • deviations: PR body lacks the session-URL footer (durable attribution is in the commit trailers and the report comment); no assignee (no card); labels are the auto-labeller's. Seat matters, not contract matters; none escalated.
  • Security path "detail withheld": the mechanism was read in the tree instead (①.1–①.7); nothing in the withheld measurement is needed for this verdict.
  • Two derived families NOT MEASURED locally by the dev (check:dual-build-cjs-loads, check:lean-entry-closure): answered by Build Core and Lint & Repo Gates success on the head.

④ Design judgment

The narrowing against the published spec on main: ADR-0131 D4 (a grant names its set by bare name; the namespace is the environment registry's; Studio refuses a name a managed package holds), ADR-0086 D4 (a package never writes into a shared or foreign record), the maintainer rulings Q4 = A on #15196 ("positions, permission sets and capabilities each hold one namespace per deployment") and A on #22307 ("one name, one holder, on every path"). The registry already refuses the second holder at install and at cold boot (SecurityCatalogNameConflictError), and security-catalog-namespace.ts records that it deliberately does not judge "an environment-catalog save over a package-held name — that path keeps its own answers". This PR is that path's own answer, so it makes the spec's own refusal real at the metadata door and takes no declared capability away in principle: a cross-owner second set was never an authorable capability. Direction right; the implementation over-reaches at one consumer path and under-reaches at one door.

Must-fix

  1. The save door's owner is read from the wrong resolution, so the owning package's own draft edit is refused. protocol.ts:21007–21010 passes writeBinding (= saveWriteBinding, built for the ADR-0086 D3 owner judge: it reads the store only when the body carries a string packageId) as the write's owner. For a draft save naming no package over a package-bound active row with a body that omits packageId, the write lands in the inherited package P (SqlDriver org auto-scope makes SysMetadataRepository.listDrafts blind to env-wide drafts (organization_id IS NULL ∧ injected org = always empty); mode=draft saves drop package_id #11087) while the predicate judges owner null, finds P foreign and answers 409 "this environment cannot save a second set of that name". The same body in publish mode is accepted (:20615 names P). This contradicts the PR's own invariant ("the same-owner edit path is untouched") and the verdict table that legalises an absent packageId, and it refuses a path the door declares (SqlDriver org auto-scope makes SysMetadataRepository.listDrafts blind to env-wide drafts (organization_id IS NULL ∧ injected org = always empty); mode=draft saves drop package_id #11087, the console's plain ?mode=draft save). Fix: hand the predicate the binding the write lands in — the draft address's into (draftAddress, :20626–20633, already in scope; refuse-as-ambiguous when it is) for a draft save, request.packageId after the metadata-protocol: a package-less direct save of an item a package owns answers 200 and writes a package-less row the item read never serves; the draft save already resolves the owning package (#11087 / #22128) #23165 rewrite for a publish-mode save — and add a pin: owner B holds the active set, body without packageId, mode: 'draft', no packageId on the request, expected success: true, draft bound to B.

Should-fix

  1. The restore door is not judged. The sibling owner judge is asked at three doors — save, restore (restoredBodyWriter → refuseRestoredBodyTheSaveDoorRefuses, the save door's refusal family metadata-protocol: rollbackMetaItem / revertCommit restore a version without the save-door refusal family, so a restore can re-create a refused shape #25278 built for exactly this, "never a copy of a rule") and promotion — and restoreVersion writes an active row where none exists ("a missing active row yields null — the unbound row", then put). The new predicate is asked at two. Add it to refuseRestoredBodyTheSaveDoorRefuses against the binding the restore reads, with a pin through rollbackMetaItem, so the PR's "every door a body reaches active through" holds.
  2. Declare the faces. The changeset's "Who sees it" should name POST /api/v1/packages/:id/duplicate (failed[] entry with these words for a set name without the source namespace prefix; the remedy "prefix it with your own module prefix" is the right one there) and the os migrate meta --stored re-save of pre-existing two-owner rows (both reported failed until one is renamed or deleted). Level unchanged.
  3. Envelope. Rulings feat(core,objectql,plugin-security,plugin-sharing): the catalog is read from the registry; assignment tables reference it by name (ADR-0131 D2/D3/D4) #15196 Q4 A and [decision] cold boot admits a package whose permission set or position name the environment catalog already holds (package registration runs before sys_metadata hydration), while a hot install of the same package is refused #22307 A fix this rule's envelope at 422 NAMESPACE_CONFLICT at install and at cold boot ("the same envelope, on every path"); the metadata door now answers 409 RESOURCE_CONFLICT for the same condition, so a client reads two codes for one rule depending on the door. The body's defence (metadata-protocol's own unmanaged-install collisions answer 409) is a pre-existing split between the two packages, not a ruling. Either adopt NAMESPACE_CONFLICT/422 here with the registry's words, or record in the predicate's docblock why this door keeps 409 and cite the ruling it departs from.
  4. A second reading of "who holds a name". Ruling B on [decision] after #22307 a cold boot refuses any environment row over a package-held permission set or position, so the 2026-08-24 legacy-overlay remedies (boot overlay reading, drift overlay_shadow, Discard Overlay) find nothing on v18: keep or retire? #22371 refused "a second reading of who holds a name beside the cold boot's, approximate on both sides, drifting over time", and the registry exports that reading (findPackageHeldSecurityCatalogNames). The predicate re-derives the package half from getItem (one item, no install claims, securityCatalogClaims unread) and the environment half from stored rows. Today the two agree wherever the install rule is in force; the drift risk is the one the ruling names. Either read the package half through the registry's export or state in the docblock why the door's reading must differ (it must count stored package-bound rows the cold boot does not see).
  5. metadata-protocol: a package-less direct save of an item a package owns answers 200 and writes a package-less row the item read never serves; the draft save already resolves the owning package (#11087 / #22128) #23165's residue. The direct save used to mint a package-less row beside a package's row of the same name, and metadata-protocol: a package-less direct save of an item a package owns answers 200 and writes a package-less row the item read never serves; the draft save already resolves the owning package (#11087 / #22128) #23165 rules that "a package-less row beside the package's row is not a second package" for the write address. The predicate counts that residue as a second owner (null against P), so on such an item the owning package's publish-mode edit and the environment's edit are both refused, and the refusal's remedy ("edit the existing set in the package that owns it") is itself refused. Either exclude the package-less row beside a single package's row from the foreign set (the metadata-protocol: a package-less direct save of an item a package owns answers 200 and writes a package-less row the item read never serves; the draft save already resolves the owning package (#11087 / #22128) #23165 rule, applied to ownership), or word the refusal for that shape with the delete that clears it.

Test and ledger on the floor: the 10 cases pin both doors with paired controls; the engine double records delete/findOne/update in scripts/engine-double-contract.pinned.json, matching the three asserts the file imports. The stub registry's registerItem(type, item) drops a third and fourth argument, which is harmless here (the write-through passes no packageId for the rows these cases write) but means the harness cannot see provenance the real registry stamps; the must-fix pin above needs no new double.

Implemented-by: claude/harden-permission-set-name-unique-across-packages
Reviewed-by: session_01AavokzJ5DndAwitDXvKy4U

VERDICT: FAIL


Generated by Claude Code

…s that now stamp it

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
…e lands in, at the restore door too, with the registry's envelope and reading

The save door judged a draft save that names no package as owner null while
the repository wrote it into the inherited package, so the owning package's own
plain draft edit was refused. The owner is now the draft address's into (an
ambiguous address refused as the repository refuses it). The restore family
asks the predicate too; the envelope is NAMESPACE_CONFLICT / 422 with the
registry's words; the package half is the registry's own package-held reading;
a package-less row beside exactly one package's row is that package's.

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk
os-dev-report

{
"issue": null,
"status": "done",
"branch": "claude/harden-permission-set-name-unique-across-packages",
"pr": "#27535",
"session": "session_017xRBcVy4VXmkQjn2MGeaWk",
"premise_still_valid": true,
"summary": "Rework of the contract-tier review FAIL (comment 6497768595), all six items taken, PR kept draft, head 585e619 (merged origin/main 556f323 first, no force-push). Must-fix 1: the save door now hands permissionSetNameCollisionRefusal the binding the write LANDS IN: the named package (for a publish-mode save naming none, the #23165-resolved package already on the request); for a draft save naming none, the draft address's into (packagelessDraftAddress, read for server-stated rewrites too), an ambiguous address refused with the repository's packagelessAddressAmbiguityRefusal (409); otherwise null. Pinned: owner B holds the active set, body without packageId, mode draft, no package on the request -> success, draft bound to B. Should-fix 2: the restore family (refuseRestoredBodyTheSaveDoorRefuses, first judge) asks it against the restore's binding, pinned via rollbackMetaItem + control. Should-fix 3: changeset 'Who sees it' names POST /api/v1/packages/:id/duplicate and os migrate meta --stored --apply. Should-fix 4: envelope is NAMESPACE_CONFLICT / 422 with the registry's words (SecurityCatalogNameConflictError), co-registered under @objectstack/metadata-protocol in spec's ERROR_CODE_LEDGER (DEFAULT_RECORD_PAGE_CONFLICT precedent; ErrorCode union unchanged; spec patch added to the changeset). Should-fix 5: the package half is the registry's own reading (registry.packageHeldSecurityCatalogNames, the member findPackageHeldSecurityCatalogNames answers; metadata-protocol cannot import objectql, so it is reached structurally like defaultRecordPageHoldersOf) plus stored rows by binding; a new objectql suite drives the doors over the real ObjectQL + SchemaRegistry so a rename reds there. Should-fix 6: a package-less row beside exactly one package's stored row is read as that package's on both sides (#23165's rule applied to ownership). Clause-② re-checked after the envelope change: still no (narrowing) (accept -> refuse vs main; the ledger row adds no ErrorCode member); Metadata-protocol: none. The PR body is NOT patched (os-dev contract): the replacement is staged at /tmp/claude-0/-home-user-objectstack/bdda939c-7424-5594-af38-0e6fe5d7eeed/scratchpad/pr-27535/pr-body.md for the seat to write (it keeps the Clause-②/Metadata-protocol/Skills-docs lines and adds the session-URL footer the review noted missing).",
"tests": "All at head 585e619 (git rev-parse --short HEAD), verify-lock, exit codes captured before any pipe. metadata-protocol: typecheck + build VERDICT command-exit 0; full pnpm --filter @objectstack/metadata-protocol test -> Test Files 280 passed | 4 skipped (284), Tests 21798 passed | 26 skipped (21824), VERDICT command-exit 0; new file protocol.permission-set-name-cross-package.test.ts 17/17. objectql: typecheck (incl. check:test-typecheck) + test (project local) -> Test Files 487 passed (487), Tests 9132 passed (9132), VERDICT command-exit 0; new file protocol-permission-set-name-registry-reading.test.ts 4/4. spec: typecheck + test -> Test Files 752 passed (752), Tests 22343 passed | 1 todo, VERDICT command-exit 0. tsc --listFilesOnly counts the metadata-protocol test in its program (1) and the objectql test in tsconfig.test.json (1). Ablation of must-fix 1 (fix committed first; src-resolved via ./protocol.js, no rebuild; scripts/ablation-replace.mjs inside a trap-restore script): anchor 1->0, marker 0->1, blob 00b7328d66f7 -> 9092f29eaeb8 on disk; Tests 2 failed | 15 passed (17) — the must-fix pin red with 'this environment cannot save the permission set "project_editor" — it is already held by package "app.owner_b"' (the reviewed defect), and the ambiguous-address pin red (422 instead of the repository's 409); restored blob == HEAD 00b7328d66f7, git diff HEAD empty (tool + trap). Direction observed: red, as expected.",
"gates": "dispatch-gates --repo objectstack-ai/objectstack --commands (no paths; 8 changed paths vs merge base 556f323): 93 derived, all run; 91 exit 0 first pass; check:dual-build-cjs-loads exit 3 and check:lean-entry-closure exit 3 (PREREQUISITE NOT MET); after pnpm --filter @objectstack/objectql build check:lean-entry-closure exit 0. --ran with command :: exit N record -> 93 accounted, 92 run, 1 NOT-MEASURED (check:dual-build-cjs-loads, needs 72 packages' dist; answered by Build Core success in CI). Key lines: adr-0087-registration exit 0 (exemption no-migration-prescription), changeset-no-major exit 0, empty-changeset exit 0, engine-double-contract OK (1123 pinned), dispatcher-error-vocabulary OK, error-status-conformance OK, error-code-casing/doc-authoring/issue-citations/nul-bytes/test-source-alias/cross-package-test-inputs/durability-log-level exit 0, spec check:api-surface unchanged, check:docs 232 in sync. Local-only: check:skill-frame-freshness exit 0; check:dev-prereqs exit 1 (worktree built only for the diff's closure, 35/105 packages without dist — local-state reading, not a finding). CI on head 585e619: 49 check-runs, all completed — 39 success, 10 skipped, 0 failure: Lint & Repo Gates success, TypeScript Type Check success, Test Core success (8/8 shards success), Build Core success, Governed Surface Queue Guard success, Check Changeset success, Dogfood Regression Gate success, Lint UI skipped, Type Check UI success, Test UI success, Build & E2E UI success, Live E2E UI success, PR-body guards success.",
"dogfood_battery": "585e6198 · closure build VERDICT command-exit 0 · pnpm --filter @objectstack/dogfood test VERDICT command-exit 0 · Test Files 350 passed | 3 skipped (353) · Tests 2817 passed | 20 skipped (2837) · no failure",
"mcp_calls": "0",
"api_writes": "1 — this os-dev-report comment on PR 27535 via scripts/pm/post-stamped.mjs (fleet-write relay: POST /repos//issues/27535/comments as objectstack-fleet[bot]); plus 3 git pushes (not REST); PR body not PATCHed; no labels written (none named, skip-changeset not applicable)",
"deviations": [
"Harness attribution reminder asked for a model-named Co-Authored-By trailer; commits carry AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude) instead.",
"Importer suites outside the touched packages (rest, runtime, cli, plugins) not run locally — the wire change is a new refusal only; Test Core 8/8 and the dogfood battery are green on the head.",
"objectql test:repo project not run locally (CI's; the new objectql test is in the local project).",
"post-stamped.mjs refuses a comment whose first non-empty line is not a seat signature, so the report comment opens with the dispatch's signature line domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk and carries os-dev-report alone on the next line (the os-dev-report marker reader admits exactly one leading signature line) — a conflict between the os-dev 'first line is the marker' wording and the write tool, flagged rather than chosen silently."
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: none · packages/objectql/src/security-catalog-namespace.ts header says an environment-catalog save over a package-held name 'keeps its own answers' — still true, but it could point at the metadata-protocol predicate now giving that answer; comment-only, noted in the staged PR body's Acceptance notes, not filed"
]
}

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract-review-claim: session_01L6bhgV4fMGJHSdSdx3u9mK · head 585e619 · 2026-10-29T15:20Z


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 585e61981375b2d04780975e135e3dc6fba4d98f
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts, packages/metadata-protocol/src/protocol-authoring-gate.ts, packages/metadata-protocol/src/protocol-save-door-refusals.ts, packages/metadata-protocol/src/protocol.permission-set-name-cross-package.test.ts, packages/metadata-protocol/src/protocol.ts, packages/objectql/src/protocol-permission-set-name-registry-reading.test.ts, scripts/engine-double-contract.pinned.json + packages/metadata-protocol/src/sys-metadata-repository.ts (resolveWriteHead, restoreVersion, packagelessAddressAmbiguityRefusal, the NO_DRAFT throw in promoteDraft), packages/metadata-protocol/src/protocol-stored-overlay-reads.ts (readActiveOverlayRows, storedOverlayEntries), packages/metadata-protocol/src/protocol-lock-version-gates.ts (packagelessDraftAddress), packages/metadata-protocol/src/meta-overlay-cache.ts, packages/objectql/src/registry.ts (packageHeldSecurityCatalogNames, securityCatalogPackageHolders, findPackageHeldSecurityCatalogNames, SecurityCatalogNameConflictError), packages/objectql/src/security-catalog-namespace.ts, packages/objectql/src/index.ts, packages/spec/src/api/errors.zod.ts, packages/spec/scripts/check-error-code-provenance.ts, packages/spec/src/kernel/metadata-plugin.zod.ts (the permission entry), packages/rest/src/error-response.ts, docs/adr/0131 D4, docs/adr/0086 D4, skills/objectstack-data/rules/security.md, .changeset/harden-permission-set-name-cross-package.md (read as the ② input), the earlier record 6497768595 and the two os-dev-report comments.

Inputs: no card (both dev reports carry "issue": null; the dispatch is the card), so the PR body, its five comments and the diff are the whole prose input. The PR body is the FIRST head's account (it still says RESOURCE_CONFLICT / 409, two doors, no @objectstack/spec change); the tree and the changeset at this head say NAMESPACE_CONFLICT / 422, three doors and a spec ledger row. Judged on the tree throughout (should-fix 1 below). Check-runs on 585e6198: 49, every one completed, 39 success, 10 skipped, 0 failure. The gate verdicts: Lint & Repo Gates, Check Changeset, Governed Surface Queue Guard, PR-body guards, TypeScript Type Check, Build Core, Test Core 8/8, Test UI, Type Check UI, Build & E2E UI, Live E2E UI, Dogfood Regression Gate, Spec property liveness — all success. The skipped ones are the opt-in and matrix-parent families with no diff in their paths. None pending.

Earlier record (6497768595, head 4cd524b, FAIL) — each item against this head

  • Must-fix 1 (owner read from saveWriteBinding, the owning package's plain ?mode=draft edit refused): CLOSED. protocol.ts:20960–20974 hands the predicate landsIn: the named package; for a draft save naming none, packagelessDraftAddress(...).into (the draftAddress read at :20559, or re-read for the server-stated rewrites), an ambiguous address refused with the repository's own packagelessAddressAmbiguityRefusal (409); else null. into is by construction the binding SysMetadataRepository.resolveWriteHead stamps (sys-metadata-repository.ts:838–905: one package's active row, with a package-less row beside it not a second package). Pinned: test §2 "a draft save naming no package, body without packageId, over package B's active set is B's own edit" expects success: true and the draft bound to OWNER_B; the dev's ablation report says that pin reds with the predicate neutralised.
  • Should-fix 2 (restore door not judged): CLOSED. protocol-authoring-gate.ts refuseRestoredBodyTheSaveDoorRefuses asks permissionSetNameCollisionRefusal(type, name, binding, 'restore') first; restoredBodyWriter is the one writer both rollbackMetaItem (:27341) and revertCommit (:26988) hand restoreVersion, with binding read from the active row (resolveOverlayPackageBinding), null when none — which is the row restoreVersion → put writes (sys-metadata-repository.ts:1695–1725). Pinned through rollbackMetaItem with a control.
  • Should-fix 3 (declare the duplicate and migration faces): CLOSED in the changeset's "Who sees it".
  • Should-fix 4 (envelope): CLOSED. NAMESPACE_CONFLICT / 422 with the registry's wording, ledgered under @objectstack/metadata-protocol (error-code-ledger.zod.ts:676–685).
  • Should-fix 5 (second reading of "who holds a name"): CLOSED. registryPackageHoldersOf asks the registry's own packageHeldSecurityCatalogNames() — the member findPackageHeldSecurityCatalogNames (registry.ts:2370–2374) itself answers through bracket access — and the objectql suite drives the doors over a real SchemaRegistry.
  • Should-fix 6 (metadata-protocol: a package-less direct save of an item a package owns answers 200 and writes a package-less row the item read never serves; the draft save already resolves the owning package (#11087 / #22128) #23165 residue counted as a second owner): CLOSED. residueOf (protocol-save-door-refusals.ts:392–398): a package-less row beside exactly one package's row is that package's, on the holder side and on the writer side; pinned in test §3 both ways.

① Derived judgments

Accept-set and public-surface changes the diff implies, each judged against the live tree at the head:

  1. PUT /api/v1/meta/permission/:name (draft and publish mode) answers 422 NAMESPACE_CONFLICT, nothing written, when the name is held by an owner other than the binding the write lands in. Placement (protocol.ts:20936–20974): after refusePackagedBaseOverride (:20468, so the packaged lock's 403 NOT_OVERRIDABLE still comes first), after the lock gate and the metadata-protocol: a package-less direct save of an item a package owns answers 200 and writes a package-less row the item read never serves; the draft save already resolves the owning package (#11087 / #22128) #23165 address rewrite (:20534–20548), before the schema gate, the runtime rules, the authoring gate, the optimistic-lock / first-write-pin resolution (:21521–21567) and repo.put (:21646). The store read propagates every non-benign failure (rethrowUnlessMetadataStoreUnprovisioned); the registry read is not caught. Fails closed. Right.
  2. Owner = the binding the write lands in. Named package → that package; publish-mode save naming none → the metadata-protocol: a package-less direct save of an item a package owns answers 200 and writes a package-less row the item read never serves; the draft save already resolves the owning package (#11087 / #22128) #23165 address already on request (one package's row, else null); draft save naming none → packagelessDraftAddress(...).into, which is resolveWriteHead's inheritance (#11087, #23297, #23657) read ahead of the write; ambiguous → the repository's own 409, now thrown at the door instead of inside put (same code, same status, earlier). Right — one resolution for the write and the judge.
  3. Holders = stored active environment rows by package_id ?? null (storedOverlayEntries, no sentinel) ∪ the registry's package half (packageHeldSecurityCatalogNames: composite-slot items and install claims, built-ins skipped, never the bare slot). A package-less stored row beside exactly one package's row is read as that package's; beside two or more, or none, it is the environment's. The bare slot is deliberately not a holder: hydration and the write-through register stored rows bare (security-catalog-namespace.ts:50–65), so the stored row is the only carrier of a stored set's owner, and a registered item with a package lives in its composite slot. Right, and it is the cold boot's package reading plus the half the cold boot cannot see, stated as such in the docblock.
  4. POST /api/v1/meta/permission/:name/publish and POST /api/v1/packages/:id/publish-drafts: judged on promoteInto (:23044–23049) before repo.promoteDraft; the batch reports code: 'NAMESPACE_CONFLICT' in failed[] (:24636) and rolls back; pinned both ways. Right, with the refusal-order note in ④ should-fix 2.
  5. Restore doors (rollbackMetaItem, revertCommit's restore limb): judged against the active row's binding, null when the set is gone — the unbound row restoreVersion would write, so a deleted set whose name a package since claimed is not restored as a second (environment) holder. revertCommit reports it per item in failed[]. Right.
  6. Third and fourth faces: duplicatePackage saves each copied set through saveMetaItem({ mode: 'publish', packageId: target, writeFace: 'package-duplicate' }) (:25748–25762), so an unprefixed set name is now a failed[] entry with this refusal; migrateStoredMetadata re-saves rows with source: 'migrate-stored' and the row's own packageId (:22277–22290), so a pre-existing two-owner name is reported failed for either owner's row until one is renamed or deleted (a package-less row beside one package's row is re-saved as that package's). Both declared in the changeset. Right.
  7. Envelope: NAMESPACE_CONFLICT / 422, the code and status SecurityCatalogNameConflictError stamps (registry.ts:2173–2176) — rulings Q4 = A on feat(core,objectql,plugin-security,plugin-sharing): the catalog is read from the registry; assignment tables reference it by name (ADR-0131 D2/D3/D4) #15196 and A on [decision] cold boot admits a package whose permission set or position name the environment catalog already holds (package registration runs before sys_metadata hydration), while a hot install of the same package is refused #22307, "one name, one holder, on every path". The REST door reads the thrown status (error-response.ts), check:error-status-conformance is green on the head, so the docs' 422 for the code holds. Right. The thrown error carries status but not httpStatus; the HTTP doors read status and no CLI envelope rethrows this door's error, so nothing reads the missing field today.
  8. @objectstack/spec: ERROR_CODE_LEDGER['@objectstack/metadata-protocol'] gains 'NAMESPACE_CONFLICT', already registered under @objectstack/objectql, so REGISTERED_ERROR_CODES (a deduped set) and the ErrorCode union are unchanged; check:error-code-provenance (lint.yml:2803, green) requires the row because metadata-protocol now stamps the string (err.code = 'NAMESPACE_CONFLICT', protocol-save-door-refusals.ts:422), exactly the DEFAULT_RECORD_PAGE_CONFLICT precedent at :668. No code minted, no waiver needed. Right.
  9. No public API widens: permissionSetNameCollisionRefusal is a private method plus a package-internal body; AuthoringGateHost and SaveDoorRefusalHost picks are internal; findPackageHeldSecurityCatalogNames was already exported from objectql (index.ts:97). Right.
  10. The same-owner paths are untouched: owning package re-authoring by name, by the metadata-protocol: a package-less direct save of an item a package owns answers 200 and writes a package-less row the item read never serves; the draft save already resolves the owning package (#11087 / #22128) #23165 address, by the plain draft save; the environment re-authoring its own runtime set; a brand-new name — each has a CONTROL pin. Right.
  11. Shared limitation, not a finding: readActiveOverlayRows goes through the meta overlay cache (default TTL 30 s, keyed by engine.writeEpoch, which every write through the engine bumps, so one process never judges on rows older than its last write; a second process can, for at most the TTL). The sibling defaultRecordPageSaveRefusal reads the same way; the cross-instance race exists without the cache too. Named here so the hardening's reach is stated honestly.

② Semver level

.changeset/harden-permission-set-name-cross-package.md: '@objectstack/metadata-protocol': patch, '@objectstack/spec': patch, Clause-②: no (narrowing), Metadata-protocol: none, the adr-0087: not-required (no-migration-prescription) marker, and a BREAKING block with before / now / owner / holders / who-sees-it / remedy. The PR body carries the same Clause-②: no (narrowing) and Metadata-protocol: none lines. Check Changeset, Governed Surface Queue Guard, Lint & Repo Gates (which run check-adr-0087-registration, check-changeset-no-major, check:error-code-provenance) are green on the head, so the level stands as the gates' verdict: a REST accept-set narrowing that adds no protocol capability ships patch. Metadata-protocol: none is right: no authorable key, value or type changes; the permission entry keeps supportsOverlay: true, allowEnvironmentOverlay: false, allowRuntimeCreate: true (metadata-plugin.zod.ts:1206), which is the ADR-0005 premise the symmetry argument rests on. The spec patch is right too: the ledger row changes no ErrorCode member; what moves is which package is recorded as stamping an existing code. Clause-②: no (narrowing) is the right arm on every face judged in ①: each one goes from accept to refuse, none from refuse to accept, and the ambiguous-address 409 was already the repository's answer.

③ Boundary flags

  • open_questions: [] in both dev reports — nothing to answer.
  • Rework report deviations: (a) commits carry AGENTS.md's model-free trailer pair instead of the harness's model-named one — a seat/attribution matter, not a contract one; (b) importer suites outside the touched packages not run locally — answered by Test Core 8/8, Build & E2E UI, Live E2E UI and the dogfood gate on the head; (c) objectql test:repo project not run locally — CI's, green; (d) post-stamped.mjs needs a signature as the first line, so the os-dev-report marker sits on line two — a tooling-contract wording conflict flagged rather than chosen silently; escalated to the seat as a tooling note, no contract effect.
  • First report deviations: PR body without the session footer and not PATCHed afterwards (os-dev contract); the rework report says the replacement body is staged for the seat. That body is the only input that still describes the earlier head — should-fix 1.
  • out_of_scope_findings: (a) an optional one-line note in skills/objectstack-data/rules/security.md — agreed not owed: the skill teaches by-name grants and "one name per stack", nothing a cross-package reuse could rely on, and Metadata-protocol: none means no new authorable capability to teach; (b) security-catalog-namespace.ts's header says the environment-catalog save "keeps its own answers" — still true, now with this predicate as that answer; comment-only, not owed.
  • Security path "detail withheld": the mechanism was read in the tree (①.1–①.6); nothing withheld is needed for this verdict.
  • One derived family NOT MEASURED locally (check:dual-build-cjs-loads): answered by Build Core success on the head.

④ Design judgment

The narrowing against the published spec on main: ADR-0131 D4 (a catalog item is referenced by its machine name alone; the namespace is the environment registry's; "Studio refuses a name a managed package holds"), ADR-0086 D4 ("a package never writes into a shared/foreign record"), the maintainer rulings Q4 = A on #15196 and A on #22371/#22307 (one name, one holder, on every path, with the registry's envelope). The registry already refuses the second holder at install and at cold boot with NAMESPACE_CONFLICT / 422; security-catalog-namespace.ts records that it deliberately does not judge the metadata author's save, "that path keeps its own answers". This head is that path's answer, in the registry's envelope, at every door a body reaches active through, judged on the binding the write lands in, reading holders through the registry's own package half plus the stored rows. It takes no declared capability away at any consumer — a second owner of a set name was never authorable (install and Studio already refused it) — and makes the spec's own refusal real at the doors that lacked it. Direction right; the implementation holds against the code it cites.

Tried to break it, on the tree: (i) If-None-Match: * cross-owner create — refused before the precondition is resolved (:21521 onward), pinned; (ii) the owning package's plain draft save — lands in and is judged as that package, pinned; (iii) a package-less residue row — owner on both sides, pinned both ways; (iv) a draft authored before another owner took the name — refused at promotion, batch aborts with the code in failed[], pinned; (v) a deleted set restored after a package claimed its name — refused at restore, pinned; (vi) a host registry without the member — reads as no package-held name, the same stance defaultRecordPageHoldersOf takes, and the objectql suite reds on a rename; (vii) an unprovisioned store — no stored sets, every other read failure propagates; (viii) the schema-only test harness — paired controls through every door, so a door that refused everything cannot pass; the engine double records delete/findOne/update in scripts/engine-double-contract.pinned.json, the three asserts the file imports, and check:engine-double-contract is green. None of these breaks the design.

Must-fix: none.

Should-fix

  1. The PR body still describes head 4cd524b. It says RESOURCE_CONFLICT / 409, two doors, "no new error code is minted" with no spec change, and pnpm ... vitest run ... → 10/10; the tree answers NAMESPACE_CONFLICT / 422 at three doors with a spec ledger row and a 17-case file. The rework report says the replacement is staged for the seat; the seat should write it before the queue so the merged description claims what the tree does. The Clause-②, Metadata-protocol and Skills-docs lines are already right and must stay.
  2. Refusal order at the promotion. permissionSetNameCollisionRefusal at :23044 runs unguarded, while its three siblings (permissionSetOwnerBindingRefusal, defaultRecordPageSaveRefusal, the name judge) run under if (draftForGate), and repo.promoteDraft's NO_DRAFT (sys-metadata-repository.ts:1552) comes after. A per-item publish with no draft at the address, of a name another owner holds, now answers 422 NAMESPACE_CONFLICT where main answers 404 NO_DRAFT. Refusal for refusal, no accept-set effect; guard it with draftForGate like the siblings so the first fact the caller is told is the one about its own state, or record in the comment why this judge runs first.
  3. Private member reached structurally. packageHeldSecurityCatalogNames is private on SchemaRegistry, and the door reaches it by duck-typing (read.call(registry)), as the exported findPackageHeldSecurityCatalogNames itself does with bracket access. The objectql suite holds the seam from the side that sees both halves, which is adequate; a one-line note on the registry member naming the metadata-protocol caller would make a future rename red at the source as well as in the test.

Implemented-by: claude/harden-permission-set-name-unique-across-packages
Reviewed-by: session_01L6bhgV4fMGJHSdSdx3u9mK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVkGJizGH7raJHhAvL

席位核对

ACCEPT at head 585e61981375b2d04780975e135e3dc6fba4d98f. The maintainer instructed this session, verbatim: 「你应该使用 pm-dispatch 技能审核和落地 pr」.

  • Form: a draft against main. Security dispatch path, so no card. No closing keyword in the body. The body was refreshed by the PM seat at 15:22Z and matches this head (17 + 4 cases, NAMESPACE_CONFLICT / 422, the three doors).
  • Surface: check-governed-merges --pr 27535 reads NOT governed, 1099 changed lines, below the 3000-line threshold.
  • Contract review: a same-form record exists on this head. ## Contract review, Served-tier: CONTRACT_REVIEW_TIER, Head-sha 585e6198…, Reviewed-by: session_01L6bhgV4fMGJHSdSdx3u9mK, PASS (comment 6501292842). It follows the FAIL on 4cd524bf (comment 6497768595); the must-fix and all five should-fixes are closed per that record.
  • Notes the record leaves non-blocking:
    • Note 2: the promotion-door ordering, a refusal-for-refusal change with no accept-set effect.
    • Note 3: a one-line comment on the registry member.
      Neither is a must-fix. Both are noted here for a follow-up, not held against landing.
  • Declarations: Clause-②: no (narrowing), Metadata-protocol: none and Skills-docs: none — … are present. The changeset ships a patch for @objectstack/metadata-protocol and @objectstack/spec. The spec change is a ledger row only: NAMESPACE_CONFLICT is listed under metadata-protocol, with no new ErrorCode union member, and the contract review judged that under ①.
  • Dogfood battery pinned to the head (owed for the narrowing arm): 585e6198, 350 files passed, 3 skipped, no failure, full and un-sharded.
  • CI pinned to the head: 53 check-runs on 585e6198, 41 success, 12 skipped, 0 failure. Lint & Repo Gates success, TypeScript Type Check success, Test Core 8/8 success, Check Changeset success. mergeable_state clean.

Landing: pr_ready + automerge_enable through the relay.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 29, 2026 16:40
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

ACCEPT → head 585e61981375b2d04780975e135e3dc6fba4d98f · 2026-10-29T16:45Z

维护者速读

  • 改了什么:权限集名称在整个部署内唯一。别的所有者已经持有的名字,在保存、发布、回滚、复制这些入口都会被拒,返回 422 NAMESPACE_CONFLICT,与安装时的报错一致。
  • 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)。
  • 风险与回滚:属于 BREAKING 收窄;如果存量数据里已经有两个所有者持有同一个名字,os migrate meta --stored 会把它们报为失败;回滚即 revert 本 PR。
  • 先看:packages/metadata-protocol/src/protocol-save-door-refusals.ts。
  • 两分钟验证:pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.permission-set-name-cross-package.test.ts,预期 17 个用例全过。
  • 需要你做的:无。

席位意见:收。契约复核 PASS(6501292842),dogfood 全套 350 个文件、0 失败。

席位核对

  1. 形态:draft、目标 main;不建卡,正文无关闭关键词。
  2. 前提实测:复核记录 6497768595 和 6501292842 都读过代码。
  3. 门禁:本 head 共 42 个 success、12 个 skipped、0 失败;两个必需 job 都是 success。
  4. 接受的偏差:复核记录中的两条可选建议(发布时报错的先后顺序、registry 方法补注释),归入后续收尾。
  5. 范围外去向:无。Skills-docs: none — REASON 属实。
  6. 落地:不属于受管面,1099 行 → pr_ready + automerge_enable。

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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant