fix(plugin-security): a data-door edit of a permission set saved into a writable runtime package updates its own row instead of forking it - #21881
Conversation
…ermission set updates its own row The unit double now keys a stored row by its package as the repository does, and serves the row's binding on the single-item read. The door pin counts the active sys_metadata rows before and after each edit. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…set saves into its own row's package The write-through's update leg saved the merged body with no package, and a sys_metadata row is keyed by its package: for a set whose only row is bound to a writable runtime package, the save minted a second, package-less row. The leg now reads the edited row's binding from the metadata door's single-item read (the row's package_id, stated as _packageId) and passes it as packageId. A set with no stored row, or a package-less one, saves as before; a code-shipped set is still refused by the lock first. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
The row count is the contract; the save argument is the mechanism, so an ablation's first red names the second row. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 16 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 d56572b5d752e62d0cf42555cd905a0c143eeae8 && git checkout d56572b5d752e62d0cf42555cd905a0c143eeae8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5e0b489bcacecf2ab6e91e20ed856ca2452d9c2e 32048afa30bde2e06e43e214193848d573701a96 && git checkout -B drift-repro 5e0b489bcacecf2ab6e91e20ed856ca2452d9c2e && git merge --no-ff 32048afa30bde2e06e43e214193848d573701a96
node scripts/docs-audit/affected-docs.mjs --json 5e0b489bcacecf2ab6e91e20ed856ca2452d9c2e
|
Fixes #21861
Clause-②: no
What this changes
Setup saves a permission set through the data door (
PATCH /api/v1/data/sys_permission_set/:id). The security plugin's write-through (createPermissionSetWriteThrough,packages/plugins/plugin-security/src/permission-set-projection.ts) redirects that edit into the metadata store. Its update leg merged the patch into the stored body and calledsaveMetaItemwith nopackageId.A
sys_metadatarow is keyed(org, type, name, package_id), and a save that names no package targets the package-less row (SysMetadataRepository.put: an omittedpackageIdis the unbound row). So for a set saved into a writable runtime package (PUT /api/v1/meta/permission/:name?package=PKG), whose only stored row is bound to that package, the edit answered200and minted a second, package-less active row carrying the edit. The package-bound row stayed as it was.The update leg now reads the package binding of the row it edits and passes it as
packageId:overlaylayer of the layered envelope the leg already holds.effectiveBodyForRowtakes that layer as the base the patch merges into.protocol.getMetaItem({ type: 'permission', name }). That read serves the row through the same served-row resolution as the layered read (findServedOverlayRow, no package), and it states the row'spackage_idon the item as_packageId("surface the persisted software-package binding",protocol.ts). The binding is not taken from the patch, from the projected record, or from a registry item.overlaylayer means no stored row. Then there is nothing to fork, and the call is unchanged._packageId, so its save still names no package.assertPermissionSetNotPackageDeclaredruns in the pre-pass). The lock and its refusal do not move.One file of production code, one new non-exported helper. No
packages/specorpackages/metadata-protocoledit, no new error code, no new exported symbol, and no second classifier: every target reaching the save has already been classifiedorgbyclassifyPackagedPermissionSet, so the binding read only tells a package-boundorgset from a package-less one.The dispatch hypotheses, measured
origin/main88a39c09(this branch's base, which carries PR fix(plugin-security): the permission-set lock reads the row's provenance, so org-owned sets, clones and runtime-package sets edit again #21857), through the new door pin's own steps, the runtime-package set's first data-door edit answered200. Its active rows went from one, bound to the package, to two:{ package_id: null, description: "Edited at the data door" }and{ package_id: "com.dogfood.bind21861", description: undefined }. The update leg is thesaveMetaItemcall at:1374on this base. The:1297named in the dispatch is the insert leg's call.sys_permission_setrecord readsmanaged_by: "admin",package_id: null, so the projection does not carry the binding. The layered read'soverlaylayer has no_packageId, and its envelopepackageIdis null: the code layer is the projection echo.getMetaItem'sitem._packageIdiscom.dogfood.bind21861, both throughGET /api/v1/meta/permission/:nameand throughprotocol.getMetaItemin-process. That existing reader is the one used here.showcase_contributoris still refused with403 NOT_OVERRIDABLEwith its row count unchanged.Pins
packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts, showcase). Every case counts the activesys_metadatarows for the name under both type spellings and in every scope, before and after the edit:200, exactly one row before and after, still bound to the package and carrying the edit. Edited twice, before and after the list read (GET /meta/permission) that every Studio page load issues;200, one package-less row before and after, carrying the edit;403withcode: NOT_OVERRIDABLE, and the stored rows are unchanged.permission-set-projection.test.ts). The suite's existing protocol double now keys a stored row by its package, as the repository does, and serves the row's binding ongetMetaItem. No new engine double, so the engine-double ledger does not move. Four cases: package-bound, package-less, a filtered edit spanning one of each (each saved into its own row), and the code-shipped refusal (codeandstatusasserted, no save, the binding never read). Each case asserts the row count before and after.Ablation (committed fix first, then mutated, then restored)
At
32048afa30,scripts/ablation-replace.mjsreplaced the anchoritem: body, ...packageArg, ...actorArg });(1 hit, then 0) withitem: body, ...actorArg });(0 hits, then 1). This puts the package-less save back. Blobe77bd871became4186f983. The run was wrapped in atraprestore on EXIT, INT and TERM.pnpm turbo run build --filter=@objectstack/plugin-securityexited 1, at DTS only (TS6133: 'packageArg' is declared but its value is never read). The JS was already emitted, andnode scripts/ablation-dist-preflight.mjs @objectstack/plugin-security "item: body, ...actorArg }"found the marker in 2 built files (dist/index.js,dist/index.mjs). The ablation was live in the artifact the door pin consumes.expected [ …(2) ], the package-bound row plus{ package_id: null, description: "edited at the data door" }.{ package_id: "com.dogfood.bind21861", description: undefined }and{ package_id: null, description: "Edited at the data door" }. The package-less and code-shipped cases stayed green, as they should.e77bd871),git diff HEADempty,git status --porcelainempty across the whole tree. After the rebuild,--absentfound the marker in 0 of 6 built files. Unit 74 of 74 passed, door pin 4 of 4 passed.Tests (all at
32048afa30)pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 167 files, 3604 passed, 45 skipped.pnpm --filter @objectstack/plugin-security run typecheck, which includescheck:test-typecheckovertsconfig.test.json: pass.pnpm --filter @objectstack/dogfood run typecheck: pass.turbo run build --filter=@objectstack/dogfood^..., 63 tasks): dist preflight shows the fix's spelling present and the ablation marker absent.permission-set-projection.test.ts74 of 74 passed. The door pinspermission-set-write-through-package-bindingandpermission-set-lock-row-provenance(PR fix(plugin-security): the permission-set lock reads the row's provenance, so org-owned sets, clones and runtime-package sets edit again #21857's) passed 18 of 18.eslint --no-inline-config --format jsonover the three changed.tsfiles reported 3 files, 0 errors, 0 warnings. The population is the config's**/*.{ts,…}globs, and the fourth path is the.mdchangeset. The config never enables type-aware linting (noparserOptions.project), so the diff cannot move a verdict on an untouched file. The repo-widepnpm lintis left to CI.integration-tier tests are not relevant (nopackages/clipath).Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 70 commands at32048afa30. All 70 were run, and each exit code was recorded. 69 exited 0 on the first pass.check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET: 8 packages outside the dogfood closure had nodist/). Those packages were built (all cache hits), and the gate exited 0 on the rerun.--ranreports: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN. The derivation names 8 commands the dispatch-time list did not have, all changeset-related because this PR adds a changeset:check-adr-0087-registration(both),check-empty-changeset(both),release-rehearsal-clone --self-test,release-pending-publish --self-test,check:objectui-changesetandcheck:pm-changeset-deadline-census.Acceptance notes
deleteMetaItemwith no package matches the row in any package. So a restore would leave one package-less row: a lost binding, not two rows. That needs a different fix, since nothing holds the binding once the row is gone. The leg is also unreachable on a real engine, because no trash state exists (the leg's own comment). Carrier: none.system/cloud-scoped one) with no artifact. For such a row, the save would now meet the protocol's own read-only-base refusal, whichPUT /meta?package=meets too, instead of forking silently. No door mints such a row:saveMetaItem's D1 check andSysMetadataRepository.assertAllowedrefuse that binding at write time. HenceClause-②: no. The accepted set does not move, and the edit still answers200.origin/mainhas moved past88a39c09. The incoming commits include fix(metadata-protocol): a package-scoped list slot serves the package-less row getMetaItem naming the package serves #21871, ametadata-protocolchange to the package-scoped list merge. It does not touchgetMetaItem's single-item stamp orfindServedOverlayRow, which this fix reads. plugin-security: discard-overlay deletes the only stored row of a permission set saved into a writable runtime package — its eligibility reads "has a package id" as "package-declared", the defect #21789 fixes in the lock #21860 (PR fix(plugin-security): Discard Overlay refuses every permission set no code package ships, so a runtime-package set's only stored row is no longer deleted #21873) has not landed, so main was not merged, per the dispatch. CI's merge ref covers the joint state.content/docssentence describes a data-door edit forking a package-bound set, so none is made false. The olderpermission-sets.mdxdrift about overlays of packaged sets is the one PR fix(plugin-security): the permission-set lock reads the row's provenance, so org-owned sets, clones and runtime-package sets edit again #21857's review already noted. It is untouched.Generated by Claude Code