Skip to content

analytics: read-scope-sql's ruled $not-over-$in-empty residue has no open card — and #13640 turned it into an echo-vs-execution disagreement on the ObjectQL strategy #13926

Description

@claude

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions