Repository navigation
[security] A by-id write is not gated by record visibility — a contributor mutates records they cannot read, when only select-scope RLS is authored #7665
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Aug 11, 2026 Claim: PM loop (
domain:identityseat, #6022)
Session:session_01BVc1ekPpi6yaWywAUhfzfd
Branch:claude/issue-7665-by-id-write-visibility-gate
Worktree:objectstack-issue-7665
Mode & model: L card (hardest of this batch —target:v17, security-gate contract change with a double-sided failure mode),mode:subagent(Claude_Code_Remote absent this shift, sanctioned fallback), ceiling-tier model.Direction of record: option (A) from the card — the platform requires a by-id write target to be inside the caller's readable set when no
update-scope rule is authored. (B) is a demo workaround the card itself rejects. The acceptance criteria are taken as binding, including the two anti-over-correction guards: read-side unchanged, and a legitimately in-scope write still succeeds; the fix must not resurrect the widener-dead directions tracked by #7401 / #6736.Hot-file serialization (this claim is dispatched but the dev BLOCKS on a predecessor): the card's regions (
:13782.7 pre-image gate,:2739resolveSharingCanEdit,:4139/:4210assertControlledByParentWrite) overlap PR #7697 (#7505, auto-merge armed, CI converging) inassertControlledByParentWrite, and sit adjacent to #7626's in-flight region (:1194–1310). The dev is instructed to verify #7697 is merged intoorigin/mainbefore branching, and to re-check #7626's landing state before flipping ready. Serial-queue record on #6022.
Generated by Claude Code
huangyiirene commented
on Aug 11, 2026 CollaboratorAuthorMore actionsCross-seat note from the #7626 dispatching session (
session_01T4VrzFdnQETy7CUUnfxcan) — not a claim; this card is yours (claim above, 10:56Z). Both predecessors this card blocks on have now landed, and the line anchors in the card body are stale. Posting the re-located ones so your dev does not have to re-derive them.Both predecessors are merged:
Card PR Landed as #7505 ( readRowByIdfail-closed)#7697 21888ab(11:28Z)#7626 ( $expandCRUD/OWD bypass)#7683 2b1e37f(~11:5xZ)So the "verify #7697 is merged before branching" and "re-check #7626's landing state before flipping ready" instructions in your claim are both satisfied — the dev can branch from current
origin/mainand take both.⚠️ Every line anchor in this card's body is now wrong. Measured onorigin/mainafter both merges:Card says Actually on merged main:1378— 2.7 by-id write pre-image gategetCallerPreImagecall sites:1864/:1925; definition:3806:4139/:4210—assertControlledByParentWritecall sites :1597/:1607; definition:4248— readRowByIddefinition:3773(#7683 deleted the
expandSkipCrudblock around:1206, shifting everything below it;expandSkipCrudnow returns 0 hits, so that fix is confirmed present.) Locate by symbol, not by line — these will move again with the next merge.One substantive question worth putting to the dev before it writes a test, because #7697 changed the ground under this card's mechanism:
readRowByIdno longer flattens engine faults tonull— it re-throws, andDatasourceUnavailableErrorkeeps itsERR_DATASOURCE_UNAVAILABLEcode (both transports map it to 503). The pre-image probe this card is about reads through exactly that function. The steady-state bypass almost certainly still holds — #7697 changed the fault path, not the normal one — but that is a PM assumption, not a verified fact, and a test written against "the probe returns null when the row is invisible" semantics may now be describing a different world. Worth measuring first rather than inheriting.Nothing else here needs the #7626 session; #7683's own dogfood suite (
showcase-expand-crud-gate.dogfood.test.ts) is a working example of the two-persona end-to-end shape your acceptance criteria demand, if it is useful as a template.Also worth knowing, since your dev will open a PR shortly — a CI trigger trap cost #7683 an hour today: a branch pushed under the
claudeApp identity produced zero GitHub Actions runs (CIandLint & Type Checkwere never created, not queued).close → reopendid not fix it — that fires onlypull_request_target. The only reliable trigger is asynchronizeevent, i.e. pushing a new commit. Tell the dev to checklist_workflow_runsfor its branch right after opening the draft PR; if it is zero, push again (mergingorigin/mainis the natural way, and is wanted here anyway).
Generated by Claude Code
Claim amendment (same claim, same branch — PM session
session_01BVc1ekPpi6yaWywAUhfzfd, seat #6022).The first dev on this card was terminated mid-flight twice by API quota, the second time by exhausting the ceiling model tier specifically. The card is a
target:v17blocker, so it is being finished on the default (opus) tier rather than parked: a fresh os-dev has taken over the same branchclaude/issue-7665-by-id-write-visibility-gate, inheriting the previous author's uncommitted work from the worktree (enforcement edit incomputeLayeredRlsFilter, a draftedselect-only-write-visibility.test.ts, and an in-progress repurpose of the dogfood planted-hole block).Two disciplines attached to the handover, recorded here because they change what the artifact means:
- The inherited diff is being reviewed as an untrusted draft from another author — verified claim by claim, not assumed — and the incoming dev owns the final result. Its report carries an
inherited_work_reviewsection stating what it kept, changed, or rejected. - The riskiest inherited decision is called out explicitly for scrutiny: the previous author intended to repurpose
rls-fixture.dogfood.test.ts's planted-hole RED block (which necessarily flips once this hole is closed) into a GREEN end-to-end pin. That is exactly where a fix can delete its own oracle, so the handover requires positive evidence that detector liveness remains proven elsewhere, named in the report.
No scope, direction or acceptance-criteria change: option (A), all five criteria, and the #7401/#6736 anti-over-correction constraint stand as claimed.
Generated by Claude Code
- The inherited diff is being reviewed as an untrusted draft from another author — verified claim by claim, not assumed — and the incoming dev owns the final result. Its report carries an
{ "issue": 7665, "status": "done", "premise_still_valid": true, "pr": "https://github.com/objectstack-ai/objectstack/pull/7792", "branch": "claude/issue-7665-by-id-write-visibility-gate", "commits": ["06d1ab576 fix(plugin-security): require by-id write targets to be within the caller's readable set under select-only RLS"], "files_changed": [ "packages/plugins/plugin-security/src/security-plugin.ts (+61/-6)", "packages/plugins/plugin-security/src/select-only-write-visibility.test.ts (new, 640 lines, 17 cases)", "packages/qa/dogfood/test/rls-fixture.dogfood.test.ts (+76)", "packages/qa/dogfood/test/fixtures/rls-owner-fixture.ts (+18/-6)", ".changeset/select-only-rls-by-id-write-visibility.md (new)" ], "summary": "Option (A) implemented at a single decision site in computeLayeredRlsFilter: when the write-class policy collection is empty for (principal, object, operation), the write scope is derived from the caller's authored SELECT narrowing. That one change closes all three row gates that were simultaneously no-op'ing on a null write Layer 1 - the 2.7 by-id pre-image gate, the 2.8 controlled_by_parent master check, and the bulk write AST injection. Derivation is skipped when any write-class policy applies (authored predicate or in-domain platform ownership floor), for insert (no pre-image), and when the caller holds the read-side superuser bypass on a posture-permitting object (unbounded readable set - the mirror of the read path's own Layer 1 short-circuit, which is what keeps a derived write scope from ever being NARROWER than the read scope it derives from). checkAuthoredRowWrite is structurally unreachable by derivation: its authored-set pre-check abstains whenever the authored set is empty, and authored is a subset of collected, so derivation only fires on inputs that already abstained - a derived scope can never masquerade as an authored admission (#5493/#7281). Refusal envelopes are the existing house answers (PERMISSION_DENIED/403, record_access_denied from 2.7; master-edit sentence from 2.8); neither separates absent from invisible, so no existence oracle is opened.", "inherited_work_review": { "verdict": "core design KEPT and endorsed on mechanism; two test defects found by measurement and corrected; nothing rejected wholesale", "kept": [ "The enforcement edit itself - single point in computeLayeredRlsFilter. Verified superior to the smaller alternative (make the 2.7 gate run unconditionally with {id}): that alternative fixes only the by-id master path and leaves the controlled_by_parent detail open, because assertControlledByParentWrite skips its RLS half whole under `if (masterWriteFilter)` and then falls through to resolveSharingCanEdit, which returns true under OWD public_read_write. It would also leave the bulk path and explain untouched. Criterion 1's detail half is only reachable via derivation.", "All three exemptions (write-class-applies / insert / read-bypass), each independently verified. The read-bypass limb is not decoration: without it a viewAllRecords holder would be narrowed by select policies the READ path short-circuits past, making the write scope narrower than the read scope. Mutation-tested - see ablation.mutations.", "The 15 drafted unit cases' structure, personas and ADR-0112 envelope assertions. Criterion 2 is genuinely satisfied: qa_contributor holds allowRead+allowCreate+allowEdit+allowDelete on all three objects, so a refusal is the record gate answering, never the CRUD bit.", "The dogfood planted-hole RED-block repurpose - see dogfood_red_block_verdict." ], "changed": [ "THE EXPLAIN PIN WAS VACUOUS. It asserted the rls layer reports 'narrows' for operation:'update', but makeStack registered the org-scoping service, and explain reads its verdict off the COMPOSED layer0 AND layer1 (`readFilter ? 'narrows' : 'not_applicable'`). Layer 0 alone satisfied it. Measured: the case stayed GREEN under the full enforcement ablation - it could not fail. Rewritten to run at posture 'single' (no org wall, null Layer 0) so Layer 1 decides alone. This also reproduces the card's measured signal faithfully: 'not_applicable - No RLS policy applies' is only reachable with a null Layer 0. Added a delete case and a create case (create must stay not_applicable - insert derives nothing). The rewritten pins now flip red under ablation, taking the count from 6 red to 8.", "THE CRITERION-5 WIDENER PIN'S GREEN WAS OVER-READABLE. The fake engine's findOne does not re-enter the middleware chain, so the pre-image gate's caller-scoped read - #7401's mechanism - is not simulated. The case is sound at the layer this fix edits (filter composition: an applicable authored write predicate keeps deciding alone and the derived scope is not AND-ed in) but it does NOT establish that the widener works end-to-end, and a reader could easily take it that way. Scope documented in place, with the mutation that makes it red named.", "Added the ablation trap to the dogfood block's header comment: this suite resolves @objectstack/plugin-security through its built dist, so a source-only revert reports a FALSE GREEN. It cost me one lap - my first dogfood ablation came back rls-consistent with no fix present, because the dist still carried the fix from an earlier closure build. `pnpm --filter @objectstack/plugin-security build` between edit and run is what makes the ablation real." ], "rejected": [], "dogfood_red_block_verdict": { "verdict": "ACCEPTED - the repurpose is correct and the fix does NOT delete its own oracle", "necessity": "The flip is forced, not chosen: the select-only fixture IS the #7665 authoring shape, so closing the class necessarily turns that block from rls-hole to rls-consistent. Leaving the old assertion would have been a red suite, not a preserved oracle.", "detector_liveness_evidence": "packages/qa/dogfood/test/rls-runner.test.ts pins all three runRlsProofs classifications against a SCRIPTED fake stack, including 'flags a HOLE when a member who cannot read a record still mutates it by id' (line 59-65, asserts summary.holes === 1 and status === 'rls-hole'). Because its stack is scripted rather than platform-driven, no platform change can switch it off - and I did not take that on faith: it was measured GREEN in BOTH ablation states (3/3 passing with the fix present and with the enforcement reverted + dist rebuilt).", "what_is_genuinely_lost_and_why_it_is_unrecoverable": "The block previously also proved that a LIVE stack run can emit rls-hole end-to-end. That specific property cannot be preserved by re-authoring the fixture, and I checked rather than assumed: to plant a live hole you need 'member cannot read' plus 'by-id write lands'. After the fix any read narrowing makes the write filter non-null, so the 2.7 gate RUNS - and its pre-image findOne executes under the caller's own context, re-entering the middleware and picking up the caller's read scope (#7401's documented mechanism, and the in-place comment at 2.7 says read-side RLS composes naturally). So the row is invisible to the probe read and the write is refused regardless of what the write predicate says. A select-narrow + update-wide fixture therefore yields rls-consistent, not a hole. The only remaining shape is allowRead:false + allowEdit:true, which would assert an unruled contract (is blind write legitimate?) and is out of this card's scope. Recorded rather than papered over." } }, "tests": { "new_unit_file": "packages/plugins/plugin-security/src/select-only-write-visibility.test.ts - 17 cases, all green: 'Test Files 1 passed (1) / Tests 17 passed (17)'", "dogfood_end_to_end": "rls-fixture.dogfood.test.ts on a real bootStack + HTTP: 'rls_note [rls-consistent] member B cannot read (GET 404) and could not mutate (PATCH 403, row unchanged)' + explicit by-id assertions (member GET != 200, member PATCH >= 300, admin re-read shows the row untouched) + an in-scope write that still lands.", "suites_green_on_merged_main": { "@objectstack/plugin-security": "Test Files 48 passed (48) / Tests 990 passed (990)", "@objectstack/plugin-sharing": "Test Files 16 passed (16) / Tests 428 passed (428) - re-run after #7760 landed in this package", "@objectstack/runtime": "Test Files 127 passed (127) / Tests 2018 passed (2018)", "@objectstack/http-conformance": "Test Files 4 passed (4) / Tests 72 passed (72)", "@objectstack/verify": "Test Files 5 passed (5) / Tests 23 passed (23)", "@objectstack/dogfood FULL": "Test Files 90 passed | 1 skipped (91) / Tests 578 passed | 3 skipped (581), 399.74s - the skip is rls-multitenant.dogfood.test.ts's pre-existing describe.skipIf(!organizationsAvailable) on a file I never touched", "typecheck": "pnpm --filter @objectstack/plugin-security typecheck - exit 0" }, "showcase_rls_proofs": "Covered by the full dogfood run above. Named re-run of the criterion-5 pins: authored-row-write-scope / bulk-widener-probe / controlled-by-parent / owner-anchor-and-bulk-writes / authz-conformance - 'Tests 39 passed | 2 skipped (41)'. The two #7401 E2E cases are named green: '[E2E public_read] the widener is LIVE end-to-end where the caller can read the row' and 'WARN [E2E private] the verdict now admits - and the WRITE is still refused, by the pre-image gate'.", "gates": "check:nul-bytes OK (7116 files), check:engine-double-contract OK (150 pinned / 133 debt / 2 exempt - the new fake engine routes both write verbs through assertEngineUpdateDispatch / assertEngineDeleteDispatch and was PINNED, not flagged), check:tenant-chokepoint OK, check:authz-resolver OK, check:error-code-casing OK, check:empty-changeset OK", "ci": "23 check runs created on PR #7792 (the #7683 zero-runs trigger trap did NOT occur). At report time: 'No other open PR may claim the same issue' success, Check Documentation Links success, ESLint / TypeScript Type Check / Check Changeset in_progress, Test Core + Dogfood Regression Gate queued. Per the #6644 L2 contract I report at draft-PR time and leave CI convergence to the PM." }, "ablation": { "method": "git checkout origin/main -- security-plugin.ts (enforcement only, tests kept). Direction predicted IN WRITING before running.", "predicted": "7 red / 8 green", "measured": "6 red / 9 green - the prediction MISSED, and the miss was the finding: the explain-update pin stayed green because Layer 0 alone made readFilter truthy. After rewriting that pin to posture 'single', the ablation is 8 red / 9 green with the two explain pins now flipping as they should.", "red_under_ablation": [ "master: out-of-scope by-id UPDATE is refused", "master: out-of-scope by-id DELETE is refused", "detail: by-id UPDATE under an unreadable master is refused", "detail: INSERT under an unreadable master is refused", "qa_doc DELETE class (no authored delete rule) derives from select", "bulk multi-UPDATE touches only readable rows", "explain: operation:'update' reports narrows", "explain: operation:'delete' reports narrows" ], "green_under_ablation": [ "in-scope by-id UPDATE still succeeds", "read side unchanged (out-of-scope row invisible to find)", "detail: owner writes their own line", "authored update widener still admits an out-of-select row (#6736 direction)", "authored widener's own boundary still refuses", "viewAllRecords holder not newly narrowed", "org_member cross-creator write refused by the platform floor", "explain: operation:'read' still narrows", "explain: operation:'create' still not_applicable" ], "end_to_end_ablation": "Enforcement reverted AND the plugin rebuilt (the dist trap above), on the real HTTP stack: 'rls_note [rls-hole] member B cannot read it (GET 404) yet MUTATED it by id (PATCH 200) - by-id write bypassed RLS (#1994 class)', 2 cases red. With the fix: PATCH 403, row unchanged. This is the QA #7637 defect reproduced and closed, measured in both directions on a live stack.", "mutations": "Because a guard that stays green in both ablation states proves nothing on its own, the two criterion-5 guards were mutation-tested, each verified APPLIED before running (a silently-failed mutation would look like the guard held): (A) drop the `collected.length === 0` requirement so derivation fires even when a write-class policy applies -> exactly 1 red, 'the authored widener still admits a row OUTSIDE the select scope', i.e. the #7401/#6736 direction; (C) drop the superuser-read-bypass exemption -> exactly 1 red, 'a viewAllRecords holder is NOT newly narrowed'. Both guards are live." }, "explain_after": "operation:'update' (and 'delete') on a select-only object now report rls verdict 'narrows' with detail 'Row-level security narrows the row set (see readFilter for the composed predicate).', where before the fix they reported 'not_applicable' / 'No RLS policy applies.' - the exact split QA #7637 cited, now closed. operation:'read' is unchanged ('narrows'), so the split is closed rather than inverted, and operation:'create' correctly stays 'not_applicable'. This needed NO new explain plumbing: explain consumes the same computeRlsFilter the middleware enforces with, so it tracked the fix automatically - all four are pinned. One limitation worth recording: the composed predicate itself is only attached to the decision for read/export (`explain-engine.ts` includes readFilter only for those), so the update decision exposes the verdict and detail but not the filter. I did not add it - the card said not to build new explain features.", "neighbor_7626": "No conflict, measured not assumed. #7626 is CLOSED (merged as PR #7683) and is in my base: `grep -c expandSkipCrud packages/plugins/plugin-security/src/security-plugin.ts` returns 0, so the ~:1194-1310 region the claim warned about no longer exists as a distinct block. My change touches computeLayeredRlsFilter (~:3936) and two comment sites at the 2.7/2.8 gates - no overlap. Re-checked immediately before the final push; nothing of #7626's was in flight against me.", "predecessor_7697": "Satisfied via squash landing, not SHA ancestry - #7505/PR #7697 landed as squash commit 21888ab, which was the branch's base commit before I merged main forward twice. Its consequence was respected: no assertion anywhere in my tests depends on 'the probe returns null on error'. Every store double is healthy, and the two probe reads my change newly activates (the 2.7 pre-image findOne and the CBP master findOne) are exercised only on the normal path. store-fault-fail-closed.test.ts, the suite that owns the fault path, is green in the 990.", "escalations": [], "deviations": [ "Predicted 7 red on ablation, measured 6. Reported as a miss rather than reconciled after the fact, because the missing flip WAS the defect (a vacuous explain pin) and finding it is the reason the ablation is worth running.", "Derivation is applied to the BULK update/delete path as well, not only by-id. This follows from fixing at the single composition site, and it is deliberate: closing by-id while leaving `update({multi})` able to mutate invisible rows would be a half fix. It does not touch #6736's direction - when an update widener IS authored, collected is non-empty and nothing is derived. Pinned by its own case.", "The `delete` class is derived alongside `update`, though the card's repro found DELETE already refused (no delete bit). The refusal there was the CRUD gate, not the row gate; my probe persona deliberately HOLDS allowDelete so the row gate is what answers, and a fix that closed update but left delete open would be an obvious half fix.", "History was flattened to one commit on top of current main. The inherited work was first committed verbatim as an unreviewed WIP commit (so nothing could be lost), then reviewed, corrected, and squashed. Nothing was ever pushed before the flatten, so no pushed ref was rewritten.", "origin/main advanced twice underneath this worktree mid-run (the sibling worktrees share one .git, so another agent's fetch moves the ref). Merged forward both times; the full re-verification above is on the second merge, which includes #7760 in plugin-sharing." ], "changeset": ".changeset/select-only-rls-by-id-write-visibility.md - patch on @objectstack/plugin-security. Patch rather than minor: no exported type, signature or authoring surface changes; the fix removes an unintended permission. It states the security consequence and the migration-visible change explicitly - a by-id or bulk update/delete of a row outside the caller's readable set is now refused 403 on select-only objects where it previously succeeded, reads/inserts/authored-write-scope objects are unaffected, and an app that relied on the old behaviour should author an explicit operation:'update' policy for the wider scope it intends." }
Generated by Claude Code
Generated by Claude Code
- added a commit that references this issue
on Aug 11, 2026 { "issue": 7665, "status": "done", "supersedes": "comment 5254998667 — that report was posted at draft-PR time and is now stale: the PM then asked for an in-scope docs accuracy fix, which added a commit, a file, two gates and a re-verification. This comment is the authoritative one; the earlier differs only in the docs work and the counts that follow from it.", "premise_still_valid": true, "pr": "https://github.com/objectstack-ai/objectstack/pull/7792", "branch": "claude/issue-7665-by-id-write-visibility-gate", "commits": [ "06d1ab576 fix(plugin-security): require by-id write targets to be within the caller's readable set under select-only RLS", "23df0a396 docs(permissions): record that a `select` policy also bounds writes when no write-class policy applies", "e74621ab8 Merge remote-tracking branch 'origin/main' (second forward merge)" ], "files_changed": [ "packages/plugins/plugin-security/src/security-plugin.ts (+61/-6)", "packages/plugins/plugin-security/src/select-only-write-visibility.test.ts (new, 640 lines, 17 cases)", "packages/qa/dogfood/test/rls-fixture.dogfood.test.ts (+76)", "packages/qa/dogfood/test/fixtures/rls-owner-fixture.ts (+18/-6)", "content/docs/permissions/rls.mdx (+13/-1)", ".changeset/select-only-rls-by-id-write-visibility.md (new)" ], "summary": "Option (A) implemented at a single decision site in computeLayeredRlsFilter: when the write-class policy collection is empty for (principal, object, operation), the write scope is derived from the caller's authored SELECT narrowing. That one change closes all three row gates that were simultaneously no-op'ing on a null write Layer 1 - the 2.7 by-id pre-image gate, the 2.8 controlled_by_parent master check, and the bulk write AST injection. Derivation is skipped when any write-class policy applies (authored predicate or in-domain platform ownership floor), for insert (no pre-image), and when the caller holds the read-side superuser bypass on a posture-permitting object (unbounded readable set - the mirror of the read path's own Layer 1 short-circuit, which is what keeps a derived write scope from ever being NARROWER than the read scope it derives from). checkAuthoredRowWrite is structurally unreachable by derivation: its authored-set pre-check abstains whenever the authored set is empty, and authored is a subset of collected, so derivation only fires on inputs that already abstained - a derived scope can never masquerade as an authored admission (#5493/#7281). Refusal envelopes are the existing house answers (PERMISSION_DENIED/403, record_access_denied from 2.7; master-edit sentence from 2.8); neither separates absent from invisible, so no existence oracle is opened.", "pm_addendum_docs_drift": { "finding_1_rls_mdx": "AGREED and FIXED in this PR. `content/docs/permissions/rls.mdx` L69 said `select` narrows reads and the write classes guard 'the matching write' - the exact mental model that produced this defect, and after the fix an understatement of enforcement. Added a three-sentence clause in the house voice stating that a `select` policy also bounds writes when no write-class policy applies, with all three boundaries (an authored write predicate keeps deciding its own class; nothing derived for `insert`; a read-side superuser-bypass holder is not narrowed), plus a pointer from the `operation` property-table row. No other docs touched; `content/docs/releases/` untouched.", "finding_2_authorization_mdx": "PARTIALLY DISAGREE - checked in context rather than echoed, and the PR body states the accurate version instead. The line reads 'If no applicable policy compiles, the result is a deny-all sentinel (fail-closed)'. In the code the sentinel fires ONLY when applicable policies existed and all of them failed to compile (the field-existence net): `if (allRlsPolicies.length > 0) { ... if (layer1 == null && dropped > 0) layer1 = RLS_DENY_FILTER }`, and the plugin's own comment at security-plugin.ts:2599 says so verbatim - 'policies applied but none compiled'. When the applicable set is EMPTY, Layer 1 is null and nothing narrows. So the sentence cannot be read as 'an empty write-class collection was promised fail-closed': taken that way it would equally mean an object with no RLS at all denies every read, which has never been the behaviour and is not what this PR changes either (an object with no select policy either still compiles to a null Layer 1 and the gate still skips). Claiming the docs promised this would have been a nicer story than the evidence supports. What IS defensible, and what the PR body now says: (a) the layer-5 row's documented FAILURE DIRECTION is fail-closed, and the write path was fail-open; and (b) far stronger, `authz-conformance.matrix.ts:39` marks `rls-by-id-write` as state:'enforced' and names `rls-fixture.dogfood.test.ts` as its proof - while that very proof file pinned the select-only case as an `rls-hole`. The ledger contradicted its own cited evidence; after this PR they agree. The showcase permission-set comment makes the same promise, also false as shipped.", "authorization_mdx_residue": "The imprecise sentence at authorization.mdx:55 is PRE-EXISTING (not created by this change) and I left it alone per the explicit 'no doc-tidying beyond the one accuracy fix' instruction. Flagging rather than filing, since the PM raised it directly and a duplicate card would be noise - say the word and I will file it as an observation-class `finding`. Suggested precise wording if it is ever picked up: 'If applicable policies exist but none of them compiles, the result is a deny-all sentinel (fail-closed); an object with no applicable policy is not narrowed by this layer at all.'" }, "inherited_work_review": { "verdict": "core design KEPT and endorsed on mechanism; two test defects found by measurement and corrected; nothing rejected wholesale", "kept": [ "The enforcement edit itself - single point in computeLayeredRlsFilter. Verified superior to the smaller alternative (make the 2.7 gate run unconditionally with {id}): that alternative fixes only the by-id master path and leaves the controlled_by_parent detail open, because assertControlledByParentWrite skips its RLS half whole under `if (masterWriteFilter)` and then falls through to resolveSharingCanEdit, which returns true under OWD public_read_write. It would also leave the bulk path and explain untouched. Criterion 1's detail half is only reachable via derivation.", "All three exemptions (write-class-applies / insert / read-bypass), each independently verified. The read-bypass limb is not decoration: without it a viewAllRecords holder would be narrowed by select policies the READ path short-circuits past, making the write scope narrower than the read scope. Mutation-tested - see ablation.mutations.", "The 15 drafted unit cases' structure, personas and ADR-0112 envelope assertions. Criterion 2 is genuinely satisfied: qa_contributor holds allowRead+allowCreate+allowEdit+allowDelete on all three objects, so a refusal is the record gate answering, never the CRUD bit.", "The dogfood planted-hole RED-block repurpose - see dogfood_red_block_verdict." ], "changed": [ "THE EXPLAIN PIN WAS VACUOUS. It asserted the rls layer reports 'narrows' for operation:'update', but makeStack registered the org-scoping service, and explain reads its verdict off the COMPOSED layer0 AND layer1 (`readFilter ? 'narrows' : 'not_applicable'`). Layer 0 alone satisfied it. Measured: the case stayed GREEN under the full enforcement ablation - it could not fail. Rewritten to run at posture 'single' (no org wall, null Layer 0) so Layer 1 decides alone. This also reproduces the card's measured signal faithfully: 'not_applicable - No RLS policy applies' is only reachable with a null Layer 0. Added a delete case and a create case (create must stay not_applicable - insert derives nothing). The rewritten pins now flip red under ablation, taking the count from 6 red to 8.", "THE CRITERION-5 WIDENER PIN'S GREEN WAS OVER-READABLE. The fake engine's findOne does not re-enter the middleware chain, so the pre-image gate's caller-scoped read - #7401's mechanism - is not simulated. The case is sound at the layer this fix edits (filter composition: an applicable authored write predicate keeps deciding alone and the derived scope is not AND-ed in) but it does NOT establish that the widener works end-to-end, and a reader could easily take it that way. Scope documented in place, with the mutation that makes it red named.", "Added the ablation trap to the dogfood block's header comment: this suite resolves @objectstack/plugin-security through its built dist, so a source-only revert reports a FALSE GREEN. It cost me one lap - my first dogfood ablation came back rls-consistent with no fix present, because the dist still carried the fix from an earlier closure build. `pnpm --filter @objectstack/plugin-security build` between edit and run is what makes the ablation real." ], "rejected": [], "dogfood_red_block_verdict": { "verdict": "ACCEPTED - the repurpose is correct and the fix does NOT delete its own oracle", "necessity": "The flip is forced, not chosen: the select-only fixture IS the #7665 authoring shape, so closing the class necessarily turns that block from rls-hole to rls-consistent. Leaving the old assertion would have been a red suite, not a preserved oracle.", "detector_liveness_evidence": "packages/qa/dogfood/test/rls-runner.test.ts pins all three runRlsProofs classifications against a SCRIPTED fake stack, including 'flags a HOLE when a member who cannot read a record still mutates it by id' (line 59-65, asserts summary.holes === 1 and status === 'rls-hole'). Because its stack is scripted rather than platform-driven, no platform change can switch it off - and I did not take that on faith: it was measured GREEN in BOTH ablation states (3/3 passing with the fix present and with the enforcement reverted + dist rebuilt).", "what_is_genuinely_lost_and_why_it_is_unrecoverable": "The block previously also proved that a LIVE stack run can emit rls-hole end-to-end. That specific property cannot be preserved by re-authoring the fixture, and I checked rather than assumed: to plant a live hole you need 'member cannot read' plus 'by-id write lands'. After the fix any read narrowing makes the write filter non-null, so the 2.7 gate RUNS - and its pre-image findOne executes under the caller's own context, re-entering the middleware and picking up the caller's read scope (#7401's documented mechanism, and the in-place comment at 2.7 says read-side RLS composes naturally). So the row is invisible to the probe read and the write is refused regardless of what the write predicate says. A select-narrow + update-wide fixture therefore yields rls-consistent, not a hole. The only remaining shape is allowRead:false + allowEdit:true, which would assert an unruled contract (is blind write legitimate?) and is out of this card's scope. Recorded rather than papered over." } }, "tests": { "new_unit_file": "packages/plugins/plugin-security/src/select-only-write-visibility.test.ts - 17 cases, all green: 'Test Files 1 passed (1) / Tests 17 passed (17)'", "dogfood_end_to_end": "rls-fixture.dogfood.test.ts on a real bootStack + HTTP: 'rls_note [rls-consistent] member B cannot read (GET 404) and could not mutate (PATCH 403, row unchanged)' + explicit by-id assertions (member GET != 200, member PATCH >= 300, admin re-read shows the row untouched) + an in-scope write that still lands.", "suites_green_on_merged_main": { "@objectstack/plugin-security": "Test Files 48 passed (48) / Tests 990 passed (990) - re-run after the SECOND forward merge", "@objectstack/plugin-sharing": "Test Files 16 passed (16) / Tests 428 passed (428) - re-run after #7760 landed in this package", "@objectstack/runtime": "Test Files 127 passed (127) / Tests 2018 passed (2018)", "@objectstack/http-conformance": "Test Files 4 passed (4) / Tests 72 passed (72)", "@objectstack/verify": "Test Files 5 passed (5) / Tests 23 passed (23)", "@objectstack/dogfood FULL": "Test Files 90 passed | 1 skipped (91) / Tests 578 passed | 3 skipped (581), 399.74s - the skip is rls-multitenant.dogfood.test.ts's pre-existing describe.skipIf(!organizationsAvailable) on a file I never touched", "dogfood RLS + criterion-5 pins (re-run post second merge)": "Test Files 7 passed (7) / Tests 48 passed (48)", "typecheck": "pnpm --filter @objectstack/plugin-security typecheck - exit 0, re-run post second merge" }, "showcase_rls_proofs": "Covered by the full dogfood run above. Named re-run of the criterion-5 pins: authored-row-write-scope / bulk-widener-probe / controlled-by-parent / owner-anchor-and-bulk-writes / authz-conformance - 'Tests 39 passed | 2 skipped (41)'. The two #7401 E2E cases are named green: '[E2E public_read] the widener is LIVE end-to-end where the caller can read the row' and 'WARN [E2E private] the verdict now admits - and the WRITE is still refused, by the pre-image gate'.", "gates": "check:nul-bytes OK (7130 files, re-run after the docs edit), check:engine-double-contract OK (150 pinned / 133 debt / 2 exempt - the new fake engine routes both write verbs through assertEngineUpdateDispatch / assertEngineDeleteDispatch and was PINNED, not flagged), check:tenant-chokepoint OK, check:authz-resolver OK, check:error-code-casing OK, check:empty-changeset OK, and for the docs change: check:doc-authoring OK (375 files clean) + check:adr-links OK (525 links resolve). `Check Documentation Links` is the CI-side doc gate and passed on the PR.", "ci": "23 check runs created on PR #7792 (the #7683 zero-runs trigger trap did NOT occur; the docs push re-triggered via synchronize). Per the #6644 L2 contract I report at draft-PR time and leave CI convergence, the ready-flip and landing to the PM." }, "ablation": { "method": "git checkout origin/main -- security-plugin.ts (enforcement only, tests kept). Direction predicted IN WRITING before running.", "predicted": "7 red / 8 green", "measured": "6 red / 9 green - the prediction MISSED, and the miss was the finding: the explain-update pin stayed green because Layer 0 alone made readFilter truthy. After rewriting that pin to posture 'single', the ablation is 8 red / 9 green with the two explain pins now flipping as they should.", "red_under_ablation": [ "master: out-of-scope by-id UPDATE is refused", "master: out-of-scope by-id DELETE is refused", "detail: by-id UPDATE under an unreadable master is refused", "detail: INSERT under an unreadable master is refused", "qa_doc DELETE class (no authored delete rule) derives from select", "bulk multi-UPDATE touches only readable rows", "explain: operation:'update' reports narrows", "explain: operation:'delete' reports narrows" ], "green_under_ablation": [ "in-scope by-id UPDATE still succeeds", "read side unchanged (out-of-scope row invisible to find)", "detail: owner writes their own line", "authored update widener still admits an out-of-select row (#6736 direction)", "authored widener's own boundary still refuses", "viewAllRecords holder not newly narrowed", "org_member cross-creator write refused by the platform floor", "explain: operation:'read' still narrows", "explain: operation:'create' still not_applicable" ], "end_to_end_ablation": "Enforcement reverted AND the plugin rebuilt (the dist trap above), on the real HTTP stack: 'rls_note [rls-hole] member B cannot read it (GET 404) yet MUTATED it by id (PATCH 200) - by-id write bypassed RLS (#1994 class)', 2 cases red. With the fix: PATCH 403, row unchanged. This is the QA #7637 defect reproduced and closed, measured in both directions on a live stack.", "mutations": "Because a guard that stays green in both ablation states proves nothing on its own, the two criterion-5 guards were mutation-tested, each verified APPLIED before running (a silently-failed mutation would look like the guard held): (A) drop the `collected.length === 0` requirement so derivation fires even when a write-class policy applies -> exactly 1 red, 'the authored widener still admits a row OUTSIDE the select scope', i.e. the #7401/#6736 direction; (C) drop the superuser-read-bypass exemption -> exactly 1 red, 'a viewAllRecords holder is NOT newly narrowed'. Both guards are live." }, "explain_after": "operation:'update' (and 'delete') on a select-only object now report rls verdict 'narrows' with detail 'Row-level security narrows the row set (see readFilter for the composed predicate).', where before the fix they reported 'not_applicable' / 'No RLS policy applies.' - the exact split QA #7637 cited, now closed. operation:'read' is unchanged ('narrows'), so the split is closed rather than inverted, and operation:'create' correctly stays 'not_applicable'. This needed NO new explain plumbing: explain consumes the same computeRlsFilter the middleware enforces with, so it tracked the fix automatically - all four are pinned. One limitation worth recording: the composed predicate itself is only attached to the decision for read/export (`explain-engine.ts` includes readFilter only for those), so the update decision exposes the verdict and detail but not the filter. I did not add it - the card said not to build new explain features.", "neighbor_7626": "No conflict, measured not assumed. #7626 is CLOSED (merged as PR #7683) and is in my base: `grep -c expandSkipCrud packages/plugins/plugin-security/src/security-plugin.ts` returns 0, so the ~:1194-1310 region the claim warned about no longer exists as a distinct block. My change touches computeLayeredRlsFilter (~:3936) and two comment sites at the 2.7/2.8 gates - no overlap. Re-checked before each of my two pushes; nothing of #7626's was in flight against me.", "predecessor_7697": "Satisfied via squash landing, not SHA ancestry - #7505/PR #7697 landed as squash commit 21888ab, which was the branch's base commit before I merged main forward twice. Its consequence was respected: no assertion anywhere in my tests depends on 'the probe returns null on error'. Every store double is healthy, and the two probe reads my change newly activates (the 2.7 pre-image findOne and the CBP master findOne) are exercised only on the normal path. store-fault-fail-closed.test.ts, the suite that owns the fault path, is green in the 990.", "escalations": [], "deviations": [ "Predicted 7 red on ablation, measured 6. Reported as a miss rather than reconciled after the fact, because the missing flip WAS the defect (a vacuous explain pin) and finding it is the reason the ablation is worth running.", "Derivation is applied to the BULK update/delete path as well, not only by-id. This follows from fixing at the single composition site, and it is deliberate: closing by-id while leaving `update({multi})` able to mutate invisible rows would be a half fix. It does not touch #6736's direction - when an update widener IS authored, collected is non-empty and nothing is derived. Pinned by its own case.", "The `delete` class is derived alongside `update`, though the card's repro found DELETE already refused (no delete bit). The refusal there was the CRUD gate, not the row gate; my probe persona deliberately HOLDS allowDelete so the row gate is what answers, and a fix that closed update but left delete open would be an obvious half fix.", "History: the inherited work was first committed verbatim as an unreviewed WIP commit (so nothing could be lost), then reviewed, corrected, and flattened to one clean commit on top of main BEFORE the first push. Nothing pushed was ever rewritten; the docs fix is a separate commit on top.", "origin/main advanced THREE times underneath this worktree mid-run (the sibling worktrees share one .git, so another agent's fetch moves the ref). Merged forward twice; the suites above were re-run on the second merge, which includes #7760 in plugin-sharing. A third advance landed after that re-run - the PR's base is behind by those commits and the PM's convergence read will see it.", "PM addendum handled: docs finding 1 fixed in-PR; docs finding 2 checked and partially disputed with code evidence rather than asserted (see pm_addendum_docs_drift)." ], "changeset": ".changeset/select-only-rls-by-id-write-visibility.md - patch on @objectstack/plugin-security. Patch rather than minor: no exported type, signature or authoring surface changes; the fix removes an unintended permission. It states the security consequence and the migration-visible change explicitly - a by-id or bulk update/delete of a row outside the caller's readable set is now refused 403 on select-only objects where it previously succeeded, reads/inserts/authored-write-scope objects are unaffected, and an app that relied on the old behaviour should author an explicit operation:'update' policy for the wider scope it intends. No changeset for the docs commit (content/docs is not a released package; the PR carries the `documentation` label from the bots)." }Supersedes the draft-PR-time report above — same result, plus the PM's docs addendum and the counts that follow from it. PR #7792 remains a draft; not flipped ready, auto-merge not armed.
Generated by Claude Code
Generated by Claude Code
Review: ACCEPT — PR #7792 (PM session
session_01BVc1ekPpi6yaWywAUhfzfd; enforcement hunk and docs diff read line-by-line, plus two independent checks of my own below).First, a correction to my own instruction — the dev was right and I was wrong
I told this dev that
content/docs/permissions/authorization.mdx:55already documented the fixed behaviour ("if no applicable policy compiles → deny-all sentinel, fail-closed"), and asked it to cite that as evidence the fix restores documented intent. That reading was wrong, and the dev disputed it with code rather than complying. I verified the rebuttal independently: the sentinel's own contract atsecurity-plugin.ts:2591reads "policies applied but none compiled, or resolution failed" — a non-empty applicable set whose members all failed to compile. An empty applicable set never reaches it. The dev's reductio settles it: my reading would mean an object with no RLS at all denies every read, which was never true. It substituted a stronger and accurate citation (authz-conformance.matrix.tsmarkingrls-by-id-writeenforced while its own cited proof file pinned anrls-hole). Correct call, correctly argued; a dev that echoed the PM here would have shipped a false claim in a security PR body. Second time this shift a dev has caught the PM — the pattern is being carried into the ops notes, not just this thread.The enforcement change
One decision site, and the right one.
collected.length === 0 && (update|delete)derives the write scope from the caller'sselectpolicies, and every exemption is load-bearing rather than decorative:- Authored write policy present → no derivation, so The by-id write pre-image gate resolves the row under the caller's own read scope, so an app-authored widener is still dead on
privateeven once checkAuthoredRowWrite admits it #7401 / Authored RLS update-wideners are silently ineffective on the bulk write path — buildWriteFilter ANDs them away (fewer rows, no error) #6736's widening directions keep their exact semantics (criterion 5), withcheckAuthoredRowWriteadditionally unreachable by derivation because the authored set is a subset of the collected one. insert→ nothing to be visible.- Superuser read bypass → the inner check asks for the read bypass specifically, which is what guarantees a derived write scope can never be narrower than the read scope it derives from. That is the subtle correctness property of the whole change and it is implemented, not just asserted.
I checked the operation coverage myself rather than trusting the update/delete pair at face value: the 2.7 gate normalises
purge → deleteandtransfer / restore → updateat:1487-1489before computing, so all six write verbs on that gate are covered by a derivation written for two.Verification quality
The ablation prediction missed (7/8 predicted, 6/9 measured) and the miss was the finding — the inherited explain pin was vacuous, green under the full ablation because the fixture ran with org scoping and explain reads the composed
layer0 AND layer1. Rewritten at posturesingleso it can fail, then 8 red / 9 green. Two targeted mutations prove criterion 5's guards are alive (derive-always → exactly the widener pin reds; drop the bypass exemption → exactly theviewAllRecordspin reds). End-to-end on a real HTTP stack, both directions measured:[rls-hole] GET 404 yet MUTATED by id (PATCH 200)without,PATCH 403, row unchangedwith.The dogfood planted-hole repurpose is accepted on evidence: detector liveness survives in
rls-runner.test.tsand was measured green in both ablation states. What is genuinely lost — an automated proof that a live stack can still emitrls-hole— is recorded rather than papered over, which is the right way to lose something.Docs
rls.mdxnow states the derivation with its three boundary conditions, in the property table and in prose. That was a real defect this change created: the old text ("selectnarrows reads;insert/update/deleteguard the matching write") is precisely the mental model that produced this bug.One asymmetry I found, filed rather than blocking
Step 3's bulk AST injection passes
opCtx.operationraw (:2113, and:2133for the delegator), unlike the 2.7 gate's normalisation. So a bulk write arriving aspurge/transfer/restorecollects nothing and derives nothing, while bulkupdate/deletenow derive. This is not a regression — that path had no derived scope before either — but the PR creates an asymmetry between the two write paths, and "which of those verbs can reach the AST path at all" is a question worth answering deliberately. Filed as a follow-up finding; not a reason to hold atarget:v17security fix that closes the measured hole.Suites green on merged main: plugin-security 990, plugin-sharing 428, runtime 2018, http-conformance 72, verify 23, full dogfood 578 (+3 pre-existing skips); 8 gates incl.
tenant-chokepointandauthz-resolver; 23 CI check runs created, so the #7683 zero-runs trigger trap did not recur.Next: flipping #7792 ready and enabling auto-merge.
Generated by Claude Code
- Authored write policy present → no derivation, so The by-id write pre-image gate resolves the row under the caller's own read scope, so an app-authored widener is still dead on
- added 7 commits that reference this issue
on Aug 17, 2026
Impact
An authenticated low-privilege user modifies records they cannot read. Not admin-only, not owner-only — the probing persona held the ordinary
showcase_contributorset. Reproduced on three objects (showcase_invoice,showcase_invoice_line,showcase_task), twice each on fresh rows. The run calls this the strongest finding of the sweep.DELETEis correctly refused (no delete bit) — only the write half is open.Ruled
target:v17class ① (a user-hitting defect on a shipped surface). "You can't mutate what you can't see" is not enforced.Reproduction (2/2 per object)
Provision two contributor personas C1 and C2 (sign-up +
sys_user_permission_setshowcase_contributor +sys_user_positioncontributor, then re-sign-in so the session carries the position).owner= C1) and a line under it.GET /api/v1/data/showcase_invoice_line/<lineId>→ 404 RECORD_NOT_FOUNDGET /api/v1/data/showcase_invoice_line?$top=300→ the line is absentPATCH /api/v1/data/showcase_invoice_line/<lineId>{"description":"C2-FORGED","quantity":999,"unit_price":12345}→ 200, admin re-read shows the forged values persisted.showcase_task.The platform's own explain engine states the split — the cleanest confirmation:
POST /security/explain {object:'showcase_invoice', operation:'read', userId:<C2>}→ rls layer "narrows"operation:'update'→ rls layer "not_applicable — No RLS policy applies"Root cause (located, re-verified on
origin/main)packages/plugins/plugin-security/src/security-plugin.ts::1378) composes the RLS filter for the UPDATE operation and is documented as skipped when that returns null. The comment at:1553states it outright: for an object whose narrowing is authored as select-only, "the fix(security)[P0]: enforce RLS on by-id writes — close member-edits-others'-records hole (#1985) #1994 pre-image check above is a no-op for it".operation: 'select'rules only (task_own_rows,invoice_own_rows), so there is no update-scope predicate and no read-visibility requirement is imposed on the by-id UPDATE path.public_read_write, soresolveSharingCanEdit(:2739) also returns true.showcase_invoice_line(controlled_by_parent),assertControlledByParentWrite(:4139/:4210) then derives the detail's write access from that same permissive master verdict.So an object narrowed with select-only RLS has an open by-id write path as shipped, and any app authoring select-only narrowing has this shape today.
Why nothing caught it
verify --rls(runRlsProofs) reports 0 HOLES, but its member probe holds no object grants, so every probe is masked by the object-level gate (403) before record scope is reached. Ashowcase_accountauto-record 400 also cascades into 5 downstream skips (8 of 23 objects skipped on a stock run) — and a skip is exactly where this hid. (The tooling gap is tracked publicly from the same run; this card is the enforcement fix.)authz-conformance.matrix.tsmarks bothrls-by-id-write(fix(security)[P0]: enforce RLS on by-id writes — close member-edits-others'-records hole (#1985) #1994) andcontrolled-by-parentasstate:'enforced'— neither holds as shipped.examples/app-showcase/src/security/permission-sets.tscomments that a contributor "can by-id read/write a line only when they can read/write its master" — also false as shipped.Acceptance criteria
operation:'select'narrowing must not be able toPATCHa by-id target outside that select scope — on the master and on acontrolled_by_parentdetail.verify --rlsproof structurally cannot fail because its probe holds no object grants, so a re-tagged version of it is not acceptable.Suggested shape (not prescriptive)
Two options, and the platform-side one is the one that generalises:
updaterule is authored.operation:'update'RLS onshowcase_invoice/showcase_task. This fixes the demo but leaves every other select-only app exposed, so it is a workaround, not the fix.Relationship to the adjacent open issues — this is the OPPOSITE direction, not a duplicate
The open cards on this gate all concern write-widening mechanisms being ineffective (too restrictive — a legitimate widener is dead): #7401 (pre-image resolves under the caller's own read scope, killing an app widener on
private), #6736 (authored update-wideners ANDed away on the bulk path). This defect is the reverse: the by-id write is too permissive — no read-visibility requirement at all under select-only RLS. Related closed history: #5386 (controlled_by_parentderivation ignored master access), #7281, #5492. None of these covers the select-only-write-bypass, and a fix must not resurrect the widener-dead behaviour those track.Provenance & disclosure
Held back from the public #7637 run report pending the maintainer's disclosure decision (D1 precedent); the private write-up was delivered 2026-08-11 in
docs/qa/platform-checklist/FOLLOW-UPS.md§1a entry D11 (branchclaude/platform-test-checklist-ocwugl@f268b2b2). The maintainer then instructed the PM directly to file this fix card (verbatim, 2026-08-11): 「立一张私有修复卡」 — so the disclosure here is authorized, not a leak.Source
QA run #7637 (
rls-both-sidesclause 5 +owd-sharing-matrixclause 4, the same defect; framework92f26f75), private write-up FOLLOW-UPS.md §1a D11. Root cause re-verified againstorigin/mainbefore filing.