Filed unassigned by the #13496 dev. This surfaced while measuring #13496's Zone-2 convergence hypothesis and is evidence for the decision that card is escalating — triage may prefer to fold it into #13496 rather than run it separately. It is filed on its own because it is reachable with no null anywhere, so it survives whatever #13496 is ruled. Recording only — no severity asserted, routing is triage's.
Measured on 50cf2940b9 (helper text read on 090f2302ec, unchanged)
packages/plugins/plugin-security/src/rls-compiler.ts carries a purpose-built guard so that a pre-resolved membership set that RESOLVES EMPTY denies rather than emitting a permissive filter. Its own docblock:
Does this filter consist solely of an empty membership ({ field: { $in: [] } })? Used to preserve the legacy "empty pre-resolved set drops the policy" semantics so the single-policy path fails closed via the deny sentinel rather than an always-false $in: [].
compileExpression calls it and returns null (policy drops, RLS_DENY_FILTER upstream) when it fires.
It is shape-matched to positive polarity only. Running the helper verbatim:
{"owner":{"$in":[]}} => guard fires: true
{"$not":{"owner":{"$in":[]}}} => guard fires: false
{"$or":[{"$not":{"owner":{"$in":[]}}},{"owner":"u_me"}]} => guard fires: false
It requires keys.length === 1 and the single inner key to be $in. Under a $not the single top-level key is $not, so the guard returns false and the filter flows through.
Why that matters — the negated shape is authored, supported and pinned
not in is a first-class member of the pushdown subset. cel-to-filter.ts's docblock: "not in is !(x in y). Negation wraps in $not." It is pinned in cel-to-filter.test.ts ("not in → !(x in y) → $not wrapping $in"), and the authoring gate accepts it — measured:
isPushdownableCel("!(id in current_user.org_user_ids)") => {"ok":true}
So an author can write using: '!(owner in current_user.org_user_ids)', it passes the enforceability lint, and when the membership set resolves EMPTY the compiler emits {"$not":{"owner":{"$in":[]}}} — which the guard does not catch.
What that filter admits
$in: [] matches nothing on every backend, which is exactly why the positive case is safe. Under $not that inverts. Measured on the two backends the module's own docblock names ("one AST, two backends"), without touching driver-memory / driver-sql / driver-sqlite-wasm:
@objectstack/formula's matchesFilterCondition — executed against a 5-row fixture: 5 of 5 rows.
service-analytics/read-scope-sql.ts — $in: [] lowers to FALSE_CLAUSE (1 = 0, its comment: "IN () matches nothing — safe"); $not lowers to a null-safe negation whose own comment states it is built so this compiler "admits the same rows driver-sql / driver-memory / formula admit". NOT (1 = 0) is TRUE for every row.
So the policy the guard exists to turn into a DENY becomes an ALLOW-ALL instead, on the read scope.
The general shape, which is the useful part
"An emptied membership is safe because $in: [] matches nothing" is a polarity-dependent claim, and the pushdown subset contains negation. The same inference appears as a bare — safe comment in read-scope-sql.ts one arm away from the $not that inverts it. Note that the hand-written Layer 0 path does NOT rely on it: tenant-layer.ts returns an explicit RLS_DENY_FILTER when its membership set is empty rather than an empty $in.
What this does NOT claim
I did not demonstrate a deployed policy authored in the negated form. I demonstrated that the platform accepts one at authoring time, compiles it, and that the deny guard standing in front of the emptied-membership case does not fire on it. I also do not assert which row set $in: [null] should select — that is #13357, unruled.
Related
#13496 (the card this was measured under; its Zone-2 decision turns on the same polarity blind spot) · #13357 (what $in: [null] selects — unruled) · #5146 / #5298 (no-value semantics on read scopes) · ADR-0055 / ADR-0058 (the pushdown contract)
Generated by Claude Code
Filed unassigned by the #13496 dev. This surfaced while measuring #13496's Zone-2 convergence hypothesis and is evidence for the decision that card is escalating — triage may prefer to fold it into #13496 rather than run it separately. It is filed on its own because it is reachable with no null anywhere, so it survives whatever #13496 is ruled. Recording only — no severity asserted, routing is triage's.
Measured on
50cf2940b9(helper text read on090f2302ec, unchanged)packages/plugins/plugin-security/src/rls-compiler.tscarries a purpose-built guard so that a pre-resolved membership set that RESOLVES EMPTY denies rather than emitting a permissive filter. Its own docblock:compileExpressioncalls it and returnsnull(policy drops,RLS_DENY_FILTERupstream) when it fires.It is shape-matched to positive polarity only. Running the helper verbatim:
It requires
keys.length === 1and the single inner key to be$in. Under a$notthe single top-level key is$not, so the guard returns false and the filter flows through.Why that matters — the negated shape is authored, supported and pinned
not inis a first-class member of the pushdown subset.cel-to-filter.ts's docblock: "not inis!(x in y). Negation wraps in$not." It is pinned incel-to-filter.test.ts("not in → !(x in y) → $not wrapping $in"), and the authoring gate accepts it — measured:So an author can write
using: '!(owner in current_user.org_user_ids)', it passes the enforceability lint, and when the membership set resolves EMPTY the compiler emits{"$not":{"owner":{"$in":[]}}}— which the guard does not catch.What that filter admits
$in: []matches nothing on every backend, which is exactly why the positive case is safe. Under$notthat inverts. Measured on the two backends the module's own docblock names ("one AST, two backends"), without touchingdriver-memory/driver-sql/driver-sqlite-wasm:@objectstack/formula'smatchesFilterCondition— executed against a 5-row fixture: 5 of 5 rows.service-analytics/read-scope-sql.ts—$in: []lowers toFALSE_CLAUSE(1 = 0, its comment: "IN () matches nothing — safe");$notlowers to a null-safe negation whose own comment states it is built so this compiler "admits the same rows driver-sql / driver-memory / formula admit".NOT (1 = 0)is TRUE for every row.So the policy the guard exists to turn into a DENY becomes an ALLOW-ALL instead, on the read scope.
The general shape, which is the useful part
"An emptied membership is safe because
$in: []matches nothing" is a polarity-dependent claim, and the pushdown subset contains negation. The same inference appears as a bare— safecomment inread-scope-sql.tsone arm away from the$notthat inverts it. Note that the hand-written Layer 0 path does NOT rely on it:tenant-layer.tsreturns an explicitRLS_DENY_FILTERwhen its membership set is empty rather than an empty$in.What this does NOT claim
I did not demonstrate a deployed policy authored in the negated form. I demonstrated that the platform accepts one at authoring time, compiles it, and that the deny guard standing in front of the emptied-membership case does not fire on it. I also do not assert which row set
$in: [null]should select — that is #13357, unruled.Related
#13496 (the card this was measured under; its Zone-2 decision turns on the same polarity blind spot) · #13357 (what
$in: [null]selects — unruled) · #5146 / #5298 (no-value semantics on read scopes) · ADR-0055 / ADR-0058 (the pushdown contract)Generated by Claude Code