Filed unassigned by the #13357 dev. This is the p2 census that card's triage audit ordered ("执行者顺手做这次普查,带正对照"), and it FIRED. Recording only — no severity asserted, routing is triage's.
Blocked-by: #13357 — what this filter SHOULD select is the ruling that card is waiting for. The fail-closed asymmetry below is independent of that ruling; the row-set consequence is not.
Measured, with a positive control
compileCelToFilter (packages/formula/src/cel-to-filter.ts) is the canonical CEL pushdown for read scopes (ADR-0058 D1/D2/D6), consumed by plugin-security/rls-compiler.ts and plugin-sharing. Executed on 0e810dde17, same expression each time, only the resolved membership array varying:
CONTROL clean list : {"ok":true,"filter":{"owner":{"$in":["u1","u2"]}}}
PROBE null member : {"ok":true,"filter":{"owner":{"$in":["u1",null]}}}
PROBE only null : {"ok":true,"filter":{"owner":{"$in":[null]}}}
PROBE empty list : {"ok":true,"filter":{"owner":{"$in":[]}}}
The control compiles to a plain $in, so the harness reaches the lowering; the probes are a reading and not a silence. The null member is passed through verbatim into an RLS filter.
The asymmetry
The module's own docblock states the discipline:
A variable that resolves to undefined/null yields unresolved-variable (the "no active org" fail-closed path).
That holds for a SCALAR variable and is pinned (cel-to-filter.test.ts: "null variable (e.g. no active org) → unresolved-variable (fail closed)"). It is not applied to a null MEMBER inside a resolved membership array: lowerMembership checks only Array.isArray(value) and emits the list as-is. So the one shape that is a security predicate gets the fail-closed treatment and the other does not.
The empty-list case is deliberate and already ruled — packages/lint/src/validate-empty-combinators.ts calls $in: [] "a legitimate, declared predicate — matches nothing on every backend", and I measured it consistent across all faces. It is listed above only as the neighbouring shape, not as a defect.
Why the emitted filter is not a harmless no-op
The backends do not agree on what {owner: {$in: [null]}} selects. Measured by execution on the same fixture (id 1, name 'a' and id 3, no value), the two readings of "no value" being a stored null and an absent key:
| surface | $in: [null] |
|---|
driver-sqlite-wasm / driver-sql (compiles to IN (NULL)) | [] — matches nothing |
| driver-memory live mingo path | ['3'] — matches the no-value row, both readings |
driver-memory reference matcher, formula | ['3'] on a stored null, [] on an absent key |
So one sharing rule or read scope admits three different row sets depending on which backend serves it. That is the harm class #5146 named in its own note — "one permission rule admitting different row sets per backend" — reached here by a machine-generated filter rather than an authored one.
Direction depends on which reading is ruled: against the SQL family a scope that should have admitted rows admits none (a lockout), and against the Mongo family a scope silently admits the rows whose column is unset (a widening). Both are wrong answers with nothing to read.
What this does NOT claim
I did not demonstrate a production context provider that actually puts a null into a membership array — I demonstrated that the lowering does not stop one. Two of the three sibling construction paths DO strip: the engine's expand batch (engine.ts: if (val == null) continue and val.filter((id) => id != null)) and search's option collection (search-filter.ts: if (opt == null) continue). This one is the unguarded member of that set.
Related
#13357 (what $in:[null] means — unruled, and the blocker) · #5332 (the $eq: null ruling, scoped to the scalar spellings) · #13494 (the scalar $eq: null cell) · #5146 / #5298 (no-value semantics on read scopes) · ADR-0055 / ADR-0058 (the pushdown contract)
Filed unassigned by the #13357 dev. This is the p2 census that card's triage audit ordered ("执行者顺手做这次普查,带正对照"), and it FIRED. Recording only — no severity asserted, routing is triage's.
Blocked-by: #13357 — what this filter SHOULD select is the ruling that card is waiting for. The fail-closed asymmetry below is independent of that ruling; the row-set consequence is not.
Measured, with a positive control
compileCelToFilter(packages/formula/src/cel-to-filter.ts) is the canonical CEL pushdown for read scopes (ADR-0058 D1/D2/D6), consumed byplugin-security/rls-compiler.tsandplugin-sharing. Executed on0e810dde17, same expression each time, only the resolved membership array varying:The control compiles to a plain
$in, so the harness reaches the lowering; the probes are a reading and not a silence. The null member is passed through verbatim into an RLS filter.The asymmetry
The module's own docblock states the discipline:
That holds for a SCALAR variable and is pinned (
cel-to-filter.test.ts: "null variable (e.g. no active org) → unresolved-variable (fail closed)"). It is not applied to a null MEMBER inside a resolved membership array:lowerMembershipchecks onlyArray.isArray(value)and emits the list as-is. So the one shape that is a security predicate gets the fail-closed treatment and the other does not.The empty-list case is deliberate and already ruled —
packages/lint/src/validate-empty-combinators.tscalls$in: []"a legitimate, declared predicate — matches nothing on every backend", and I measured it consistent across all faces. It is listed above only as the neighbouring shape, not as a defect.Why the emitted filter is not a harmless no-op
The backends do not agree on what
{owner: {$in: [null]}}selects. Measured by execution on the same fixture (id 1, name 'a'andid 3, no value), the two readings of "no value" being a stored null and an absent key:$in: [null]driver-sqlite-wasm/driver-sql(compiles toIN (NULL))[]— matches nothing['3']— matches the no-value row, both readingsformula['3']on a stored null,[]on an absent keySo one sharing rule or read scope admits three different row sets depending on which backend serves it. That is the harm class #5146 named in its own note — "one permission rule admitting different row sets per backend" — reached here by a machine-generated filter rather than an authored one.
Direction depends on which reading is ruled: against the SQL family a scope that should have admitted rows admits none (a lockout), and against the Mongo family a scope silently admits the rows whose column is unset (a widening). Both are wrong answers with nothing to read.
What this does NOT claim
I did not demonstrate a production context provider that actually puts a null into a membership array — I demonstrated that the lowering does not stop one. Two of the three sibling construction paths DO strip: the engine's expand batch (
engine.ts:if (val == null) continueandval.filter((id) => id != null)) and search's option collection (search-filter.ts:if (opt == null) continue). This one is the unguarded member of that set.Related
#13357 (what
$in:[null]means — unruled, and the blocker) · #5332 (the$eq: nullruling, scoped to the scalar spellings) · #13494 (the scalar$eq: nullcell) · #5146 / #5298 (no-value semantics on read scopes) · ADR-0055 / ADR-0058 (the pushdown contract)