Uh oh!
There was an error while loading. Please reload this page.
fix(service-analytics): refuse an empty $nin on the read-scope lowering instead of folding it to constant TRUE - #13649
Conversation
…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
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not 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
Coarse fallback — 8 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 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 |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13571
Implements the
domain:servicesruling 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
$nin: []now THROWS incompileOperator(packages/services/service-analytics/src/read-scope-sql.ts), in the module's one refusal envelope (READ_SCOPE_COMPILE_FAILED/ 500), following the arity throw one line above. Its old fold was'1 = 1'— constant TRUE — which VACATES the read scope: every row admitted, no$notneeded, on the lowering where a wrong answer is ADR-0021 scope over-reach. The new message was measured againstlooksLikeInternalErrorLeak: FALSE, like the other thirteen, so it is withheld from responses BY DECLARATION and teaches no sniffing list a new phrase.$in: []keeps its1 = 0fold — deliberately. It is the ruled 空组合子在同仓有两个对立答案:五个后端归约成布尔单位元,service-analytics 的两个编译器 fail-closed 抛错 —— #5239 的一致性表四条因此进不了表 #5322/fix(driver-sql): 空$and/$or/$not按布尔单位元编译,$or: []不再返回全表 (#5134) #5243 identity, narrowing at its own arm, and load-bearing:RLSCompiler.compileFilterdeliberately emits an emptied positive membership inside composites (rls-empty-membership-polarity.test.ts, PR fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware #13570: "own rows keep flowing"), and that filter reaches this compiler throughsecurity.getReadFilter. A uniform throw was measured and rejected by the ruling: it would 500 every analytics query for any user whose membership set resolves empty beside an own-rows grant. The module header carries a read-scope-sql's emptied-membership folds are polarity-dependent at the lowering site itself:$in: []folds to1 = 0one arm from$not, and$nin: []folds to1 = 1(constant TRUE) #13571 section so a future reader cannot mistake the asymmetry for an oversight; the ruled boundary is "shape errors THROW, boolean identities REDUCE", and a reduction to scope-vacating constant TRUE sits on the throw side.$nin: []has zero producers (measured): the CEL lowering never emits$nin, and PR fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware #13570's guard drops even-polarity empty-$ninpolicies before emission — so the refusal costs no live traffic.Pins updated deliberately (part of the ruling, not edited to stay green)
comparand-shape-refusal.test.ts— the combined "not a refusal" pin split into: empty$instill folds (with the load-bearing rationale) and empty$ninrefuses with the envelope, each carrying the 空组合子在同仓有两个对立答案:五个后端归约成布尔单位元,service-analytics 的两个编译器 fail-closed 抛错 —— #5239 的一致性表四条因此进不了表 #5322-boundary reasoning.read-scope-not-null-safe.test.ts—$notover$in: []keeps its pinned constant (the verdict's DECLARED residue for a non-RLS producer, closing it is a ruled follow-up design);$notover$nin: []now pins the refusal envelope.read-scope-refusal-envelope.test.ts— new REFUSALS row ⑬ in source order; ledger ratchet moved 14 rows/12 sites to 15 rows/13 sites; the ACCEPTED empty-$inrow annotated with why it stays while the exclusion refuses.New controls (
read-scope-empty-nin-refusal.test.ts)getReadScopeprovider control — the spec contract (analytics-service.tscarries a hand-written example) filled by hand, handing{ owner: { $nin: [] } }to a realNativeSQLStrategyover sql.js rows. Post-fix: refused withcode READ_SCOPE_COMPILE_FAILED,status 500. Pre-fix, measured (reverse verification below): the same case admitted the whole fixture table.{ $or: [ { owner: { $in: [] } }, { owner: 'u_me' } ] }still compiles and still admits exactly the own row; the denies-by-itself$andcomposite 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.tsswapped 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 outputAssertionError: expected [ 'r1', 'r2', 'r3' ] to be undefined— the pre-fix whole-table admission, measured. Both over-denial controls and the empty-$inpins stayed green on the pre-fix tree, as predicted. Restoration proven by bytes:git diff HEADempty, worktree blob hash79641a7...equal to the HEAD blob. (No dist ablation preflight needed: these suites import../read-scope-sql.jsrelative source through vitest — noexports-resolved dist on the measured path.)Verification on
1880b33(post-final-commit, clean worktree)pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2— verdictTest 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 includessrcwhole, so the test files are inside the measured program).node scripts/pm/dispatch-gates.mjs, both sections read whole, harvested via--commands): 34 families +check:nul-bytes— all green, includingcheck: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") andcheck:dual-build-cjs-loads(measured pass with provenance floors).pnpm lint(repo-wideeslint . --no-inline-config, the CI-owned scan, run whole — no narrowing to prove) — exit 0.check-test-completenessreads a savedturbo run testlog that only CI produces locally; nothing red, nothing measured.Declared residue and relations (none closed here)
$notover$in: []still compiles to constant TRUE at this lowering for a non-RLS producer — the verdict's declared residue; a polarity-aware refusal is a follow-up ruled design. read-scope-sql's emptied-membership folds are polarity-dependent at the lowering site itself:$in: []folds to1 = 0one arm from$not, and$nin: []folds to1 = 1(constant TRUE) #13571's verdict comment carries the reasoning; this PR does not attempt it.$nin: []to constant TRUE (whole table), so a lowering-site refusal cannot guard this route #13640 remains open and is untouched here: the ObjectQL engine execution path bypasses this compiler entirely, so no disposition of the lowering guards that route.isEmptyMembershipFilterdeny guard is polarity-blind: an emptied membership under a supportednot incompiles to allow-all instead of the deny sentinel #13552 are prior producer-side work referenced for evidence only; nothing about them is changed by this PR.Generated by Claude Code
Generated by Claude Code