Filed unassigned by the #13640 dev while implementing that card. Recording only — severity and routing are triage's. Not resolved by #13640 and does not block it.
What was measured, on 46b53a25b
read-scope-sql.ts's module header declares this, verbatim, as a ruled follow-up of the #13571 verdict:
⚠️ Declared residue, ruled follow-up — NOT covered here: $not over $in: [] still compiles to constant TRUE at this lowering for a producer that is not the (#13570-guarded) RLS compiler. Closing that needs a polarity-aware design whose interaction with the #5322$not-over-identity reductions is ruled first — a naive "refuse NOT of the FALSE constant" cannot tell net polarity.
A targeted search over this repo's issues finds no open card carrying that follow-up. #13571 is closed; #13552 and #13496 are closed; nothing else names it. So the residue is recorded only in a source comment, where nothing schedules it.
Why #13640 makes it sharper rather than smaller
#13640 closed the same vacancy on the ObjectQL ENGINE execution path, at the getReadScope-to-engine merge, and was ruled explicitly NOT to move the two routes that go through compileScopedFilterToSql. The result is a disagreement that did not exist before, on one strategy:
ObjectQLStrategy.execute() now REFUSES a scope of { f: { $not: { $in: [] } } } shape (READ_SCOPE_COMPILE_FAILED / 500).ObjectQLStrategy.generateSql() — the /analytics/sql echo for the same query — still COMPILES it, to a predicate that admits every row.
The echo's predicate is NOT (("deal"."owner" IS NOT NULL AND 1 = 0)) — the NULL-safe negation rewrite totalises the leaf, the empty membership folds to the FALSE constant, and NOT FALSE keeps every row. NativeSQLStrategy.applyReadScope builds its WHERE from that same compiler and the same non-RLS producer, so it carries the same predicate. ⚠️ Stated as read from the compiler, not as an end-to-end row count: what #13640 pinned is that compileScopedFilterToSql still COMPILES this shape (its immobility control), and the row consequence above is derived from the emitted predicate rather than measured against a backend. Triage should re-measure before grading.
So one read scope now has two answers on one strategy, chosen by whether the caller asked for rows or for the SQL that would produce them — and the echo's answer is the widening one.
What keeps it from biting today
The same protection profile the two sibling cards carry, and it is producer-side self-discipline, not a gate: in-repo the only scope producer is the RLS compiler, and since PR #13570 its polarity-aware guard drops both emptied-membership shapes at widening polarity before emission; the CEL lowering never emits $nin. StrategyContext.getReadScope is a spec contract (packages/spec/src/contracts/analytics-service.ts carries a hand-written example), so an out-of-repo producer is exactly the case with no mechanical guard in front of it on those two routes.
Related
#13571 / PR #13649 (the lowering-site refusal and the verdict that declared this residue) · #13640 / PR #13924 (the engine-path guard, which deliberately did not move the echo) · #13552 / PR #13570 (the producer-side polarity guard) · #5322 (empty combinators reduce as boolean identities) · ADR-0021 (read-scope contract)
Generated by Claude Code
Filed unassigned by the #13640 dev while implementing that card. Recording only — severity and routing are triage's. Not resolved by #13640 and does not block it.
What was measured, on
46b53a25bread-scope-sql.ts's module header declares this, verbatim, as a ruled follow-up of the #13571 verdict:A targeted search over this repo's issues finds no open card carrying that follow-up. #13571 is closed; #13552 and #13496 are closed; nothing else names it. So the residue is recorded only in a source comment, where nothing schedules it.
Why #13640 makes it sharper rather than smaller
#13640 closed the same vacancy on the ObjectQL ENGINE execution path, at the
getReadScope-to-engine merge, and was ruled explicitly NOT to move the two routes that go throughcompileScopedFilterToSql. The result is a disagreement that did not exist before, on one strategy:ObjectQLStrategy.execute()now REFUSES a scope of{ f: { $not: { $in: [] } } }shape (READ_SCOPE_COMPILE_FAILED/ 500).ObjectQLStrategy.generateSql()— the/analytics/sqlecho for the same query — still COMPILES it, to a predicate that admits every row.The echo's predicate is⚠️ Stated as read from the compiler, not as an end-to-end row count: what #13640 pinned is that
NOT (("deal"."owner" IS NOT NULL AND 1 = 0))— the NULL-safe negation rewrite totalises the leaf, the empty membership folds to the FALSE constant, andNOT FALSEkeeps every row.NativeSQLStrategy.applyReadScopebuilds itsWHEREfrom that same compiler and the same non-RLS producer, so it carries the same predicate.compileScopedFilterToSqlstill COMPILES this shape (its immobility control), and the row consequence above is derived from the emitted predicate rather than measured against a backend. Triage should re-measure before grading.So one read scope now has two answers on one strategy, chosen by whether the caller asked for rows or for the SQL that would produce them — and the echo's answer is the widening one.
What keeps it from biting today
The same protection profile the two sibling cards carry, and it is producer-side self-discipline, not a gate: in-repo the only scope producer is the RLS compiler, and since PR #13570 its polarity-aware guard drops both emptied-membership shapes at widening polarity before emission; the CEL lowering never emits
$nin.StrategyContext.getReadScopeis a spec contract (packages/spec/src/contracts/analytics-service.tscarries a hand-written example), so an out-of-repo producer is exactly the case with no mechanical guard in front of it on those two routes.Related
#13571 / PR #13649 (the lowering-site refusal and the verdict that declared this residue) · #13640 / PR #13924 (the engine-path guard, which deliberately did not move the echo) · #13552 / PR #13570 (the producer-side polarity guard) · #5322 (empty combinators reduce as boolean identities) · ADR-0021 (read-scope contract)
Generated by Claude Code