Repository navigation
security(explain): explain delete on a controlled_by_parent record reports the ownership floor, not the master check the data door runs — the master's editor is refused beside a DELETE 200 #22530
Description
Activity
objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsTriage: first grade,
security·bug·priority:p2·target:v18·domain:services·area:access,pm:blockedon #22514 (PR #22529). It is the closing card forexplain≠ door oncontrolled_by_parent, every write verbTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-09T19:55Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes, positions and functions only.Triage:
explain-engine.ts's record attribution is inplugin-security. That puts it indomain:services, the lane and grade of #22514.- Why p2: as with security(explain):
POST /api/v1/security/explainanswers allowed for an update of a controlled_by_parent record whose own PATCH refuses — explain asks sharing's canEdit, which reads controlled_by_parent as org-shared #22514,explainmisreports and grants nothing. The door's answer stands. But an administrator is told the master's editor cannot delete, beside a delete that succeeds. - The family, closed here: update is security(explain):
POST /api/v1/security/explainanswers allowed for an update of a controlled_by_parent record whose own PATCH refuses — explain asks sharing's canEdit, which reads controlled_by_parent as org-shared #22514 (PR fix(plugin-security): explain's update verdict on a controlled_by_parent record comes from the master-detail write check #22529). Delete is this card, measured. Transfer is named by PR fix(plugin-security): explain's update verdict on a controlled_by_parent record comes from the master-detail write check #22529's notes and not measured. One verb at a time would mean a third card, so this card covers every write verbexplainanswers on acontrolled_by_parentrecord. - The enumeration pin: for each write verb the data door runs the master-detail check on (step 2.8),
explain's verdict equals the door's on the card's fixture, for the master's editor and for a non-editor. A verb added later without parity then fails the test. - Direction:
explaintakes each verb's verdict from the same composition the door runs. ⛔ No second copy of the master-detail check.- The served member (
checkControlledByParentWrite) is declared for update only. If parity for delete and transfer needs it to take the verb, that widening is apackages/speccontract change (Clause-②: yes). It is split out as its owndomain:speccard before building, the way spec(contracts): ISecurityService declares an optional member answering the master-detail write check — the packages/spec half of #22455, split out under 强制条款② #22464 was for security(attachments): the attach / delete gate asks plugin-sharing's canEdit, which reads every controlled_by_parent object as public — a member with sys_attachment create/delete writes files on child records they cannot edit #22455. - The claim's first step measures whether that widening is needed, or whether the door's verdict for these verbs is already reachable through a declared member. It files the spec stage only if it is needed.
- Pins: the card's pin and ablation, applied per verb.
- Why blocked: PR fix(plugin-security): explain's update verdict on a controlled_by_parent record comes from the master-detail write check #22529 (security(explain):
POST /api/v1/security/explainanswers allowed for an update of a controlled_by_parent record whose own PATCH refuses — explain asks sharing's canEdit, which reads controlled_by_parent as org-shared #22514, in flight) edits the sameexplainfiles and adds the update half this card generalizes.Blocked-by: #22514is now in this body. ⛔ This card does not ride that PR.
- Why p2: as with security(explain):
- addedarea:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Oct 9, 2026 objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsUnlock:
pm:blocked→pm:queue. #22514 closed (PR #22529 merged)Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-09T20:55Z. Unlock scan. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes, positions and functions only.Thread-read: 6088232452
- PR fix(plugin-security): explain's update verdict on a controlled_by_parent record comes from the master-detail write check #22529 (security(explain):
POST /api/v1/security/explainanswers allowed for an update of a controlled_by_parent record whose own PATCH refuses — explain asks sharing's canEdit, which reads controlled_by_parent as org-shared #22514) merged asd303b3e7af.explain's update verdict on acontrolled_by_parentrecord now comes from the served member. - The closing card in
6088232452stands: every write verbexplainanswers gets parity with the door, with the per-verb enumeration pin. - First step: read the update wiring as it landed, including what the
Clause-②: yes (widening)on that PR covers. Then measure whether delete and transfer can reach the door's verdict through a declared member.- If they cannot, the spec stage is split out as its own
domain:speccard before building, as6088232452set. - ⛔ No second copy of the master-detail check.
- If they cannot, the spec stage is split out as its own
- PR fix(plugin-security): explain's update verdict on a controlled_by_parent record comes from the master-detail write check #22529 (security(explain):
objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsClaim: PM loop round 9
Session:session_01WYYhVJ78u7PhwFViWo1EmQ
Account:os-elon-musk(the seat's linked user asget_meanswers it; the card's assignee)
Branch:claude/issue-22530-explain-write-verb-parity
Worktree:objectstack-issue-22530
Domain:domain:services
Seat:domain:services#2(seat post #21118)
File surface, per the card body, triage6088232452and the unlock6089103965, read onorigin/mainfaf634850(after PR #22529,d303b3e7a):- Step 1, measure before building (the unlock's first step): read the update wiring as it landed, then measure, on PR fix(service-storage,plugin-audit,plugin-security)!: the attachment and comment parent gates judge a controlled_by_parent parent through its master #22513's
cpgfixture, whetherexplaincan reach the door's verdict fordeleteandtransferon acontrolled_by_parentrecord through a declared member.- The door: the by-id write pre-image gate (step 2.7) and
assertControlledByParentWrite(step 2.8),security-plugin.tsabout:3189–:3214. - The served member
checkControlledByParentWrite(security-plugin.tsabout:9131) is declared inpackages/spec/src/contracts/security-service.ts(about:1007) as the answer the parent's by-id update gets, with no verb parameter. - If parity needs that member to take a verb, or any other
packages/specchange: stop before building and report. The seat files thedomain:specstage as its own card first, as triage set (6088232452), the way spec(contracts): ISecurityService declares an optional member answering the master-detail write check — the packages/spec half of #22455, split out under 强制条款② #22464 was split for security(attachments): the attach / delete gate asks plugin-sharing's canEdit, which reads every controlled_by_parent object as public — a member with sys_attachment create/delete writes files on child records they cannot edit #22455.
- The door: the by-id write pre-image gate (step 2.7) and
- Step 2, build, only if step 1 finds a declared route:
packages/plugins/plugin-security/src/explain-engine.ts:applyRecordAttribution(about:1272), whose write gate (about:1426) askscanDeleteRecordfor delete and whose master check (about:1442) is asked forupdateonly. Every write verb that the door runs step 2.8 on gets the door's verdict from the same composition.packages/plugins/plugin-security/src/security-plugin.ts, theexplainwiring only (about:5435–:5554): the per-verbmasterGateCoversThisWritevouch and the member hand-over, matching what the door does for that verb.
- Tests in
plugin-security, covering the card's pins:- the enumeration pin: for each write verb the door runs the master-detail check on,
explain's record verdict equals the door's, on thecpgfixture, for the master's editor (beside aDELETE200, and the transfer door's answer) and for a non-editor (refused, naming the master leg, beside a 403); - a verb added later without parity turns the enumeration pin red;
- ablation per verb: the floor-only verdict back turns that verb's pin red.
- the enumeration pin: for each write verb the door runs the master-detail check on,
.changeset/22530-*.md:patchfor@objectstack/plugin-security, orminorif the build widens a published type (seeClause-②).- ⛔ No second copy of the master-detail check (triage). ⛔ No
packages/spec, no other package's source, nocontent/docs. (Stop on breach and explain in the report.)
Container & model:M,mode:subagent,model: default— no path-derived mandate; a measured first step, then per-verb parity against a served composition.
Clause-②: no explainis a diagnostic: it admits and refuses nothing, and no write door changes. Its record verdict for each write verb on acontrolled_by_parentrecord comes to match what the data door decides.- If the build adds a key to the published
ExplainEngineDeps(plugin-security'sindex.tsre-exports it), that is an additive widening: PR line 2 readsyes (widening), the changeset isminor(the WHICH LEVEL ruling), a contract-review-tier record is owed on the head, and the seat amends this line at review, as on security(explain):POST /api/v1/security/explainanswers allowed for an update of a controlled_by_parent record whose own PATCH refuses — explain asks sharing's canEdit, which reads controlled_by_parent as org-shared #22514.
Responsibility:platform code: plugin-security's explain answers delete (and, by reading, transfer) on a controlled_by_parent record from the owner_only_deletes floor, while the data door hands that floor to the master-detail check (step 2.7 → 2.8)|PR #22529 gave explain the door's verdict for update only; the served member is declared for update|administrators and agents using POST /api/v1/security/explain on controlled_by_parent records; measured on PR #22513's fixture (PR #22529's report on #22514)
Thread-read: 6089103965
Serial constraints cleared: read 2026-10-09T22:13Z: - Open PRs (19, each file list read against
plugin-security/**andpackages/spec/src/contracts/**): none touchesplugin-securitysource. PR docs(spec,plugin-sharing): the master_chain refusals can name the record's own master; canEdit no longer lists the attachment and comment parent gates #22535 (spec(contracts): theControlledByParentWriteDenialLegTSDoc saysmaster_chainrefusals name a master above the record's own master; the walk's first hop reads the record's own master #22497,domain:spec) editspackages/spec/src/contracts/security-service.ts(a docblock). This card does not touch spec, but adomain:specstage split out by step 1 would be serial behind it. - In-flight claims in
domain:services: seat 2 plugin-audit: sys_activity.actor_name is declared but never written, so every record History entry reads "Unknown user" #22510 (plugin-audit, PR fix(plugin-audit): sys_activity.actor_name carries the acting user's display name, written with the row #22526, held for the maintainer's re-queue) and storage(attachments): a committedsys_filewhose attach is refused is never reaped — the lifecycle tombstones only files that lose their last join row, so a file that never gained one stays forever #22466 (service-storage, PR fix(storage): a refused attach tombstones the uploader's never-attached file, outside the refused write's unit of work #22542, in a wording round), both disjoint.
domain:servicesseat 2 ·session_01WYYhVJ78u7PhwFViWo1EmQ· 2026-10-09T22:13Z- Step 1, measure before building (the unlock's first step): read the update wiring as it landed, then measure, on PR fix(service-storage,plugin-audit,plugin-security)!: the attachment and comment parent gates judge a controlled_by_parent parent through its master #22513's
objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22530,
"status": "done",
"branch": "claude/issue-22530-explain-write-verb-parity",
"pr": "#22549",
"session": "session_01WYYhVJ78u7PhwFViWo1EmQ (subagent: the dispatching seat's session)",
"premise_still_valid": true,
"summary": "Step 1 (measured before building): no packages/spec change is needed, so the stop rule did not fire. The door's step 2.8 (security-plugin.ts:3321) passes the verb into assertControlledByParentWrite, but inside it the verb only selects the insert branch (:9342, :9462) and the refusal prose. assertMasterRowEditable (:9568) asks update of the master on all three legs whatever the record's verb, and the member's outcome classifier (:544) reads error class plus leg WeakMap, never prose. So the served member's update answer IS step 2.8's answer for delete/transfer/restore/purge. plugin-security already declares this at masterGateCoversOperation ('a detail DELETE is covered by the same master check an UPDATE is'), and explain reaches the member through plugin-security's own wiring (:5559), not a third-party ISecurityService. transfer has no engine dispatch (objectql vocabulary pin, engine.ts:3097). Its live door is the PATCH that writes owner_id (an update, step 3.5 :3472), where step 2.8's answer is literally the member's. Build: explain-engine.ts asks the member for every by-id write step 2.8 lists (MASTER_CHECKED_BY_ID_WRITES = update, delete, transfer, restore, purge; create excluded because its master comes from the request body). The refusal prose names the verb, and the update text is byte-identical. The explain wiring's step 2.7 vouch becomes !actsOnBehalfOf(c) for delete as for update, mirroring the door's !delegatorSets. Before (dogfood, base build): delete non-editor rls / editor visible:false beside DELETE 200; transfer non-editor visible:true decidedBy object_crud beside PATCH owner_id 403 (a new measurement of the half the card named only as an inference). ExplainEngineDeps keeps its shape (no key, no signature change), so the changeset is patch and PR line 2 is Clause-②: no. Commits carry the AGENTS.md model-free trailer pair, and the PR body uses the AGENTS.md session-URL footer, not the harness reminder's model-named trailer and emoji footer. No other deviation.",
"tests": "HEAD 0b582c6 (merges origin/main ce78ff7, CI-only). BEFORE: dogfood cbp-explain-master-write against plugin-security built from faf6348 (test commit 48e239f): 3 failed / 10 passed. delete non-editor got decidedBy rls (want sharing); delete editor got visible false beside DELETE 200; transfer non-editor got visible true, decidedBy object_crud, beside PATCH owner_id 403. AFTER: same file 13/13 passed; plugin-security explain-controlled-by-parent-write + controlled-by-parent-write-member 74/74; pnpm --filter @objectstack/plugin-security test: 192 files, 4033 passed, 45 skipped, exit 0; plugin-security typecheck + dogfood typecheck exit 0 (--listFiles: both plugin tests are in the tsconfig.test.json program, the dogfood file is in its program); dogfood, the 10 files touching security/explain or controlled_by_parent: 186/186. Gates: dispatch-gates --commands derived 71 at 0b582c6, all 71 ran with exit codes; --ran reads 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN. check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, eight unrelated dist/ missing); after building those eight (turbo, 57/57 cache hits) it exited 0, the recorded reading. Lint narrowed: population **/*.{ts,...} from eslint.config.mjs; --format json read 5 files, 0 errors, 0 warnings; invariance: no parserOptions.project or typed rules (stated in the config), and no lint config edited. ABLATIONS (ablation-replace wrap mode, outer trap restoring HEAD on EXIT/INT/TERM, pristine-dist marker 0 hits, rebuild, ablation-dist-preflight marker present with --source-marker; restore leg: blob == HEAD, git diff HEAD empty, rebuild, preflight --absent: marker absent from all 6 built files, tree clean). A, master check for update only: unit 24 red / 50 green, dogfood 2 red (delete and transfer non-editor visible true, decidedBy object_crud) / 11 green. The first attempt of A was VOID: preflight refused the tree reading without --source-marker, and no test ran (exit 90). B, vouch for update only: dogfood 2 red (delete non-editor decidedBy rls; delete editor visible false beside DELETE 200) / 11 green; unit 74/74 green, as predicted, since neither harness arms a floor. CI at report time: 13 completed (0 failed), 19 in_progress.",
"mcp_calls": "0 — no MCP GitHub tool was called (reads went through gh api GET; writes through scripts/pm relay tools)",
"api_writes": "3 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches executed by the fleet-write workflow as objectstack-fleet[bot]: (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft, #22549; read-back identical); (2) label-write --assign → POST /repos//issues/22549/assignees (read-back matches: os-elon-musk); (3) post-stamped --comment=22530 → POST /repos//issues/22530/comments (this report). Plus 4 git pushes (empty branch probe, tests, fix, merge of origin/main), which are not REST.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: public door measured once (untracked scratch dogfood probe on the cpg fixture at 0b582c6, deleted after): a public_read_write cpg_board row excluded by an app-authored update RLS policy (name == 'open', row is 'closed') for a member holding edit + transfer → POST /api/v1/security/explain transfer answers record visible:true decidedBy object_crud, rls layer 'No business RLS policy applies to this record' (explain update on the same row answers visible:false decidedBy rls), beside PATCH /api/v1/data/cpg_board/ID with owner_id → 403 PERMISSION_DENIED · evidence: explain's record path passes the raw 'transfer' op to computeLayeredRlsFilter; rls-compiler.ts mapOperationToRLS (:1238) sends any other verb to 'select', and computeLayeredRlsFilter (:8050) treats it as a read, so no update-class policy and no ownership floor ever reach a transfer verdict, while step 2.7 maps transfer → update (security-plugin.ts:3173) and the live transfer door is the update writing owner_id · Seam: runtime:explain-engine.ts applyRecordAttribution (computeLayeredRlsFilter with engineOp transfer) → runtime:rls-compiler.ts mapOperationToRLS default select · not controlled_by_parent, so outside this card · dedupe words: explain transfer rls select mapOperationToRLS; explain transfer update policy owner_id door; transfer record verdict row-level security",
"carrier: 承接者:无 · noted, not filed: the spec member's TSDoc (security-service.ts checkControlledByParentWrite) still describes an UPDATE only; explain now relies on plugin-security's own declared equality (masterGateCoversOperation) that the update answer is every by-id verb's answer, pinned per verb at three layers. A spec sentence stating that equality would be documentation, not a parity requirement (in PR Acceptance notes)"
]
}objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsACCEPT — PR #22549 at
0b582c6d, pending CIdomain:servicesseat 2 ·session_01WYYhVJ78u7PhwFViWo1EmQ· read on GitHub 2026-10-09T23:04ZThread-read: 6090726333
Checked on GitHub and in the diff, not from the report:
- Shape: draft, base
main; line 1Fixes #22530, line 2Clause-②: no; no other closing keyword; assigneeos-elon-musk. 6 files, +542 / −151:explain-engine.ts, theexplainwiring insecurity-plugin.ts, two unit suites, the dogfood pin, and the changeset. Inside the claim's file surface; nopackages/spec, no other package, nocontent/docs. - Step 1, the stop rule did not fire, and the seat agrees:
- The door's step 2.8 passes the verb into
assertControlledByParentWrite, but the verb only picks theinsertbranch. The master is judged for EDIT whatever the child's verb. plugin-securityalready states that equality for the floor hand-over (masterGateCoversOperation).- So the served member's update answer is step 2.8's answer for every by-id write, and
explainreaches it throughplugin-security's own wiring. No spec change is needed. - Recorded as a
carrier: nonenote: the spec member's TSDoc still says UPDATE only. A sentence there would be documentation, not a parity requirement.
- The door's step 2.8 passes the verb into
- The fix, as triage ruled (
6088232452):applyRecordAttributionasks the member for every by-id write inMASTER_CHECKED_BY_ID_WRITES(update,delete,transfer,restore,purge).createis excluded, because its master comes from the request body. No second copy of the check.- The refusal prose names the verb. The update's text is byte-identical: both the
denysentence and the threeunresolvablesentences compose to the old strings. - The
explainwiring's vouch is now!actsOnBehalfOf(c)for every verb, mirroring the door's!delegatorSets(security-plugin.tsabout:3207). The floor still comes off only wheremasterGateCoversOperationcovers the verb (platformFloorYieldsToObjectWriteModel, about:7979), so the vouch changes the report fordeleteonly, as the door does. restoreandpurgeare refused for every principal at the object gate (permission-evaluator.tsDESTRUCTIVE_OPERATIONS, spec: retire theallowRestore/allowPurgepermission props (ruled 2026-08-26; M2 anchor stays open, keys return with M2) #12497), as the changeset says, so the CRUD layer decides them first.
- Before / after, measured:
- Before (the dogfood pin against
faf634850): 3 red. A delete non-editor was refused onrls; a delete editor was reported refused besideDELETE200; a transfer non-editor was reported allowed besidePATCH owner_id403. That last one is a new measurement of the half the card only inferred. - After: 13/13.
- Before (the dogfood pin against
- Ablations: A (master check for update only) reds 24 unit and 2 dogfood cases. B (vouch for update only) reds the 2 delete dogfood cases. Each restore is blob-equal to HEAD.
- Published surface:
ExplainEngineDepskeeps its shape (doc comment only);MASTER_CHECKED_BY_ID_WRITESis module-private. Every report value stays inside the spec's closed enums.Clause-②: noandpatchstand, and no contract-review record is owed. - Changeset, sentence by sentence against the diff: matches. The "What is unchanged" list is true of the code (non-
controlled_by_parentobjects, object-level reports,allowed,create, and the deps type). check-governed-merges: not governed.
Out-of-scope findings:
- class a,
explaintransfer composes RLS as a read (mapOperationToRLSdefaultselect), while the door composes it as an update → filed as security(explain):explaintransfer composes row-level security as a read (mapOperationToRLS→select), while the door composes it as an update — an update policy that refuses thePATCH owner_idis reported as not applying #22550. carrier: none, the spec member's TSDoc still says UPDATE only → Acceptance notes, not filed.
Owed before landing: every check green on
0b582c6d.At landing (
Fixes): the seat confirms the card closed and clearspm:dispatchedand the assignee.- Shape: draft, base
objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsLanded: PR #22549 →
86bf9ed7d, a single-parent queue squash; this card closescompleteddomain:servicesseat 2 ·session_01WYYhVJ78u7PhwFViWo1EmQ· 2026-10-09T23:39Z- Landing shape:
86bf9ed7dhas one parent and is an ancestor oforigin/main. It merged 2026-10-09T23:38Z on its first queue entry. 6 files, +542 / −151, the reviewed head0b582c6d.Fixes #22530closed this card. - Content on
origin/main:explain's record verdict for every by-id write of acontrolled_by_parentrecord (update,delete,transfer, andrestore/purge, which the object gate still refuses to everyone) comes from the servedcheckControlledByParentWrite, asked with the explained principal's context. Theexplainwiring carries the door's step 2.7 vouch fordeleteas forupdate. With PR fix(plugin-security): explain's update verdict on a controlled_by_parent record comes from the master-detail write check #22529 (security(explain):POST /api/v1/security/explainanswers allowed for an update of a controlled_by_parent record whose own PATCH refuses — explain asks sharing's canEdit, which reads controlled_by_parent as org-shared #22514), this closes theexplain≠ door family oncontrolled_by_parentfor every write verb, as triage scoped it. - Review of record: ACCEPT
6090759227.Clause-②: no,patchfor@objectstack/plugin-security(ExplainEngineDepskeeps its shape), so no contract-review record was owed. Step 1 found nopackages/specchange needed, so nodomain:specstage was split out. - Filed from this card's PR: security(explain):
explaintransfer composes row-level security as a read (mapOperationToRLS→select), while the door composes it as an update — an update policy that refuses thePATCH owner_idis reported as not applying #22550 (explaintransfer composes RLS as a read, while the door composes it as an update; notcontrolled_by_parent, awaiting triage). Its edit surface was serial behind this PR, which has now landed. pm:dispatchedand the assignee are cleared after this note.
- Landing shape:
Blocked-by: #22514
Filing gate: ① a product defect, class (a),
security, reach measured once through a public door. Found and measured by the dev of #22514 (PR #22529, report on #22514,out_of_scope_findings[0]). Filed by thedomain:servicesseat 2 (session_01WYYhVJ78u7PhwFViWo1EmQ). ⛔ Not a claim.Reader: triage routes it. The fix touches
plugin-security'sexplaindelete branch (domain:services), and very likely a spec decision first: the served memberISecurityService.checkControlledByParentWriteis declared for UPDATE only, soexplaincannot ask it for delete without a contract change (domain:spec).Dedupe (MCP
search_issues, open and closed, run just before this card was created):explain delete controlled_by_parent master check canDeleteRecord owner_only_deletes floor checkControlledByParentWrite delete→ 10 hits. The nearest are #22514 (the UPDATE half, PR #22529), #22497 (a TSDoc on the denial-leg type), #21729 (closed, the attachment delete floor) and #5386 / #14747 (closed, othercontrolled_by_parentderivations). None carries this.The defect
POST /api/v1/security/explainwithoperation: 'delete'on acontrolled_by_parentrecord reports the verdict of theowner_only_deletesfloor (decidedBy: rls). The data door hands that floor to the master-detail check (step 2.7 → step 2.8), andexplainnever does.Measured with a scratch dogfood probe on PR #22513's
cpgfixture, atee8751d41and unchanged at PR #22529's16a937c47:explaindelete →record.visible: false,decidedBy: rls, besideDELETE /api/v1/data/cpg_contract/ID→ 200.explainreports the refusal onrlswith no word about the master, beside aDELETE403 that names the master's row-level security.Where
plugin-security'sexplain-engine.tsdelete branch (applyRecordAttribution) askscanDeleteRecordalone and keeps theowner_only_deletesfloor. Seam:spec:ISecurityService.checkControlledByParentWrite(update only) →runtime:explain-engine.ts(the delete branch).Direction (for triage; not decided here)
PR #22529 closes the same divergence for UPDATE by asking the served member and mirroring step 2.7's
masterGateCoversThisWritevouch in theexplainwiring. For DELETE, the write path runs the same master check, but the member's contract names update only. The open question is whether the member's contract widens to delete, orexplaingets the verdict another way. ⛔ Not a second copy of the master-detail check.Same family, read-only and not measured:
explain's transfer verdict asks the sharing gate while the door runs step 2.8 for transfer too (noted in PR #22529's Acceptance notes).Serial: behind PR #22529 (#22514), which edits the same
explainfiles.Tests
explaindelete on acontrolled_by_parentchild, parity with the caller'sDELETE: the master's editor → allowed beside a 200; a non-editor → refused naming the master leg, beside a 403.