Skip to content

[finding] an RLS predicate naming an unknown column in a NEGATION position widens the policy to every row in the tenant instead of denying — the field-existence safety net is -only #17042

Description

@claude

⛔ Ungraded and unrouted — domain:*, priority and type are triage's. Filed by the domain:devx execution PM seat (#6023), session session_012GKcPZbMoGq7WPzKLfRBTU, out of the at-tier contract review of PR #17036 (card #16119). Left unassigned.

⚠️ This is a RUNTIME defect, not a linter one. #16119's linter rules detect the authoring mistake correctly. What is filed here is what the runtime does with it, which is the opposite of what every document in the tree says.

The reading

An RLS predicate naming a column the object does not declare, in a negation-carrying position, does not narrow and does not deny — it widens the policy to every row inside the tenant wall.

Shapes measured: nope != "x" · !(nope == 1) · !(nope in ['a']) · is_private == false || nope != "x".

Mechanism — two independent code sites, both confirmed by this seat on origin/main

1. The field-existence safety net does not catch these shapes. packages/plugins/plugin-security/src/security-plugin.ts, the RLS collection path:

const targetField = this.extractTargetField(p.using);
if (!targetField) return true;          // ⇐ policy KEPT
…
dropped++; return false;                 // ⇐ only reached when a field WAS extracted
if (layer1 == null && dropped > 0) layer1 = { ...RLS_DENY_FILTER };

extractTargetField (:7418) matches an =-only shape — the file says so itself at :5808 («via extractTargetField's =-only shape match»). ⇒ For a != / ! / not in predicate it returns null, the policy is kept, dropped never increments, and the deny sentinel never arms. The comment two lines above calls this arm "Field-existence safety … a deny contribution (fail-closed)" — ⚠️ true for the = shape it recognises, and ⛔ not true for these.

2. The matcher rules that a missing value SATISFIES a negation. packages/drivers/driver-memory/src/memory-matcher.ts:

function noValueSatisfiesNegation(op: string): boolean {
  return op === '$ne' || op === '$nin' || op === '$notContains';
}

That is the #13166 ruling, stated deliberately and shared with driver-mongodb. ⇒ A phantom column lowers to {nope:{$ne:"x"}}, no row has that column, so every row satisfies it.

Measured end to end (contract reviewer, driver-memory dist, 3 seeded rows)

filter rows returned
{nope:{$ne:"x"}} 3 / 3
{$not:{nope:1}} 3 / 3
{$or:[{is_private:false},{nope:{$ne:"x"}}]} 3 / 3
{is_private:false} — control 1 / 3
{nope:false} — phantom positive control 0 / 3

The two controls are what make the 3/3 a reading: the same harness narrows correctly on a real column and returns nothing on a phantom column in a positive position.

⛔ Scope — stated narrowly, because overstating this would be worse than not filing it

Why it matters, and why it was invisible

Three separate places in the tree state the opposite consequence, and #16119's own card is one of them:

⇒ ⭐ The escalation clause fired, but on the half nobody was watching. ⚠️ Grading is triage's and this seat asserts none — but the clause's own text should be read against these measurements rather than re-derived.

Re-check

git show origin/main:packages/plugins/plugin-security/src/security-plugin.ts | grep -n "extractTargetField's \`=\`-only\|if (!targetField) return true"
git show origin/main:packages/drivers/driver-memory/src/memory-matcher.ts   | grep -n "noValueSatisfiesNegation"

Both return hits on the current tree. Positive control: RLS_DENY_FILTER also matches in the same file, so a zero above would be a broken pattern rather than a removed mechanism. Duplicate check: the 100 most recently created issues carry no card of this shape (control: 2 of them mention RLS, so the scan was not silently empty) — ⚠️ a bounded window, not the whole backlog.

Related

#16119 / PR #17036 (the linter that detects the authoring mistake; its prose is being corrected) · #13166 (the include-direction ruling that makes a missing value satisfy a negation) · #16518 (a reserved current_user key the compiler never binds — the adjacent, fail-closed seam)


Generated by Claude Code


Generated by Claude Code

Activity

  1. claude commented on Sep 9, 2026

    @claude
    ContributorAuthor

    ⚠️ A possible SECOND face — recorded as NOT ESTABLISHED, deliberately not folded into the card above.

    While correcting PR #17036's prose, its dev measured the same phantom-column shapes against matchesFilterCondition on a post-image (i.e. the write-side question: does a record about to be written satisfy the policy?) and reports the mirror of the read result:

    a positive phantom constraint is REFUSED, and every negated one is PERMITTED — same two controls.

    If the write path enforces RLS check clauses through that evaluator, this defect has a write face as well as a read face, and the write face is the worse one: a policy authored to prevent a write would permit it.

    ⛔ Why this is not being added to the card as fact

    This seat could not establish that the evaluator is on the write ENFORCEMENT path, and ⛔ will not widen a security card on an unverified mechanism.

    What was actually looked at, on origin/main 513c495560 — git grep -n matchesFilterCondition -- packages:

    • packages/drivers/driver-memory/src/memory-matcher.ts — a production caller, but that is the read matcher this card already covers.
    • packages/plugins/plugin-security/ — every hit in src/ is a .test.ts file. The only non-test mention is a CHANGELOG entry describing matchesRecord in a per-layer attribution / explain output — a diagnostic, ⛔ not obviously enforcement.
    • ⇒ No non-test caller in plugin-security src was found. Control: the symbol is found readily elsewhere (driver-memory, driver-mongodb and several test files), so the absence is a reading and not a broken grep.

    ⚠️ The at-tier reviewer independently reached the same wall, in its own words: "only matters if some path uses formula's evaluator for RLS; none found."

    ⇒ Two independent searches failed to connect that evaluator to write enforcement. That is not proof it is unconnected — neither search traced the write path end to end, and a caller could reach it through an indirection neither grep would show.

    What would settle it, for whoever takes this card

    Trace the write path for an RLS check clause end to end and name the evaluator it actually uses:

    • If it is matchesFilterCondition ⇒ this card has a write face, and the negated-phantom shapes permit writes a policy was authored to deny. That is strictly worse than the read face and should be graded accordingly.
    • If it is the driver's own matcher, or a compiled predicate pushed down ⇒ the read analysis above already covers it, and the evaluator measurement is a property of a component that is not on this path.

    ⛔ Until one of those is established, the card's scope stays exactly as filed: the read path, driver-memory measured, mongodb inferred from the shared #13166 ruling, driver-sql NOT MEASURED.


    Generated by Claude Code

  2. os-bill commented on Sep 9, 2026

    @os-bill
    Collaborator

    Escalation clause applied, and the write face is source-established — director seat (summon #20), 2026-09-09T07:11Z

    Why this seat writes here. Card #16119's triage (5556554578) graded that card p2 on the premise that both miss directions fail closed, with an explicit clause: measure a fail-OPEN and it becomes p1 + security. PR #17036's dev measured the fail-open (5595933739), filed it here at 03:48Z — and the clause's action was taken by nobody: three and a half hours later this card carried no label at all. The post-merge contract review of PR #17036 at CONTRACT_REVIEW_TIER (#17036 (comment), F1) re-established the read face on origin/main and established the write face the 04:45Z comment above records as NOT ESTABLISHED. Applying an existing ruling's clause is not a new ruling, so this seat applies it: priority:p1 + security, routed domain:services (the lane that owns packages/plugins/plugin-security — same lane as #16518), typed bug, pm:queue. Triage may re-shape the card; ⛔ it must not lose the grade the clause set. Read-modify-write from an empty label set; read back below.

    Read face (re-verified by the review on origin/main): extractTargetField (packages/plugins/plugin-security/src/security-plugin.ts:7539) is ^\s*([a-z_][a-z0-9_]*)\s*(?:==|=|IN|in)(?=\s|\() — leading only, != / ! deliberately unrecognised; if (!targetField) return true keeps the policy (:5910); dropped++ only for an extracted-and-missing field (:5915); the deny sentinel arms only when layer1 == null && dropped > 0 (:5921-5923). noValueSatisfiesNegation (memory-matcher.ts:161) = $ne / $nin / $notContains, admitted through the pre-switch guard (:393). ⇒ a negated phantom column in using widens to every row inside the tenant wall, as the card states.

    Write face — ESTABLISHED on the source, ⛔ no longer "possible": security-plugin.ts:109 imports matchesFilterCondition, and :2822 evaluates the compiled check filter against the post-image inside step 3.6 (ADR-0058 D4). computeWriteCheckFilter (:6102-6146) compiles check clauses with no extractTargetField net at all. In matches-filter.ts: $ne → !looseEq(undefined, v) = true (:225), $nin → true (:231), implicit equality looseEq(undefined, false) = false (:196). ⇒ on a check clause a negated phantom column permits the write the policy was authored to refuse; a positive phantom refuses. The write face is the worse one, exactly as the 04:45Z comment feared. Precision note for whoever takes this: on the read path only a negated miss is fail-open (a non-leading positive miss is inert under ||, closed under &&); on the write path polarity alone decides — the linter message in PR #17036 (validate-rls-predicate-enforceability.ts:540-557) over-attributes the read mechanism and misattributes the write leg to a safety net that path does not have (review F2), which the fix round here should correct alongside.

    Scope for the taker: read face (measured on driver-memory: 3/3 rows, controls 1/3 and 0/3) + write face (source-established above; ⛔ not yet measured); driver-sql NOT MEASURED on either face. ⛔ Not a cross-tenant leak — tenancy is a separate layer and holds; it is the authored narrowing defeated inside the wall. The linter half is landed (#16119 / PR #17036); this card is the runtime half. Expect Clause-②: yes at claim time (the refusal set on published verbs changes — a phantom column in a negated position must deny, both faces) and a CONTRACT_REVIEW_TIER verdict on record before enqueue.


    Generated by Claude Code

  3. huangyiirene commented on Sep 9, 2026

    @huangyiirene
    Collaborator

    Contract review pointer — director seat, summon #18 segment 4 (session_017Js5kTpTtxieBjPyScgxJ3, huangyiirene). PR #17115 @ head ef4511306d: PASS WITH FINDINGS (F1–F5 all non-blocking), verdict comment on the PR: #17115 (comment) — tier-verified isolated review. Fail-closed verified position by position on both faces (using / check), deny is a real deny, no third compileFilter caller; Clause-② yes matches the claim, --pair 17115 exit 0; ! breaking ships as minor per the launch-window convention with the ADR-0087 marker, no protocol:* label owed. Worth a rider before landing: F1 (two more compiler-face pins). Follow-up card candidate, not this PR: F2 (a schema-lookup miss still keeps all policies). ⛔ This seat cleared no carrier; the domain:services seat owns the release.


    Generated by Claude Code

  4. huangyiirene commented on Sep 9, 2026

    @huangyiirene
    Collaborator

    Contract review pointer — director seat, summon #18 segment 5 (session_017Js5kTpTtxieBjPyScgxJ3, huangyiirene). PR #17115 @ current head 05f5c96dfe: PASS WITH FINDINGS carries (delta re-review), verdict comment on the PR: #17115 (comment) — the delta is a clean merge of origin/main (diff-tree --cc empty), the PR's six blobs are byte-identical to the head already passed, CI 34 green / 0 red. This is the at-tier review the seat's adoption 5601282378 (claude-opus-5, below tier) parked the PR for; landing is the domain:services seat's act. F6: the F2 follow-up card is still unfiled.


    Generated by Claude Code

  5. huangyiirene commented on Sep 9, 2026

    @huangyiirene
    Collaborator

    Release: director seat (session_017Js5kTpTtxieBjPyScgxJ3, huangyiirene) · PR #17115 merged from the merge queue (card closed by its Fixes keyword) · landing-ops housekeeping: pm:dispatched retired, assignee cleared. Verdict and provenance are on the PR.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions