Skip to content

fix(service-analytics): refuse an empty $nin on the read-scope lowering instead of folding it to constant TRUE - #13649

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-13571-read-scope-empty-fold-refusal
Aug 31, 2026
Merged

fix(service-analytics): refuse an empty $nin on the read-scope lowering instead of folding it to constant TRUE#13649
os-steve merged 1 commit into
mainfrom
claude/issue-13571-read-scope-empty-fold-refusal

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#13571

Implements the domain:services ruling on the card (verdict: 13571#issuecomment-5473783095) — option A, asymmetric refusal — after the dispatched uniform-throw direction was falsified by measurement (report: 13571#issuecomment-5473763764).

What changed

Pins updated deliberately (part of the ruling, not edited to stay green)

New controls (read-scope-empty-nin-refusal.test.ts)

  1. Non-RLS getReadScope provider control — the spec contract (analytics-service.ts carries a hand-written example) filled by hand, handing { owner: { $nin: [] } } to a real NativeSQLStrategy over sql.js rows. Post-fix: refused with code READ_SCOPE_COMPILE_FAILED, status 500. Pre-fix, measured (reverse verification below): the same case admitted the whole fixture table.
  2. Over-denial control (the bound) — the fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware #13570-pinned composite { $or: [ { owner: { $in: [] } }, { owner: 'u_me' } ] } still compiles and still admits exactly the own row; the denies-by-itself $and composite still returns zero rows, not a refusal and not the whole table. This block red under a uniform empty-membership throw is the availability regression the ruling exists to avoid.

Reverse verification (direction predicted before running)

Fix committed first; then read-scope-sql.ts swapped to its pre-fix blob (git checkout e238c79 -- ...), mutation proven on disk (0 hits of the new refusal text, 1 hit of the old fold), and the four touched suites re-run: 7 red / 101 green, every red keyed to the new refusal and none outside it. The provider control failed with real output AssertionError: expected [ 'r1', 'r2', 'r3' ] to be undefined — the pre-fix whole-table admission, measured. Both over-denial controls and the empty-$in pins stayed green on the pre-fix tree, as predicted. Restoration proven by bytes: git diff HEAD empty, worktree blob hash 79641a7... equal to the HEAD blob. (No dist ablation preflight needed: these suites import ../read-scope-sql.js relative source through vitest — no exports-resolved dist on the measured path.)

Verification on 1880b33 (post-final-commit, clean worktree)

  • pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2 — verdict Test Files 84 passed (84) · Tests 1819 passed (1819); the four touched files re-run under --reporter=verbose (108/108) with every new/updated pin confirmed by name.
  • pnpm --filter @objectstack/service-analytics typecheck — exit 0 (the package tsconfig includes src whole, so the test files are inside the measured program).
  • Gate derivation from the actual diff (node scripts/pm/dispatch-gates.mjs, both sections read whole, harvested via --commands): 34 families + check:nul-bytes — all green, including check:dispatcher-error-vocabulary (ADR-0112 code-carrying file), check:engine-double-contract, check:where-matcher, check:test-source-alias, and after the lint.yml-shaped workspace build: check:type-check-debt (judgment line: "check-type-check-coverage --re-measure: OK — 29 ledger entr(ies) re-measured ... none above its recorded number") and check:dual-build-cjs-loads (measured pass with provenance floors).
  • pnpm lint (repo-wide eslint . --no-inline-config, the CI-owned scan, run whole — no narrowing to prove) — exit 0.
  • NOT MEASURED, per the gate's own exit-3 branch: check-test-completeness reads a saved turbo run test log that only CI produces locally; nothing red, nothing measured.

Declared residue and relations (none closed here)

Generated by Claude Code


Generated by Claude Code

…ng instead of folding it to constant TRUE (#13571)
An emptied exclusion folded to '1 = 1' — constant TRUE — which vacates the
whole read scope: every row admitted, no $not needed, on the lowering where
a wrong answer is ADR-0021 scope over-reach. It now throws in the module's
one refusal envelope (READ_SCOPE_COMPILE_FAILED / 500), like the arity check
one line above.
Deliberately asymmetric (domain:services ruling, 2026-08-31): $in: [] keeps
its ruled #5322/#5243 constant-FALSE fold. That fold is narrowing at its own
arm and load-bearing — the RLS compiler deliberately emits an emptied
positive membership inside composites (PR #13570's 'own rows keep flowing'
pin), and that filter reaches this compiler through security.getReadFilter.
A uniform throw was measured and rejected: it would 500 every analytics
query for any user whose membership set resolves empty beside an own-rows
grant. $nin: [] has zero producers (the CEL lowering never emits $nin; the
#13570 guard drops even-polarity empty-$nin policies), so this refusal
costs no live traffic.
Includes a non-RLS getReadScope provider control (the spec contract filled
by hand) pinning refusal post-fix, and the over-denial control pinning that
the #13570 composite still compiles and still admits exactly the own row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json eaba72e48cc294038e74ece5bf7677568d55b038packageMentionDocs.

Which tree this was computed on

This run read content/docs from 04f3a37ba52c8e5a082492a15e7145d03012676e — the merge of head 1880b33469ad63cd9a23b1e63f93e2c2815c89f3 into base eaba72e48cc294038e74ece5bf7677568d55b038, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 04f3a37ba52c8e5a082492a15e7145d03012676e && git checkout 04f3a37ba52c8e5a082492a15e7145d03012676e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eaba72e48cc294038e74ece5bf7677568d55b038 1880b33469ad63cd9a23b1e63f93e2c2815c89f3 && git checkout -B drift-repro eaba72e48cc294038e74ece5bf7677568d55b038 && git merge --no-ff 1880b33469ad63cd9a23b1e63f93e2c2815c89f3
node scripts/docs-audit/affected-docs.mjs --json eaba72e48cc294038e74ece5bf7677568d55b038

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-steve@claude