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
- 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.
- As an application developer, I want to write
~t.email.like(pattern), so that I can exclude rows matching a pattern.
- 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.
- 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.
- 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.
- 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.
- 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 ~.
- 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).
- 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.
- 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.
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) needsis_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)rendersNOT (category_id IN (...));~((a) & (b))rendersNOT (... AND ...). No new operator methods, nothing to memorize per operator, and every future predicate form gets negation without further design.User Stories
~t.category_id.in_(ids), so that I can express NOT IN without materializing the complement set myself.~t.email.like(pattern), so that I can exclude rows matching a pattern.~to work on AND/OR compounds, so that I can negate a whole condition group without hand-applying De Morgan.is_transfer=Falsebranch (~t.transfer_out.exists() & ~t.transfer_in.exists()) is spellable the day.exists()lands.&/|, ordering, and paging exactly like un-negated ones, so that adding a~never restructures my query.notkeyword on a predicate, so that the guard points me at~.~(t.amount > 5)excludes NULL rows (exactly as SQLNOTand the existing!=do).Implementation Decisions
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).__invert__returning the not-wrapped node.~is valid on every predicate node; there is no node kind it rejects.not_in, nonot_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).NOTover the rebuilt child condition. No dialect divergence:NOTis uniform across the supported backends.not node,node and node) keeps raising, with its message updated to name~as the supported spelling.!=behavior.~t.rel.exists()) is expressed through this same node — the sibling PRD adds no negation machinery of its own.Testing Decisions
~over each operator kind (equality, ordering comparisons, IN, LIKE),~over compounds, double negation,~mixed with&/|, NULL-row behavior under negation, and composition withorder_by/limit.notnode (leaf child and compound child), asserted from both the Python emitter and the Rust decoder, exactly as the existing compound vector is.~.Out of Scope
.exists()) — the sibling PRD.NOT (...)).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.