Skip to content

feat PRD: Uniform predicate negation — prefix ~ as a NOT wire node #310

Description

@0x054

Origin: design session for #307/#308 (2026-07-18), owner-approved. Decision record: ADR-0008 (predicate negation is uniform ~, a NOT node in the wire). Sibling PRD: existence tests (filed alongside; depends on this one — NOT EXISTS is spelled ~t.rel.exists()).

Problem Statement

As a ferro user I cannot negate most predicates. Negation exists only where a comparison operator happens to have a pair (==/!=, </>=): there is no NOT IN, no NOT LIKE, and no way to negate a compound. What should be one rule is a lookup table with holes — and the holes sit exactly where real queries need them ("transactions not in these categories", "emails not matching this pattern"). The blocked Pinch workload (#307) needs is_transfer=False, which is a NOT EXISTS — unspellable today and unspellable even after existence tests land, without a negation story.

Solution

One universal rule: prefix ~ negates any predicate node — leaf comparison, AND/OR compound, or (once the sibling PRD lands) existence test. ~t.category_id.in_(ids) renders NOT (category_id IN (...)); ~((a) & (b)) renders NOT (... AND ...). No new operator methods, nothing to memorize per operator, and every future predicate form gets negation without further design.

User Stories

  1. As an application developer, I want to write ~t.category_id.in_(ids), so that I can express NOT IN without materializing the complement set myself.
  2. As an application developer, I want to write ~t.email.like(pattern), so that I can exclude rows matching a pattern.
  3. As an application developer, I want ~ to work on AND/OR compounds, so that I can negate a whole condition group without hand-applying De Morgan.
  4. As an application developer, I want exactly one negation rule to learn, so that I never have to check a per-operator table to know whether a predicate is negatable.
  5. As a Pinch backend developer, I want negation in place before existence tests ship, so that the is_transfer=False branch (~t.transfer_out.exists() & ~t.transfer_in.exists()) is spellable the day .exists() lands.
  6. As an application developer, I want negated predicates to compose with &/|, ordering, and paging exactly like un-negated ones, so that adding a ~ never restructures my query.
  7. As an application developer, I want a clear error with the supported spelling when I misuse Python's not keyword on a predicate, so that the guard points me at ~.
  8. As a docs reader, I want a central note on SQL three-valued logic under negation, so that I am not surprised that ~(t.amount > 5) excludes NULL rows (exactly as SQL NOT and the existing != do).
  9. As a maintainer, I want the negation shape pinned by a hand-authored golden vector, so that the Python emitter and Rust decoder can never silently disagree about it.
  10. As a maintainer, I want negation carried as one recursive wire node rather than per-operator negative forms, so that the operator surface stays closed and future predicate kinds inherit negation for free.

Implementation Decisions

  • Negation is a new recursive node kind in the query wire IR (not, wrapping a single child node), beside the existing leaf and compound kinds. QueryIR version bumps; a hand-authored golden vector pins the shape (the contract-review moment, per the golden-vector convention).
  • The Python predicate node gains an __invert__ returning the not-wrapped node. ~ is valid on every predicate node; there is no node kind it rejects.
  • No per-operator negative forms are added (no not_in, no not_like, no negation flags on any node). NOT (x IN (...)) is already correct SQL; De Morgan expansion was rejected because it cannot close over IN/LIKE (ADR-0008).
  • The Rust condition builder gains one recursion case emitting SQL NOT over the rebuilt child condition. No dialect divergence: NOT is uniform across the supported backends.
  • The existing guard on Python truthiness misuse (not node, node and node) keeps raising, with its message updated to name ~ as the supported spelling.
  • SQL three-valued logic is a documentation duty, not a design change: one central docs note with rendered SQL showing that negation excludes NULL-valued rows, matching the existing != behavior.
  • Existence-test negation (~t.rel.exists()) is expressed through this same node — the sibling PRD adds no negation machinery of its own.

Testing Decisions

  • Tests assert external behavior only: result sets at the end-to-end query seam and payload shape at the golden-vector wire seam. No assertions on internal node structure.
  • End-to-end, on both database backends via the existing backend matrix, in the style of the existing per-feature query test files: ~ over each operator kind (equality, ordering comparisons, IN, LIKE), ~ over compounds, double negation, ~ mixed with &/|, NULL-row behavior under negation, and composition with order_by/limit.
  • One hand-authored golden vector for the not node (leaf child and compound child), asserted from both the Python emitter and the Rust decoder, exactly as the existing compound vector is.
  • Error-path tests pin the truthiness-guard message naming ~.

Out of Scope

  • Existence tests / reverse-relation predicates (.exists()) — the sibling PRD.
  • Any per-operator negative forms.
  • Planner-style rewrites or push-down of NOT (rendering is a faithful NOT (...)).
  • Changes to include(), projections, or aggregates.

Further Notes

Independently shippable and useful on its own (closes the NOT IN / NOT LIKE gap). Ships first; the existence-tests PRD builds on the node this PRD introduces.

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