diff --git a/.changeset/filter-null-semantics-ruled-target.md b/.changeset/filter-null-semantics-ruled-target.md new file mode 100644 index 0000000000..7900fa41b2 --- /dev/null +++ b/.changeset/filter-null-semantics-ruled-target.md @@ -0,0 +1,47 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): record the ruled EXCLUDE direction for negative operators over no-value rows (#5299) + +`FILTER_LOGIC_CASES` is a published contract — its header tells third-party +driver authors it is "the single source of truth every filter backend is checked +against". Since 2026-08-10 that header has been telling them something the +maintainer has superseded, so this change makes the published document say what +is ruled as well as what is enforced. + +**The ruling (#5299, 2026-08-10).** SQL's native three-valued logic is the common +denominator, and both JS evaluators align to it: + +> **Negative operators never match no-value rows; the only ways to select "no +> value" are `$exists: false` / `$null: true`.** + +Concretely: `$notContains` on a value-less field does not match, `$exists` means +"has a value" (`!= null`) and never key-presence, and `$nin` on a no-value field +does not match. + +**No behaviour moves in this change, and the header now says why.** The ruled +direction reverses the one #5298 shipped and #5146 ruled before it — the same +direction the four enrolled `d`-column cases currently enforce, and the direction +every SQL compiler in the repo was deliberately bent to in #5298 (`nullSafeNegative`, +plus four independent copies of `nullValueSatisfiesOperator` answering `$nin` → +true and `$notContains` → true). A new "family 4" note in the header carries the +eleven-surface measurement of that gap, taken by adding the candidate rows to the +table and running every suite that drives it. + +It also records the two things that block enrolment, both measured rather than +argued: + +- the DEBT ledger in `scripts/check-driver-conformance.mjs` is per + (driver × case-set), not per case — there is no way to spell "this driver fails + one row", so a row added ahead of a backend is just a red gate; +- two of the five scored drivers (`driver-memory`, `driver-mongodb`) are inside + the #5499 investment freeze, and both answer the include direction on their live + query paths. + +The pin tests in `@objectstack/formula` and `@objectstack/driver-memory` are +re-annotated to match, and the formula-side pins now assert the non-negated +`$notContains` / `$nin` row sets explicitly, so the cross-backend PR that lands +the ruled semantics has to move them deliberately. One stale claim is corrected +while doing it: `driver-memory`'s pin said `formula` reads `$exists` as +key-presence, which stopped being true in PR #5962. diff --git a/packages/drivers/driver-memory/src/memory-driver-document-not.test.ts b/packages/drivers/driver-memory/src/memory-driver-document-not.test.ts index a886d69ae3..de3f87d469 100644 --- a/packages/drivers/driver-memory/src/memory-driver-document-not.test.ts +++ b/packages/drivers/driver-memory/src/memory-driver-document-not.test.ts @@ -202,8 +202,23 @@ describe('[#5324] InMemoryDriver.find compiles a document-level $not', () => { * with the identical matcher-vs-formula divergence already filed as **#5299**, * where this measurement is recorded. Pinned as measured so the fix that lands * there has to move these lines deliberately. + * + * ⚠️ [#5299, ruled 2026-08-10] The ruling is in, and it says the REFERENCE + * column below is the target on all three rows: SQL three-valued logic is the + * common denominator, so **negative operators never match no-value rows; the + * only ways to select "no value" are `$exists: false` / `$null: true`.** The + * `live` column is therefore the side that is wrong on every row here — + * mingo's `$exists` is key-presence, and its `$nin` / `$notContains` match a + * value that is not there. + * + * ⛔ Still not flipped, and by decision rather than by difficulty: this package + * is inside the #5499 investment freeze. Note also what the ruling assumed and + * this file disproves — it says "driver-memory already reads has-value" and + * "driver-memory and SQL already agree", which is true of the reference + * matcher and FALSE of the live query path users actually reach. That is the + * reason this pin exists. */ - describe('known two-face divergences on a value-less field — pinned, see #5299', () => { + describe('[#5299] the ruled cells, live vs reference — behaviour frozen (#5499)', () => { const liveVsReference = async (where: unknown) => ({ live: await idsFrom(nulled, where), reference: NULLED.filter((r) => match(r, where)).map((r) => r.id), diff --git a/packages/drivers/driver-memory/src/memory-matcher-not-null-safe.test.ts b/packages/drivers/driver-memory/src/memory-matcher-not-null-safe.test.ts index 3b10cf18b5..3d319fb11b 100644 --- a/packages/drivers/driver-memory/src/memory-matcher-not-null-safe.test.ts +++ b/packages/drivers/driver-memory/src/memory-matcher-not-null-safe.test.ts @@ -128,30 +128,68 @@ describe('[#5146] memory-matcher — $not over records with no value', () => { }); }); - // ── Where this matcher and `formula` disagree — pinned, not harmonised ───── - - describe('known disagreements with formula.matchesFilterCondition (NOT ruled on by #5146)', () => { + // ── The three cells #5299 ruled on — behaviour FROZEN, annotation current ── + + /** + * [#5299, ruled 2026-08-10] These three cells were filed as "known + * disagreements with `formula`, not ruled on by #5146". They are ruled now: + * SQL's native three-valued logic is the common denominator, so **negative + * operators never match no-value rows; the only ways to select "no value" are + * `$exists: false` / `$null: true`.** + * + * ⛔ Nothing below is flipped, and the reason is not inertia. This package is + * inside the #5499 investment freeze, and the ruling itself says + * `checkCondition`'s early-exit guard STAYS AS IT IS. What the ruling changed + * is the annotation: the section is no longer "a divergence nobody has ruled + * on", it is "a ruled target, with this matcher's distance from it measured". + * + * Re-measured on `60f0dd8`, because the old wording had gone stale in a way + * that mattered — it named `formula` as the key-presence reader on `$exists`, + * and `formula` stopped being that in PR #5962 (#5298 ③ / #5369). Where each + * cell actually stands: + * + * `$exists` CONVERGED, and this matcher was already right. Both + * evaluators read "has a value"; the formula-side assertion + * lives in `matches-filter-not-null-safe.test.ts`. Kept here + * because the two OTHER faces of this package — the live + * mingo query path and the analytics face — still read + * key-presence, so the package disagrees with itself and this + * test is the face that is correct. + * `$notContains` This matcher ALREADY answers the ruled semantics; every + * other surface in the repo (including all four SQL + * compilers, deliberately, via #5298's `nullSafeNegative`) + * answers the opposite. Here the gap is theirs, not ours. + * `$nin` HALF right: a missing key already does not match, a + * present-but-null value still does. The ruled answer is "no" + * for both. Frozen at this state. + */ + describe('[#5299] the ruled no-value cells — target recorded, behaviour frozen (#5499)', () => { it('$nin: an ABSENT field is treated differently from a null one', () => { // The early `value === undefined` guard in `checkCondition` exempts only // `$exists` / `$ne` / `$null`, so an absent field fails `$nin` outright - // while a null field passes it. `formula` answers "not among" for both. - // Pinned as measured; the ruling belongs to the issue that records it. + // while a null field passes it. The ruling keeps this guard; the NULL half + // is the part still short of the ruled answer, and it is frozen. expect(ids(NULLED, { $not: { stage: { $nin: ['won'] } } })).toEqual(['1']); expect(ids(MISSING, { $not: { stage: { $nin: ['won'] } } })).toEqual(['1', '3', '4']); }); - it('$notContains: a value-less field does NOT satisfy it here', () => { - // `typeof null !== 'string'` → false, so the negation matches. `formula` - // answers true for the same record, and `driver-sql` follows `formula`. + it('$notContains: a value-less field does NOT satisfy it here — the RULED answer', () => { + // `typeof null !== 'string'` → false, so the negation matches. This is + // what #5299 ruled canonical. `formula` and all four SQL compilers answer + // the opposite today; moving them is a cross-backend programme, not a + // change to this file. expect(matched({ $not: { stage: { $notContains: 'w' } } })).toEqual(['1', '3', '4']); }); - it('$exists: a present-but-null field counts as NOT existing here', () => { - // `formula` reads `$exists` as "the key is present" (a null value exists); - // this matcher reads it as "has a value". Same answer for an absent field, - // different for a null one. + it('$exists: a present-but-null field counts as NOT existing here — CONVERGED', () => { + // Both readings of "no value" answer alike, and `formula` now agrees: + // "has a value", the strict mirror of `$null` (#5298 ③ / #5369, PR #5962). + // No longer a disagreement — the assertion is kept because this package's + // other two filter faces still read key-presence. expect(ids(NULLED, { $not: { stage: { $exists: true } } })).toEqual(['3', '4']); expect(ids(MISSING, { $not: { stage: { $exists: true } } })).toEqual(['3', '4']); + expect(ids(NULLED, { stage: { $exists: true } })).toEqual(['1', '2']); + expect(ids(NULLED, { stage: { $exists: true } })).toEqual(ids(NULLED, { stage: { $null: false } })); }); }); }); diff --git a/packages/formula/src/matches-filter-not-null-safe.test.ts b/packages/formula/src/matches-filter-not-null-safe.test.ts index 71d73c63e6..b898a9b194 100644 --- a/packages/formula/src/matches-filter-not-null-safe.test.ts +++ b/packages/formula/src/matches-filter-not-null-safe.test.ts @@ -116,8 +116,12 @@ describe('[#5146] matchesFilterCondition — $not over records with no value', ( it('$not of $notContains does NOT match them — the mirror case', () => { // A value-less field satisfies `$notContains` here, so the negation - // rejects it. `driver-sql` follows this answer; `driver-memory` answers - // the opposite for a null-valued field, which is filed on its own. + // rejects it. `driver-sql` follows this answer; `driver-memory`'s + // REFERENCE matcher answers the opposite for a null-valued field. + // + // ⚠️ [#5299, ruled 2026-08-10] This is the SUPERSEDED direction — see the + // block at the bottom of this file for the ruled target and the measured + // reason nothing has moved yet. expect(matched({ $not: { stage: { $notContains: 'w' } } })).toEqual(['1']); }); @@ -182,4 +186,59 @@ describe('[#5146] matchesFilterCondition — $not over records with no value', ( } }); }); + + // ── The NON-negated negatives — pinned against a ruling that has not landed ─ + + /** + * [#5299, ruled 2026-08-10] The maintainer took SQL's native three-valued + * logic as the common denominator: **negative operators never match no-value + * rows; the only ways to select "no value" are `$exists: false` / + * `$null: true`.** Under that rule this evaluator answers `['2']` below. + * + * It answers `['2','3','4']`, and that is pinned here rather than fixed, + * because flipping it ALONE would re-open the exact hole PR #5962 closed. That + * PR converged `formula` (the RLS write-side `check`) and `read-scope-sql` + * (the read-side lowering) in ONE change precisely because they are + * security-coupled: one policy string must not admit two row sets. Every SQL + * face still emits `nullSafeNegative` for these two operators + * (`col IS NULL OR col NOT IN (…)`), so a formula-only flip would make an RLS + * `check` DENY a write on a null field that the read scope still RETURNS — + * #5962's defect with the sign reversed. + * + * So these assertions are load-bearing in both directions. They say what this + * evaluator does today, and they are the tripwire the cross-backend PR must + * step on: whoever lands the ruled semantics changes these lines DELIBERATELY, + * in the same PR that moves `driver-sql`, `read-scope-sql`, `filter-normalizer` + * and `driver-turso`'s remote transport — not one evaluator at a time. + * + * The full eleven-surface measurement and the enrolment blocker (the + * conformance ledger has no per-row DEBT, and two of the five scored drivers + * are inside the #5499 freeze) are recorded on family 4 in + * `@objectstack/spec`'s `filter-logic-conformance.ts` header. + */ + describe('[#5299] $notContains / $nin over a value-less field — the pre-ruling answer', () => { + it('$notContains MATCHES a value-less field — ruled target is that it must NOT', () => { + expect(matched({ stage: { $notContains: 'w' } })).toEqual(['2', '3', '4']); + }); + + it('$nin MATCHES a value-less field — ruled target is that it must NOT', () => { + expect(matched({ stage: { $nin: ['won'] } })).toEqual(['2', '3', '4']); + }); + + it('$ne answers identically — one family, and the reason a partial flip is incoherent', () => { + // `$nin` is the list form of `$ne`, and `$ne` is ENROLLED in + // `FILTER_LOGIC_CASES` asserting exactly this row set. Moving `$nin` + // without `$ne` splits this evaluator against itself and against the gate. + expect(matched({ stage: { $ne: 'won' } })).toEqual(['2', '3', '4']); + expect(matched({ stage: { $nin: ['won'] } })).toEqual(matched({ stage: { $ne: 'won' } })); + }); + + it('the ruled ESCAPE HATCH already works, in both directions', () => { + // Whatever happens to the three cells above, the rule's second half is + // already true here: "no value" is selectable, precisely, today. + expect(matched({ stage: { $exists: false } })).toEqual(['3', '4']); + expect(matched({ stage: { $null: true } })).toEqual(['3', '4']); + expect(matched({ stage: { $exists: true } })).toEqual(['1', '2']); + }); + }); }); diff --git a/packages/spec/src/data/filter-logic-conformance.ts b/packages/spec/src/data/filter-logic-conformance.ts index 9adff89bbb..ed1891774a 100644 --- a/packages/spec/src/data/filter-logic-conformance.ts +++ b/packages/spec/src/data/filter-logic-conformance.ts @@ -73,12 +73,18 @@ * {@link FilterLogicRow.d} column carries it, and the four `d`-column cases * below enforce it on every backend. * + * ⚠️ That answer is the INCLUDE direction, and a later ruling has replaced it as + * the TARGET without any backend having moved yet. The four `d`-column cases + * below still state what the tree does, which is what a conformance table is + * for — but do not read them as the settled semantics. Family 4 below is the + * ruling that supersedes them and the measurement of what it costs. + * * ## Case families that are RULED but not yet enrolled * - * The one family left was ruled by the maintainer and is implemented in some - * backends. It is not in the table yet — a red row here does not enforce a + * Both families left were ruled by the maintainer and are implemented in some + * backends. They are not in the table yet — a red row here does not enforce a * ruling, it just turns another lane's unfinished work into this table's - * failure, and the family still has a blocker standing, named below. Add the + * failure, and each family still has a blocker standing, named below. Add the * rows in the PR that closes the gap, not before. * * Two families have GRADUATED out of this note, and how they did is the note's @@ -107,7 +113,7 @@ * remains" written from a reading of the backend LIST rather than from a * measurement is the sentence that hides the next one. * - * The family numbering of the one that remains is kept as its historical id. + * The family numbering of the ones that remain is kept as their historical id. * * ### 3. `{ field: {} }` — a field constrained by zero operators (#5240) * @@ -124,6 +130,70 @@ * `expectRejection` discriminant, or a sibling table) — deliberately not * invented here. The case lands with that extension, alongside the * schema-side narrowing that stays with the spec lane. + * + * ### 4. Negative operators over a no-value row — the EXCLUDE direction (#5299) + * + * Ruled by the maintainer on **2026-08-10** (#5299): *SQL three-valued logic is + * the common denominator; align both JS evaluators to it.* Concretely — + * `$notContains` on a value-less field does NOT match; `$exists` means "has a + * value" (`!= null`), never key-presence; `$nin` on a no-value field does NOT + * match. Stated for authors as one rule: **negative operators never match + * no-value rows; select "no value" with `$exists: false` / `$null: true`.** + * + * This REVERSES the include direction #5298 ruled on 2026-08-06 and #5146 ruled + * for `$not` before it — the same direction the four `d`-column cases above + * currently enforce. So the two enrolled rows `$ne returns the rows with no + * value` and `$not returns the rows with no value` are not neighbours of this + * family, they are the SAME family stating the opposite answer: under native + * three-valued SQL, `d <> 'v1'` and `NOT (d = 'v1')` are UNKNOWN for a NULL `d` + * and return `['2']`, not `['2','3','4']`. Enrolling family 4 therefore means + * re-ruling those two rows in the same PR, not adding rows beside them. + * + * ⛔ What blocks enrolment is NOT a missing wording, and it is worth stating + * because the obvious workaround does not exist: **the DEBT ledger in + * `scripts/check-driver-conformance.mjs` is per (driver × case-set), not per + * case.** An entry says "this driver's suite does not import this marker at + * all". There is no spelling for "this driver fails one row while passing the + * other thirty-five", so a row added ahead of a backend is simply a red gate — + * the thing #5903's note calls "a gate that reports a known red", which teaches + * every agent reading CI to discount the colour. + * + * The measurement, taken on `60f0dd8` by adding the candidate rows to this + * table and running every suite that drives it. `MATCH` = a no-value row + * satisfies the operator, i.e. the include direction the ruling reverses: + * + * | Surface | `$notContains` | `$nin` | `$exists: true` on a null value | + * |---|---|---|---| + * | `formula` `matchesFilterCondition` | MATCH | MATCH | no — already ruled-correct (#5962) | + * | `driver-memory` reference matcher | no — already ruled-correct | MATCH on a null value, no on a missing key | no — already ruled-correct | + * | `driver-memory` live mingo path | MATCH | MATCH | MATCH — reads KEY-PRESENCE | + * | `driver-memory` analytics face | MATCH | MATCH | MATCH — reads KEY-PRESENCE | + * | `driver-sql` / `driver-sqlite-wasm` / `driver-turso` local | MATCH | MATCH | no — `IS NOT NULL` | + * | `driver-turso` REMOTE | MATCH | MATCH | no — `IS NOT NULL` | + * | `driver-mongodb` `translateFilter` | MATCH | MATCH | MATCH — mongo `$exists` IS key-presence | + * | `service-analytics` `read-scope-sql` | MATCH | MATCH | no — `IS NOT NULL` | + * | `service-analytics` `filter-normalizer` | MATCH | MATCH | no — `IS NOT NULL` | + * + * Every `MATCH` above is DELIBERATE, not drift: #5298 put it there. The SQL + * faces reach it through `nullSafeNegative` (`col IS NULL OR col NOT LIKE ?`) + * and the four independent copies of `nullValueSatisfiesOperator` + * (`$nin` → true, `$notContains` → true) in `sql-driver.ts`, + * `read-scope-sql.ts`, `filter-normalizer.ts` and `remote-transport.ts`. So + * "SQL already agrees" is false of this tree: SQL was bent TOWARDS the JS + * answer, and the ruling asks for it to be bent back. + * + * Two of the eleven surfaces — every `driver-memory` face and `driver-mongodb` + * — are inside the #5499 investment freeze, and they are two of the FIVE + * drivers this gate scores. Since the ledger cannot carry a per-row exemption, + * enrolment cannot happen while the freeze stands, whatever the other nine do. + * + * ⚠️ `$exists` is the one cell of the three that is NOT open on the JS + * evaluators: `formula` and `driver-memory`'s reference matcher both already + * read "has a value" (#5298 ③ / #5369, landed in #5962). What is still open on + * that cell is `driver-memory`'s live mingo path and `driver-mongodb`, both of + * which read key-presence — both frozen. So the ruling's `$exists` leg needs no + * work on the surface it names, and cannot be enrolled because of two it does + * not. */ import type { FilterCondition } from './filter.zod'; @@ -314,6 +384,15 @@ export const FILTER_LOGIC_CASES: readonly FilterLogicCase[] = [ // answer them: it was the last backend of eleven still returning `['2']`, and // until it did, enrolling these two rows would have been a gate that reports a // known red — which teaches every agent reading CI to discount the colour. + // + // ⚠️ [#5299, ruled 2026-08-10] These two rows now state the SUPERSEDED + // direction. The ruling took SQL's native three-valued logic as the common + // denominator, under which `d <> 'v1'` and `NOT (d = 'v1')` are UNKNOWN for a + // NULL `d` and both rows become `['2']`. They are left as they are on purpose: + // they are what all eleven surfaces answer today, and a conformance table that + // states the target instead of the tree is a gate reporting a known red. They + // move in the PR that moves the backends — see family 4 in this file's header + // for the measured blocker list. { name: '$ne returns the rows with no value', filter: { d: { $ne: 'v1' } },