Repository navigation
fix(plugin-security): a controlled_by_parent middle master is judged at the chain hop by its own direct-write composition (#23093) - #23114
Conversation
…at the chain hop by its own direct-write composition Maintainer ruling A on #23093 extends the 2026-08-15 ruling to the chain walk's middle hop: in assertMasterRowEditable, a master that is itself controlled_by_parent, on a non-delegated write, passes masterGateCoversThisWrite, so only the platform ownership floor yields there. An app-authored update policy and the derive-from-select floor still run, and a delegated write keeps both floors. A line write under a quote is now admitted exactly when the same principal's direct update of the quote is. Pins: controlled-by-parent-chain-middle-master.test.ts (real ObjectQL + SQL driver, real sharing middleware, member_default floor armed). Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude <noreply@anthropic.com>
…p-two-level-chain-walk
…in hop agrees with the quote's own write PR #23091 pinned today's answer for pwr_quote_line as contradicted. With the chain hop judged by the middle master's own direct-write composition (ruling A on #23093), every persona's quote-line writes agree with its update of the quote: the object is proven and the run has no hard failure. Expectation flips only. Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 18 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 ad12dc0acb25f93b12d3cf97ac72660b70594f26 && git checkout ad12dc0acb25f93b12d3cf97ac72660b70594f26
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a3d47c2c59dce84c1cb1a64abb0f07b16675ce0b 15058d6ff808951416948a1d83dcb99f120f8a02 && git checkout -B drift-repro a3d47c2c59dce84c1cb1a64abb0f07b16675ce0b && git merge --no-ff 15058d6ff808951416948a1d83dcb99f120f8a02
node scripts/docs-audit/affected-docs.mjs --json a3d47c2c59dce84c1cb1a64abb0f07b16675ce0b |
…te is described as ruled, and the coverage vouch lists the chain hop Four texts this PR's change made false or incomplete, prose and comments only: the pending parent-write-reach release note, the verify README and the cli.mdx family description now state that a detail under a controlled_by_parent middle master is writable exactly when the caller may update that middle master directly (which needs edit access to the root), and that the family reports such a chain proven; the masterGateCoversThisWrite option docblock lists the chain hop among the call sites that vouch, pointing at that call site's own bullet. Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs, and nothing else: card #23093 (body and all 11 comments — triage 6132405210 and its amendment 6133394280, claims 6132814355 and 6134458430, dev reports 6133252394, 6135392664 and 6136047254, the decision analysis 6133315184, the maintainer's ruling A at 6133922339, its director binding at 6134050293, the claim amendment 6135431019); PR #23114 (body, its 7-file list, the net diff against the merge base with Gate verdicts = the check-runs on this head: 39 success, 7 skipped (opt-in and filtered lanes: Console Pin Gate, Dogfood shards, Temporal Conformance, Packed-tarball smoke, and the re-fired Auto Label / Check PR Size), 2 failure — both are ① Derived judgmentsAccept sets and public surfaces the diff implies
DELIBERATE CORRECTION — confirmed.
None of the three new sentences carries a tracker number, and none claims more than the engine does at this head. ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Designed red:
|
Fixes #23093
Clause-②: no — a runtime authorization behaviour under the maintainer's ruling A; no published accept set or public surface moves.
What changes
@objectstack/plugin-security,assertMasterRowEditable(the master-edit legs the ADR-0055 chain walk runs on every hop). The write-RLS leg now passes one more option tocomputeRlsFilter:So when the master on a hop is itself
controlled_by_parentand the write is not delegated, that hop composes the master's write filter the way the master's own by-id write does (step 2.7, commit 6feac91): the platform ownership floor (owner_only_writes, positions['org_member']) yields to the master gate. Nothing else yields. An app-authored update policy on that master and the #7665 derive-from-select floor still run, and a delegated write keeps both principals' floors. The docblock bullet that read "A master that is itself a detail keeps its floor exactly as it does today" now states the ruling. The cycle guard, the depth bound and the walk itself are untouched.Why the vouch holds. The flag is set on the predicate (
declaresControlledByParent(rel.master)) that makes the walk inassertControlledByParentWritecontinue to that master's own master. That master's own master gate therefore runs next, for the same principal, on the same pass, unless the walk refuses (cycle, depth bound, broken chain). This is the same structural argument step 2.7 makes for the by-id write.The ruling
The maintainer chose A on the decision analysis (card comment 6133315184). It is recorded twice:
6133922339, the maintainer's word: 「A 延伸裁决 (推荐)」. The option text the maintainer chose:
6134050293, the director binding of the maintainer's 「同意」. It also sets the fork clause: for one principal, a line write under a middle master is admitted iff that principal's direct by-id update of the middle master is admitted.
No fork was found. Every case below, at both layers, satisfies that invariant.
Pins —
controlled-by-parent-chain-middle-master.test.ts(new)The rig is a real
ObjectQLwith the SQL driver (better-sqlite3, in memory), the realSecurityPlugin, the realSharingServicemiddleware, andmember_defaultcarrying the floor. Principals holdorg_member. A delegator resolves from storedsys_user/sys_member/sys_user_positionrows. The chain iscbc_line → cbc_quote → cbc_account(controlled_by_parent,controlled_by_parent,private). Every case asserts the quote's own by-id update beside the line writes for the same principal.403 PERMISSION_DENIED, naming mastercbc_account403, namingcbc_account; update and delete refused403, namingcbc_quotestage == 'open', closed quote403, namingcbc_quote(row-level security)403, namingcbc_quote; the same member acting for itself is admitted403, namingcbc_quote; the same agent acting for itself is admittedEvery refusal asserts the ADR-0112 envelope (
code,status), plus the first sentence naming the caller's own object and verb:[Security] Access denied: VERB on 'cbc_line' requires edit access to its master record. Every refusal also asserts that no row moved.Pre-fix baseline (the same file on
main8464380, before the fix): 5 failed / 4 passed. Pin 1's line update was refused(master 'cbc_quote' not editable by this user (row-level security)). Both pin-2 refusals sat at the quote hop instead of the root. The creator case of pin 3 was admitted. The non-delegated control of pin 4 was refused.A narrowing the ruling's composition implies. Before this change, the hop composed the platform floor and an app-authored update policy as alternatives (row-level policies OR together). So the creator of a middle master could write its details while that master's own update refused them, the two-answer shape in the other direction. Under one composition the line write is refused as the quote's update is. The changeset states it for upgraders.
Ablations (each from the committed fix c6a48bd, through
scripts/ablation-replace.mjs, restore proven by blob == HEAD 12a9565cd3b9 and an emptygit diff HEAD)The test imports
./security-plugin.jsfrom source, so nodist/is involved.masterGateCoversThisWrite: false(the old leg)expected { kind: 'refused' … } to deeply equal { kind: 'admitted' }, refused(master 'cbc_quote' not editable by this user (row-level security))VALIDATION_FAILEDcontrolled_by_parentmastercontrolled_by_parentmaster!delegatedWrite &&removed from the flagFull stack, hotcrm-shaped (scratch, not committed)
@objectstack/verifybootStackwas driven throughhooks.runas signed-up personas (the previous dev's matrix plus an H2 persona). Five personas ran, each with and withoutorg_member, over chains another user owns, the persona owns, and the persona created itself, with 3 line verbs each. Measured on this fix over base 8464380: 0 cases where a line answer differs from the same persona's quote update. Results:rp_quote;rp_quote(row-level security), its own door chain included;Whole-repo sweep
The refusal texts
requires edit access to its masterandnot editable by this userappear in 11 files. None pins the two-level refusal under the floor; the fixtures that armorg_memberare single-level. The fullplugin-securitysuite is green after the change. PR #23091 has merged, so its pinned expectation is flipped here, expectation only (declared in 6132790096):packages/verify/src/parent-write-reach.test.ts:pwr_quote_lineis nowproven. The master-all-records reaches areALL_ALLOWEDwith every answerwritten, anddetail-onlystaysALL_REFUSED. The summary isproven: 3, contradicted: 0, held: 135, contradictions: 0, hardFailures: 0.packages/cli/test/verify-parent-write-reach.test.ts: nothing to flip. Its fixture is single-level (vpw_contract → vpw_account), so none of its expectations reads the two-level answer.Local verification (at c6a48bd, before the merge of
main; the os-dev-report on the card carries the re-run on the final head)pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 193 files passed, 3981 tests passed, 45 skipped.VERDICT command-exit 0.pnpm --filter @objectstack/plugin-security typecheck: exit 0. The new file is in thetsconfig.test.jsonprogram (--listFileslists it once).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran …: 66 derived, 66 run, 0 NOT-MEASURED, every recorded exit 0.check:i18nfirst answeredPREREQUISITE NOT MET(exit 3) and was re-run after the CLI closure build: exit 0.--no-inline-config --format json): the 2 touched TS files, 0 errors, 0 warnings, neither ignored. This repo'seslint.config.mjsnever enables type-aware linting (noparserOptions.project), so the diff cannot move a verdict on an untouched file. The fullpnpm lintis CI's.Acceptance notes
Docs that this change makes stale (outside this card's declared surface, not edited):
packages/verify/README.md, about line 136: "Today's engine refuses every write to a detail two levels below its root master, even where the caller edits the middle master, so on an app that declares such a chain the family reports a contradiction and fails the run."content/docs/deployment/cli.mdx, about line 2014: the same sentence.After this change, both describe the old engine. Named in the report for the seat to route.
The
RlsFilterOptions.masterGateCoversThisWritedocblock lists the call sites that vouch. The chain hop is now one of them and is documented at the call site, not in that list.security/explainand thesys_attachment/sys_commentparent gates ask this same check (checkControlledByParentWrite), so they give the same answers; no separate edit.Patch round (15058d6), appended by the
domain:servicesseat 2 PMThe dev writes this body once, so the seat appends the dev's report text here (report
6136047254on #23093).Four texts that this change made false or incomplete are corrected. These are prose and comment edits only, per the claim amendment
6135431019:.changeset/15953-verify-parent-write-reach-family.md, line 19: the pending parent-write-reach release note. Its sentence "Today's engine refuses every write to a detail two levels below its root master ..." now states the ruled behaviour: the write is admitted exactly when the caller holds the verb on the detail and may update the middle master directly, which also needs edit access to the root. It is refused otherwise, so the family reports such a chainproven.check-empty-changesetis red on this file BY DESIGN, a DELIBERATE CORRECTION of a pending release note this PR made false. It is not restored, and no changeset is added for@objectstack/verify.packages/verify/README.mdandcontent/docs/deployment/cli.mdx: the same sentence, rewritten the same way.security-plugin.ts, themasterGateCoversThisWriteoption docblock: its list of the call sites that vouch for coverage now includes the chain hop inassertMasterRowEditable, pointing at that call site's bullet instead of restating the reason.Local verification at 15058d6:
dispatch-gates --ran: 96 derived, 96 run, 0 NOT-MEASURED. Every exit is 0 exceptcheck-empty-changeset, which exits 1 as above.pnpm --filter @objectstack/plugin-security typecheck: OK.controlled-by-parent-chain.test.ts: 29 passed.packages/verifyparent-write-reach.test.ts: 10 passed.At 343e21e, the code head, which this round changes only in comments:
plugin-security: 191 files, 3966 tests passed, 45 skipped.Generated by Claude Code