Repository navigation
harden(metadata): a permission set's name is unique across packages - #27535
objectstack-fleet[bot] merged 7 commits into
Conversation
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>
📓 Docs Drift CheckThis PR changes 2 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 167 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
|
os-dev-report { |
|
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U Contract-review-claim: Triage seat, under gap rule (b) (#27062): this head is over 60 minutes old with no record and no claim. The review runs at Generated by Claude Code |
Contract reviewServed-tier: Inputs: no card exists (the dev report's ① Derived judgmentsAccept-set changes the diff implies, each judged against the live tree:
② Semver level
③ Boundary flags
④ Design judgmentThe narrowing against the published spec on Must-fix
Should-fix
Test and ledger on the floor: the 10 cases pin both doors with paired controls; the engine double records Implemented-by: VERDICT: FAIL Generated by Claude Code |
…ion-set-name-unique-across-packages
…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>
|
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk { |
|
triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK Contract-review-claim: session_01L6bhgV4fMGJHSdSdx3u9mK · head 585e619 · 2026-10-29T15:20Z Generated by Claude Code |
|
triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK Contract reviewServed-tier: Inputs: no card (both dev reports carry Earlier record (6497768595, head 4cd524b, FAIL) — each item against this head
① Derived judgmentsAccept-set and public-surface changes the diff implies, each judged against the live tree at the head:
② Semver level
③ Boundary flags
④ Design judgmentThe narrowing against the published spec on Tried to break it, on the tree: (i) Must-fix: none. Should-fix
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:devx#1 · sunhaolin · session_017xRBcVkGJizGH7raJHhAvL 席位核对ACCEPT at head
Landing: Generated by Claude Code |
|
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk ACCEPT → head 维护者速读
席位意见:收。契约复核 PASS(6501292842),dogfood 全套 350 个文件、0 失败。 席位核对
Generated by Claude Code |
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, andMetadata-protocol: nonemeans there is no new authorable capability to teach.This narrows a published accept set (a metadata-door write that answered
200is now refused), so it owes one contract-review-tier review before the queue.维护者速读
NAMESPACE_CONFLICT/ 422。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 绿(三道门的拒绝 + 各自对照,含"所属包不带包名的草稿编辑可保存"一条)。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 withNAMESPACE_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).permissionSetNameCollisionRefusalrefuses apermissionwrite whose name a DIFFERENT owner already holds, nothing written. It is asked at every door a body reachesactivethrough:saveMetaItem, draft and publish mode), right after the sibling owner judge;promoteDraftForPublish, shared by the per-item publish andpublishPackageDrafts), onpromoteInto— a batch publish aborts on it;refuseRestoredBodyTheSaveDoorRefuses, underrollbackMetaItemandrevertCommit, on the binding the restore lands in (the active row'spackage_id, whichputkeeps;nullwhen 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 apackageId, so the owning package's own plain?mode=draftedit (body withoutpackageId, 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, theintoof the draft addresspackagelessDraftAddressnames — 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/objectqlexports that reading asfindPackageHeldSecurityCatalogNames(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'sdefaultRecordPageHoldersOffor the default-record-page rule. A new objectql suite drives the doors over the realObjectQL+SchemaRegistrycomposition, so a rename of that member reds there. The first cut'sgetItemreading (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'sSecurityCatalogNameConflictError("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'sERROR_CODE_LEDGERnow lists it under@objectstack/metadata-protocoltoo (theDEFAULT_RECORD_PAGE_CONFLICTprecedent); theErrorCodeunion 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 infailed[]; remedy: prefix it with your own module prefix) andos 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
patchchangeset ships it for@objectstack/metadata-protocoland@objectstack/spec.Tests
packages/metadata-protocol/src/protocol.permission-set-name-cross-package.test.ts— 17 cases on the storing engine double (doubles recorded inscripts/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 withoutpackageId,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 throughrollbackMetaItemand 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 (ObjectQLPluginbinding the real protocol to a realObjectQLand itsSchemaRegistry, an in-memory driver): the exported reading names the claimant; another installed package and the environment are refusedNAMESPACE_CONFLICT/ 422 naming it, nothing stored; control.Touched packages — typecheck and full suites (verify-lock, exit codes captured before any pipe)
tsc --listFilesOnlycounts the metadata-protocol test in that package's program (1) and the objectql test intsconfig.test.json's (1), whichcheck:test-typecheckcompiles.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.mjsunder a script withtraprestore againstHEAD.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 whyClause-②carries the(narrowing)arm while the kind staysnoneand the levelpatch. Re-checked after the envelope change: relative tomainthe doors still go from accepting to refusing, and the ledger row adds no member to theErrorCodeunion, so the declaration is unchanged.getItemdid 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-overlaysand 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;--ranreconciliation: 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-closurefirst exited 3 for the same reason; afterpnpm --filter @objectstack/objectql buildit measuredexit 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-widepnpm lintand the full-build gates are CI's. On head585e6198CI 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 UIandLive E2E UIsuccess;Lint UIskipped.Key gate verdict lines
Dogfood battery (Clause-② carries the narrowing arm) — final head
Generated by Claude Code
Body refreshed by the PM seat 2026-10-29T15:22Z from the os-dev rework report.