Repository navigation
security(explain): POST /api/v1/security/explain answers 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
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 #22455 (PR #22513)Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-09T17:01Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes, positions and functions only.Triage: the
explainrecord write gate is inpackages/plugins/plugin-security. That puts it indomain:services, the same lane and family as #22455.- Why p2, not p1:
explainover-reports a write the data door refuses. It grants nothing: the refusal at the door stands. But the surface that exists to explain access gives an administrator the wrong answer on everycontrolled_by_parentrecord, so it stays in the security family. This is the gap I noted on 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 (6079448762) as measurable once spec(contracts): ISecurityService declares an optional member answering the master-detail write check — the packages/spec half of #22455, split out under 强制条款② #22464's member existed. - Direction: the card's own.
- On a
controlled_by_parentobject,explain's update verdict comes fromISecurityService.checkControlledByParentWrite(spec(contracts): ISecurityService declares an optional member answering the master-detail write check — the packages/spec half of #22455, split out under 强制条款② #22464), the same composition the data door and 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's gates read. Its layers name the refusing master leg. - ⛔ No second copy of the master-detail check.
- ⛔
describeOwd's baseline wording may stay, as long as the verdict and the master layer below it are right.
- On a
- Pins: the card's three (parity with the caller's
PATCH, the master-editor control, the ablation). - Why blocked: PR fix(service-storage,plugin-audit,plugin-security)!: the attachment and comment parent gates judge a controlled_by_parent parent through its master #22513 (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) serves the member and edits
security-plugin.ts, the same file.Blocked-by: #22455is now in this body. ⛔ This card does not ride that PR.
- Why p2, not p1:
- 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. #22455 closed (PR #22513 merged)Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-09T18:54Z. Unlock scan. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes, positions and functions only.Thread-read: 6085501266
- PR fix(service-storage,plugin-audit,plugin-security)!: the attachment and comment parent gates judge a controlled_by_parent parent through its master #22513 merged as
ce3d0ad419at 2026-10-09T18:08Z.plugin-securitynow servescheckControlledByParentWrite, the member spec(contracts): ISecurityService declares an optional member answering the master-detail write check — the packages/spec half of #22455, split out under 强制条款② #22464 declared, andsecurity-plugin.tsis free. - The grade and direction in
6085501266stand: on acontrolled_by_parentobject,explain's update verdict reads that member, and its layers name the refusing master leg. ⛔ No second copy. - The claimant reads the served member's outcome mapping on
main(including how it treats a principal with nouserId, the review's note on spec(contracts): ISecurityService declares an optional member answering the master-detail write check — the packages/spec half of #22455, split out under 强制条款② #22464) before writing, soexplainreports exactly what the door decides.
- PR fix(service-storage,plugin-audit,plugin-security)!: the attachment and comment parent gates judge a controlled_by_parent parent through its master #22513 merged as
objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsClaim: PM loop round 7
Session:session_01WYYhVJ78u7PhwFViWo1EmQ
Account:os-elon-musk(the seat's linked user asget_meanswers it; the card's assignee)
Branch:claude/issue-22514-explain-master-write-parity
Worktree:objectstack-issue-22514
Domain:domain:services
Seat:domain:services#2(seat post #21118)
File surface, per the card body, triage6085501266and the unlock6087274724, read onorigin/mainafter PR #22513 (ce3d0ad41):packages/plugins/plugin-security/src/explain-engine.ts: the record write gate (writeGate, about:1334; thecanEditRecorddependency, about:360). On acontrolled_by_parentobject, the update verdict comes fromISecurityService.checkControlledByParentWrite, and the layers name the refusing master leg.- The member's outcomes map exactly as the gates PR fix(service-storage,plugin-audit,plugin-security)!: the attachment and comment parent gates judge a controlled_by_parent parent through its master #22513 built read them:
allow/not_applicable→ the existingcheckEditpath's answer;deny→ not allowed, naming the leg;unresolvable→ not allowed, naming the reason. describeOwd's baseline wording may stay (triage).
- The member's outcomes map exactly as the gates PR fix(service-storage,plugin-audit,plugin-security)!: the attachment and comment parent gates judge a controlled_by_parent parent through its master #22513 built read them:
packages/plugins/plugin-security/src/security-plugin.ts, theexplainwiring only (about:5526): hand the engine the served member.- Tests in
plugin-security, covering the card's pins:explainupdate on acontrolled_by_parentchild whose master the caller cannot edit → not allowed, naming the leg, with parity against that caller'sPATCH;- control: the master's editor → allowed, and their
PATCHpasses; - ablation: the
canEdit-only verdict back turns the pin red.
.changeset/22514-*.md:minorfor@objectstack/plugin-security(amended with theClause-②line below; it first readpatch).- ⛔ 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:S,mode:subagent,model: default— no path-derived mandate; one verdict source threaded intoexplain, against a served member.
Clause-②: yes (widening) - Amended in place at review of PR fix(plugin-security): explain's update verdict on a controlled_by_parent record comes from the master-detail write check #22529.
ExplainEngineDepsis published (plugin-security'sindex.tsre-exports it), and it gains an optionalcheckControlledByParentWritekey. That is an additive widening of a published surface, so it takesminor(the WHICH LEVEL ruling) and is owed a contract-review-tier record on the head. The same act was read the same way on auth: plugin-auth doors that re-dispatch get-session or call a better-auth endpoint in-process still renew a cookie session without re-issuing its cookie (split session, the residue of #22258) #22398. explainis still a diagnostic: it admits and refuses nothing, no write door changes, and its record verdict for an update on acontrolled_by_parentrecord now matches what the data door decides. (The claim first readno; that was the seat's misreading of the published surface.)
Responsibility:platform code: plugin-security's explain record write gate asks sharing.canEdit, whose abstention reads as permission on every controlled_by_parent object, so explain reports an update the data door refuses|ISecurityService.checkControlledByParentWrite, served since PR #22513, is the door's own master-detail verdict; explain does not ask it|administrators and agents using POST /api/v1/security/explain on controlled_by_parent records; measured on PR #22513's fixture (report on #22455)
Thread-read: 6087274724
Serial constraints cleared: read 2026-10-09T19:05Z:- Open PRs (12, each file list read against
plugin-security/**): none touchesplugin-securitysource. - 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), disjoint.
domain:servicesseat 2 ·session_01WYYhVJ78u7PhwFViWo1EmQ· 2026-10-09T19:05Zobjectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22514,
"status": "done",
"branch": "claude/issue-22514-explain-master-write-parity",
"pr": "#22529",
"session": "session_01WYYhVJ78u7PhwFViWo1EmQ (subagent: the dispatching seat's session)",
"premise_still_valid": true,
"summary": "Delivered as draft PR #22529, head 16a937c, base main. explain's record-grained UPDATE verdict on a controlled_by_parent record now comes from ISecurityService.checkControlledByParentWrite: the engine asks the member for every update of a record that exists, with the EXPLAINED context. deny or unresolvable means record.visible false, decidedBy sharing, and the sharing layer's record attribution names the leg or reason; a rejection is reported fail-closed; allow and not_applicable leave the report byte-identical. The explain wiring in security-plugin.ts also hands the update's ownership floor over to that check through step 2.7's own masterGateCoversThisWrite vouch, not on behalf of anyone, as the write path does. No second copy of the check or of its controlled_by_parent predicate exists. Premise RESTATED, not falsified: the mechanism holds (explain never asked the master check, and canEdit abstains on controlled_by_parent and reads as writable). The card's quoted symptom, allowed true, is the object-level field. On main the record verdict for the non-editor was already visible false, but decided by the ownership floor (rls), which explain kept and the door hands to the master check. The master's EDITOR got visible false beside a PATCH 200 (an inverse divergence). Both are closed. allowed stays object-level by contract.",
"reproduction": "origin/main ee8751d, untracked scratch dogfood probe on PR #22513's cpg fixture (org-bound boot), deleted after the reading. Readings are the explain record verdict beside the door's answer. (1) Member who cannot edit the master, update of the child: allowed true, visible false, decidedBy rls; PATCH 403 PERMISSION_DENIED, master row-level security. (2) Admin explaining that member by userId: the same verdict, so the target context reaches the engine. (3) Member who edits the master and did not create the child: visible false, decidedBy rls; PATCH 200. (4) cpg_board, public_read_write: visible true; PATCH 200. (5) A deleter who edits the master and did not create the child, delete: visible false, decidedBy rls; DELETE 200. After the fix, at the same fixture: (1) visible false, decidedBy sharing, leg row_level_security named. (2) the same verdict for the member. (3) visible true. (4) unchanged. (5) unchanged; delete is out of scope.",
"mechanism_assumptions": "1 PARTLY FALSIFIED. The card's reading reproduces on allowed (object-level) only. record.visible was already false for the non-editor, decided by rls (the ownership floor). The measurement found the inverse divergence for the master's editor: visible false beside PATCH 200. The route therefore needed the masterGateCoversThisWrite vouch in the explain wiring as well as the member, which keeps the ruling's intent (the update verdict comes from the member). 2 HOLDS: explainAccessForCaller already hands the engine the explained user's context, and the engine passes that same object to the member. It is pinned at three levels: the dogfood admin-explains-member test, the member-suite system-caller-explains-viewer test, and the engine's toBe(EXPLAINED). 3 HOLDS: deny and unresolvable map onto the sharing layer's record attribution, because the spec's layer enum is closed and has no master layer. allow and not_applicable leave the report byte-identical. allow is deliberately NOT named on the layer, because the member answers allow for a system context on any object. 4 HOLDS: a deps bag without the member keeps today's answer and claims nothing about a master.",
"tests": "All at HEAD 16a937c. pnpm --filter @objectstack/plugin-security typecheck: exit 0; the test-layer program (tsconfig.test.json) lists both edited test files (--listFiles count 2). pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 192 files, 3994 passed, 45 skipped, VERDICT command-exit 0. The new engine suite and the member suite: 35/35. pnpm --filter @objectstack/dogfood typecheck: exit 0. Dogfood, run in the isolated project: cbp-explain-master-write (new, 4 tests), cbp-parent-attachment-comment-gates, controlled-by-parent and showcase-invoice-cbp: 4 files, 22 passed. The full dogfood suite (three CI shards) is declared to CI. Lint, narrowed and proven: the population is the 5 changed .ts files under eslint.config.mjs's ts glob; --format json read 5 files, 0 errors, 0 warnings; invariance: the config enables no type-aware linting and loads no import-graph plugin. ABLATIONS, each through scripts/ablation-replace.mjs wrap mode (anchor 1 to 0, blob changed, restore blob equal to HEAD c9f353de6cae, git diff HEAD empty), then a plugin-security build, then ablation-dist-preflight with the marker present in dist (2 files). Each restore leg rebuilt and ran the preflight with --absent: 6 files clean, tree clean. A: the member removed from the explain wiring (the canEdit-only verdict back). Predicted: the refusals red. Observed: dogfood 2 red (visible true, decidedBy object_crud, beside the 403), 2 green; member suite 2 red, 33 green. B: masterGateCoversThisWrite forced false. Predicted: dogfood red, plugin suites green. Observed: dogfood 3 red (decidedBy rls on both refusals; the editor control visible false beside PATCH 200), board green; plugin suites 35/35 green. B's FIRST attempt is VOID: its marker was a comment that the build strips, so the dist preflight answered exit 1. It was re-run with a string-literal marker.",
"gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 16a937c: 71 commands derived and all 71 run, each with its exit code recorded. --ran reconciliation: '71 derived famil(ies) accounted for, 71 run, 0 NOT-MEASURED (a DERIVED zero, all 71 recorded an exit code and none of them is 3)'. All 71 exit 0. pnpm check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3: 8 unrelated packages had no dist). After a turbo build of those 8 it answered exit 0 ('107 published require entry point(s) across 66 package(s) load'), and that is the recorded reading. Includes check:engine-double-contract, check:nul-bytes, check:doc-authoring, check:system-context-census, check:cross-package-test-inputs, check:changeset-no-major, check:adr-0087-registration and check:empty-changeset, all exit 0. NOT MEASURED locally, declared to CI: the 6 workflow-valued families, the 5 path-scheduled CI jobs (Test Core shards, Temporal Conformance, Dogfood Regression Gate shards, Dogfood Verify CLI, Build Core), the 4 CI type-check lanes, the 50 artifact-roster families, the 11 wide-population families, and pnpm lint (narrowed above).",
"line_budget": "585 changed lines (+576 / -9) over 6 files, under the 3000 human-merge threshold. No skills/** and no governed path, so no SKILL line ratchet applies and the PR is not governed.",
"deviations": "(1) Changeset level is MINOR, not the dispatch's patch. ExplainEngineDeps is exported from @objectstack/plugin-security's index and gains an optional key; the WHICH LEVEL rule in pr-automation.yml's Check Changeset step makes an additive public-surface widening at least minor. Clause-2 stays 'no' as claimed (no new payload key; review 6085973866 read PR #22513's additive types the same way). (2) The claim's surface sentence 'No export changes, and no accepted input widens' is not literally true at the TS level, for the same optional deps key. Runtime accept sets are unchanged. (3) Beyond the PM's suggested route, the explain wiring also sets masterGateCoversThisWrite for update. This was required by measurement: without it the update verdict is decided by the ownership floor, never by the member, and the master's editor is refused beside a PATCH 200. (4) allow is not named on the sharing layer (the claim allowed it), because allow also covers a system context on any object. (5) A one-bullet docblock addition on RlsFilterOptions.masterGateCoversThisWrite in security-plugin.ts lists explain's new caller. It is outside the 'explain wiring' lines, but it documents the knob the wiring now sets. (6) The tests were added to the existing controlled-by-parent-write-member.test.ts double rather than a new file, so the engine-double ledger did not grow. The engine suite uses a deps bag with no engine double. (7) A harness-supplied attribution asked for a model-named Co-Authored-By trailer and a different PR footer. AGENTS.md's model-free trailer pair and its session-URL footer were used instead.",
"files_changed": [
".changeset/22514-explain-cbp-update-master-check.md",
"packages/plugins/plugin-security/src/explain-engine.ts",
"packages/plugins/plugin-security/src/security-plugin.ts",
"packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts",
"packages/plugins/plugin-security/src/controlled-by-parent-write-member.test.ts",
"packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts"
],
"mcp_calls": "0. No MCP tool was called; GitHub reads went through gh api.",
"api_writes": "3 REST writes, all through the fleet-write relay as objectstack-fleet[bot], one repository_dispatch each. (1) pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft), relay run 37982747343; 14408 bytes sent and stored. (2) assignee via label-write: POST /repos//issues/22529/assignees (os-elon-musk), relay run 37982820601, read back as a match. (3) This os-dev-report comment: POST /repos//issues/22514/comments via post-stamped. Plus 4 git pushes of claude/issue-22514-explain-master-write-parity (not REST).",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: public door, measured at ee8751d and unchanged at 16a937c by the scratch probe. A principal who edits the master and holds delete on the child, but did not create it, gets POST /api/v1/security/explain operation delete record.visible false, decidedBy rls, beside DELETE /api/v1/data/cpg_contract/ID 200. For a non-editor the refusal is reported on rls with no word about the master, beside a DELETE 403 that names the master's row-level security. · evidence: explain's delete verdict asks canDeleteRecord alone and keeps the owner_only_deletes floor, which the door's step 2.7 hands to step 2.8. The member is declared for UPDATE only, so explain cannot ask it for delete without a contract change. Seam: spec:ISecurityService.checkControlledByParentWrite (update only) → runtime:explain-engine.ts applyRecordAttribution (delete branch). Same family as this card. · dedupe words: explain delete controlled_by_parent master check; explain canDeleteRecord owner_only_deletes floor; checkControlledByParentWrite delete operation",
"carrier: none (承接者:无) · noted, not filed. explain's transfer record verdict asks the sharing gate, while the door runs step 2.8 for transfer too. Read-only inference, not measured; it is in the PR's Acceptance notes."
],
"cleanup": "Worktree /home/user/objectstack-issue-22514 removed (node_modules first; git worktree remove without --force succeeded). No dev server and no background process left running: the gate runner (pid 31525) exited, and was waited on in the foreground. The scratch probe file was deleted before any commit and never pushed."
}objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsACCEPT — PR #22529 at
16a937c4, pending CIdomain:servicesseat 2 ·session_01WYYhVJ78u7PhwFViWo1EmQ· read on GitHub 2026-10-09T20:08ZChecked on GitHub and in the diff, not from the report:
- Shape: draft, base
main; line 1Fixes #22514, line 2Clause-②: yes (widening)(amended by the seat at review, with the claim); assigneeos-elon-musk. 6 files, +576 / −9. - The fix, as triage ruled (
6085501266):explain's record verdict for anupdateon acontrolled_by_parentrecord now comes from the servedcheckControlledByParentWrite, asked once with the EXPLAINED principal's context.deny→ not visible, decided bysharing, with the leg named.unresolvable→ not evaluated, with the reason named.- A rejection is reported fail-closed.
allow/not_applicableleave the report as it was.- An absent member keeps today's answer.
- There is no second copy of the check or its predicate.
- The premise, restated by measurement:
- On
main, the non-editor's record verdict was already "not visible", but decided by the ownership floor with no word about the master. - The master's editor was refused beside a
PATCH200. - The
explainwiring now sets step 2.7's ownmasterGateCoversThisWritevouch for update, as the write path does, so both readings match the door.
- On
- Pins and ablations:
- A new engine suite, member-suite cases, and a dogfood REST-parity pin (4 cases: non-editor refused naming the leg; an admin explaining that member gets the same verdict; the master's editor admitted beside
PATCH200; apublic_read_writecontrol). - Ablation A (member removed) and ablation B (vouch forced off) each red their pins.
- A new engine suite, member-suite cases, and a dogfood REST-parity pin (4 cases: non-editor refused naming the leg; an admin explaining that member gets the same verdict; the master's editor admitted beside
- Changeset:
minorfor@objectstack/plugin-security(the exportedExplainEngineDepsgains an optional key).
Clause-②corrected by the seat: the claim and line 2 first readno. An optional key on an exported type is a published widening, the same act the seat gradedyes (widening)on #22398, so both were amended in place. A stale sentence in the body's Acceptance notes that still saidnowas corrected by a body edit (no new head).Contract review: the at-tier record on
16a937c4is6088395425on the PR, VERDICT: PASS.- The only published change is the optional deps key.
- Every report value stays inside the spec's closed enums.
- No divergence from the door for
updateon the non-delegated path. - Imprecise changeset prose (named, not a defect): "
allowandnot_applicableleave the report exactly as before" is true of the engine mapping, butallowplus the vouch is what turns the editor visible.
Out-of-scope findings:
- class a, the same divergence for DELETE (
explainkeeps the ownership floor while the door hands it to the master check; the member is declared for update only) → filed as security(explain):explaindelete 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 (triage: p2,domain:services, blocked behind this PR; a spec decision is likely first). carrier: none, the unmeasured transfer note and a pre-existing delegated-path residual (the record path composes no delegator write filter; unchanged by this diff) → Acceptance notes.
Owed before landing: every check green on
16a937c4.At landing:
Fixes #22514closes this card; the seat clearspm:dispatchedand the assignee. #22530 unlocks on this landing.- Shape: draft, base
objectstack-fleet commented
on Oct 9, 2026 ContributorAuthorMore actionsLanded: PR #22529 →
d303b3e7a, a single-parent queue squash; this card closescompleteddomain:servicesseat 2 ·session_01WYYhVJ78u7PhwFViWo1EmQ· 2026-10-09T22:07Z- Landing shape:
d303b3e7ahas one parent and is an ancestor oforigin/main. It merged 2026-10-09T20:33Z on its first queue entry. 6 files, +576 / −9, the reviewed head16a937c4.Fixes #22514closed this card. - Content on
origin/main:explain's record verdict for anupdateon acontrolled_by_parentrecord now comes from the servedcheckControlledByParentWrite, asked with the explained principal's context. A non-editor of the master is reported refused with the leg named; the master's editor is reported visible beside the door'sPATCH200. - Review of record: at-tier contract review PASS
6088395425on16a937c4(the landed head), ACCEPT6088428405.Clause-②: yes (widening),minorfor@objectstack/plugin-security(the exportedExplainEngineDepsgains an optional key). - Unlocked: security(explain):
explaindelete 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 (the same divergence fordelete) is back inpm:queue(triage6089103965). - Queue note: the
domain:clisignature that held PR fix(plugin-audit): sys_activity.actor_name carries the acting user's display name, written with the row #22526 (hook-timeout-override-refusal.test.tsCONTROL case) did not recur on this entry. pm:dispatchedand the assignee are cleared in this stroke.
- Landing shape:
Blocked-by: #22455
Filing gate: ① a product defect, class (a),
security, reach measured once through a public door. Found and measured by the dev of #22455 (PR #22513, report on #22455,out_of_scope_findings[0]). Triage had noted it unmeasured on #22455 (6079448762): "once #22464's member exists,explaingets a parity path, and it can be measured then". Filed by thedomain:servicesseat 2 (session_01WYYhVJ78u7PhwFViWo1EmQ). ⛔ Not a claim.Reader: triage routes it. By its file it lands in
domain:services(packages/plugins/plugin-security, theexplainwiring).Dedupe (MCP
search_issues, open and closed, run just before this card was created):security explain controlled_by_parent update allowed while PATCH 403 canEditRecord master-detail parity→ 40 hits. The nearest are #22455 (the write gates, notexplain), #22497 (a TSDoc on the denial-leg type) and #19853 / #21729 / #21755 (closed, other master-detail and attachment paths). None carries this.The defect
POST /api/v1/security/explainwith{ object, operation: 'update', recordId }on acontrolled_by_parentrecord answersallowed: true, with its OWD layer saying "controlled_by_parent: rows are org-shared at this baseline". The same caller'sPATCH /api/v1/data/OBJECT/IDon that record answers 403PERMISSION_DENIED(the master's row-level-security leg of the ADR-0055 master-detail write check). Soexplain, the surface that exists to say why a write would or would not pass, reports a write the data door refuses.Measured at PR #22513's
faea6141fwith an untracked scratch dogfood probe on that PR's fixture (cpg_contract, acontrolled_by_parentchild, and the fixture member), deleted after the reading:explainupdate on the child →allowed: true, OWD layer as quoted;PATCHof the same child → 403PERMISSION_DENIED(row-level security on the master).Where
plugin-security'sexplainrecord write gate (canEditRecord) askssharing.canEdit. That reads an abstention as permission, anddescribeOwdtreatscontrolled_by_parentas org-shared. It never asks the master-detail write check that the data door runs (assertControlledByParentWrite).Fix direction (for the claimant)
ISecurityService.checkControlledByParentWrite(#22464; served byplugin-securityonce PR #22513 lands) is the parity path: one composition answers both the data door andexplain. On acontrolled_by_parentobject,explain's update verdict should come from it, and its layers should name the master leg that refuses. ⛔ No second copy of the master-detail check.Serial: behind PR #22513 (#22455), which serves the member and edits
security-plugin.ts.Tests
explainupdate on acontrolled_by_parentchild whose master the caller cannot edit → not allowed, naming the master leg; parity with that caller'sPATCH.PATCHpasses.canEdit-only verdict turns the pin red.