Skip to content

test(dogfood): probe app-authored RLS wideners on the bulk write path (#6736) - #7274

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-6736-bulk-widener-probe
Aug 10, 2026
Merged

test(dogfood): probe app-authored RLS wideners on the bulk write path (#6736)#7274
os-zhuang merged 1 commit into
mainfrom
claude/issue-6736-bulk-widener-probe

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Refs #6736probe only, deliberately NOT Fixes.

The fix for this path is deferred by the maintainer's 2026-08-08 ruling on #5493 (Q2 = A1), and #6736 exists precisely so that deferral cannot die inside a ruled thread (the #6216 failure mode). Auto-closing the card on merge would destroy the record this card was filed to keep. What the ruling did not defer is the card's own first acceptance step — "An end-to-end probe first (the mechanism above is a code read)". This PR is that probe, and nothing else.

What this adds

One dogfood test file, packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts. It pins today's behaviour, and today's behaviour is defective-by-declaration. The file says so in its own header, in those words. It is not a fix, contains no fix, and must not be read as one.

The measurement

Real stack — bootStack with the real SecurityPlugin, the real SharingServicePlugin and the real ObjectQL engine. A member holds an app-authored permission set declaring, in the author's own words, "any holder may UPDATE a note in stage open, and DELETE a note in stage stale" — said about the row, never about its owner.

pathrows the declaration admitsrows the statement toucheserror / 403 / envelope
update({multi})3 (n_bob_open, n_alice_open_1, n_alice_open_2)1 (n_bob_open)none
delete({multi})3 (d_bob_stale, d_alice_stale_1, d_alice_stale_2)1 (d_bob_stale)none

The claim in the card reproduces, on both verbs.

The narrowing predicate, read off the running system rather than inferred, is identical for both verbs:

{ "owner_id": "hOkItA1Pb2RWo5FmuiSAeFXHFIXss6CB" }

delete composes the same way and for a documented reason, not by accident: buildWriteFilter returns the owner-match alone for the delete verb (ADR-0111 D3 — a share widens rows, never verbs), so there is no share branch for a widener to have been OR-ed alongside either.

What surfaces the discrepancy: nothing. The engine returns an affected-row count of 1, which is indistinguishable from "only one row matched the caller's predicate". The whole probe run emits zero WARN and zero ERROR lines. Contrast two nearby precedents that do speak: the by-id delete denial one block up in the same middleware logs a specific reason (ADR-0111 D10), and #6783 added SYSTEM_WRITE_SKIP_NOTICE for exactly the "configured but inert stops being silent" shape. This branch has neither.

Why the fixture is a public_read object

Deliberate, and load-bearing. buildReadFilter returns null for any non-private model, so reads are open and cannot confound the measurement — on a private object the cross-owner rows would be invisible to the caller and a bulk write that touched nothing would be explained by read scoping rather than by the write composition, which is the wrong thing to measure. Meanwhile buildWriteFilter returns the owner-match for privateandread alike (only a fully public object is write-open). So on this object the sharing write filter is the only narrowing agent: the composition under test, isolated.

Discrimination controls — why the headline number means anything

"1 out of 3" proves nothing on its own. An inert widener (never parsed, never applicable) and a fixture that accidentally granted ownership would each produce a confident-looking number. Two controls split those apart, and both hold:

  • C1 — the widener is LIVE. The same principal, same row, same widener via the by-id path succeeds: sharing.checkEdit answers deny, security.checkAuthoredRowWrite answers admit, the PATCH returns 2xx and the row really changes. An inert declaration would 403 here.
  • C2 — the widener has a BOUNDARY. A row the widener does not admit is still refused, asserted as the ADR-0112 envelope (FORBIDDEN / PERMISSION_DENIED), not as a bare throw. A fixture that leaked ownership, or a disabled gate, would let this through.

Plus an explicit integrity case asserting the resolved principal, that the app-authored set actually reached the execution context, that the run is not isSystem, and that the caller owns exactly one row of each fixture.

Reverse verification — direction predicted in writing BEFORE the run

Manipulation: neutralise composeAnd(ast.where, writeFilter) in the bulk branch only; revert immediately. Predicted, before running: the two MEASURE cases go red (touched 1 to 3; survivors 2 to 0), and the three other cases — integrity, C1, C2 — stay green, because the by-id path never enters that branch. Recorded reasoning: if C1 or C2 also moved, the probe would not be isolating the bulk branch and the headline count would not be attributable to it.

Measured: exactly that. 2 failed | 3 passed, the two failures being the two MEASURE cases, with affected 1 to 3 on both verbs and survivors 2 to 0.

× [MEASURE update({multi})] → expected [ 'n_alice_open_1', ...(2) ] to deeply equal [ 'n_bob_open' ]
× [MEASURE delete({multi})] → expected [] to deeply equal [ Array(2) ]
✓ [integrity] ✓ [C1] ... ✓ [C2] ...

No delta to report honestly: the prediction and the measurement agree case for case.

Scope — what this PR deliberately does NOT do

Per the ruling and the dispatch: no filter-shaped authored-only write FilterCondition, no second composition site, no change to buildWriteFilter's composition, and no extension of checkAuthoredRowWrite (a per-row verdict has no seat in AST composition, which is exactly why the by-id surface cannot reach this path). Zero production files change; git diff origin/main over packages/ and examples/ is empty apart from the new test.

Gates

  • pnpm --filter @objectstack/dogfood exec vitest run test/bulk-widener-probe.dogfood.test.ts5 passed
  • pnpm --filter @objectstack/dogfood typecheck — clean
  • eslint packages/qa/dogfood/test/bulk-widener-probe.dogfood.test.ts --no-inline-config — clean
  • check:nul-bytes, check:verify-stand-in, check:engine-double-contract, check:error-code-casing, check:route-envelope, check:query-options-erasure, check:authz-resolver — all green

No changeset: test-only, releases nothing (skip-changeset).


Generated by Claude Code

…#6736)
End-to-end probe, not a fix. #6736's acceptance section names an end-to-end
measurement as the first thing needed ("the mechanism above is a code read"),
and the maintainer's 2026-08-08 ruling on #5493 (Q2 = A1) deferred the fix for
this path with a stated reason: no measured pull. This supplies the missing
measurement and pins today's behaviour so it cannot change unobserved in
either direction.
Measured on the real stack (bootStack + real SecurityPlugin + real
SharingServicePlugin + real ObjectQL engine), app-authored RLS wideners on
update and delete:
update({multi}): declaration admits 3 rows, statement touches 1, no error
delete({multi}): declaration admits 3 rows, statement removes 1, no error
Both narrowed by the same predicate, `{ owner_id: <caller> }`, which
buildWriteFilter contributes and the bulk branch ANDs into the AST. Zero WARN
or ERROR lines in the whole run: the affected-row count is the only signal and
it names no authority.
Two discrimination controls keep the headline numbers meaningful: the same
principal / row / widener SUCCEEDS on the by-id path (so the declaration is
live, post-#5493), and a row the widener does not admit is still refused with
the ADR-0112 envelope (so the widener has a real boundary).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ
@vercel

vercelBot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 10, 2026 4:24am

Request Review

@os-zhuangos-zhuang added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed size/m labels Aug 10, 2026 — with Claude
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

No hand-written docs reference the 0 changed package(s). ✅

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changesetPR has no user-facing published change; bypasses the changeset gatetests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-zhuang@claude