Skip to content

Commit a7236a4

Browse files
committed
docs(spec,formula,driver-memory): record the ruled EXCLUDE direction for negative operators over no-value rows (#5299)
The 2026-08-10 maintainer ruling on #5299 takes 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`. No behaviour moves here, and the diff explains why rather than leaving the next reader to re-derive it. The ruled direction REVERSES what #5298 shipped and #5146 ruled before it — which is what the four enrolled `d`-column conformance cases enforce today, and what every SQL compiler in the repo was deliberately bent to (`nullSafeNegative`, plus four independent copies of `nullValueSatisfiesOperator` answering `$nin` -> true and `$notContains` -> true). Flipping `formula` alone would re-open the write-side/read-side split PR #5962 closed, with the sign reversed. Measured on 60f0dd8 by adding the candidate rows to `FILTER_LOGIC_CASES` and running every suite that drives it. Eleven surfaces answer the include direction on `$notContains` and `$nin`; `$exists` is already ruled-correct on both JS evaluators (#5962) and still key-presence on `driver-memory`'s live mingo path and on `driver-mongodb`. The table is in the new family 4 note in the conformance header. Two measured blockers to enrolment, both recorded there: - the DEBT ledger in `scripts/check-driver-conformance.mjs` is per (driver x case-set), not per case, so there is no legal spelling for "this driver fails one row" — a row added ahead of a backend is simply a red gate; - two of the five scored drivers are inside the #5499 investment freeze, and both answer the include direction on their live query paths. Pins re-annotated rather than flipped, and made load-bearing: the formula-side file now asserts the non-negated `$notContains` / `$nin` row sets explicitly, so the cross-backend PR has to move them deliberately. `driver-memory`'s behaviour is untouched per #5499 and per the ruling's own "the early-exit guard stays as-is"; one stale claim in its pin is corrected — it said `formula` reads `$exists` as key-presence, which stopped being true in PR #5962. Refs #5299 #5298 #5962 #5499 #5240 #5146 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UmjKBzRFKnUgkk74DVUqYd
1 parent 60f0dd8 commit a7236a4

5 files changed

Lines changed: 257 additions & 19 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): record the ruled EXCLUDE direction for negative operators over no-value rows (#5299)
6+
7+
`FILTER_LOGIC_CASES` is a published contract — its header tells third-party
8+
driver authors it is "the single source of truth every filter backend is checked
9+
against". Since 2026-08-10 that header has been telling them something the
10+
maintainer has superseded, so this change makes the published document say what
11+
is ruled as well as what is enforced.
12+
13+
**The ruling (#5299, 2026-08-10).** SQL's native three-valued logic is the common
14+
denominator, and both JS evaluators align to it:
15+
16+
> **Negative operators never match no-value rows; the only ways to select "no
17+
> value" are `$exists: false` / `$null: true`.**
18+
19+
Concretely: `$notContains` on a value-less field does not match, `$exists` means
20+
"has a value" (`!= null`) and never key-presence, and `$nin` on a no-value field
21+
does not match.
22+
23+
**No behaviour moves in this change, and the header now says why.** The ruled
24+
direction reverses the one #5298 shipped and #5146 ruled before it — the same
25+
direction the four enrolled `d`-column cases currently enforce, and the direction
26+
every SQL compiler in the repo was deliberately bent to in #5298 (`nullSafeNegative`,
27+
plus four independent copies of `nullValueSatisfiesOperator` answering `$nin`
28+
true and `$notContains` → true). A new "family 4" note in the header carries the
29+
eleven-surface measurement of that gap, taken by adding the candidate rows to the
30+
table and running every suite that drives it.
31+
32+
It also records the two things that block enrolment, both measured rather than
33+
argued:
34+
35+
- the DEBT ledger in `scripts/check-driver-conformance.mjs` is per
36+
(driver × case-set), not per case — there is no way to spell "this driver fails
37+
one row", so a row added ahead of a backend is just a red gate;
38+
- two of the five scored drivers (`driver-memory`, `driver-mongodb`) are inside
39+
the #5499 investment freeze, and both answer the include direction on their live
40+
query paths.
41+
42+
The pin tests in `@objectstack/formula` and `@objectstack/driver-memory` are
43+
re-annotated to match, and the formula-side pins now assert the non-negated
44+
`$notContains` / `$nin` row sets explicitly, so the cross-backend PR that lands
45+
the ruled semantics has to move them deliberately. One stale claim is corrected
46+
while doing it: `driver-memory`'s pin said `formula` reads `$exists` as
47+
key-presence, which stopped being true in PR #5962.

packages/drivers/driver-memory/src/memory-driver-document-not.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,23 @@ describe('[#5324] InMemoryDriver.find compiles a document-level $not', () => {
202202
* with the identical matcher-vs-formula divergence already filed as **#5299**,
203203
* where this measurement is recorded. Pinned as measured so the fix that lands
204204
* there has to move these lines deliberately.
205+
*
206+
* ⚠️ [#5299, ruled 2026-08-10] The ruling is in, and it says the REFERENCE
207+
* column below is the target on all three rows: SQL three-valued logic is the
208+
* common denominator, so **negative operators never match no-value rows; the
209+
* only ways to select "no value" are `$exists: false` / `$null: true`.** The
210+
* `live` column is therefore the side that is wrong on every row here —
211+
* mingo's `$exists` is key-presence, and its `$nin` / `$notContains` match a
212+
* value that is not there.
213+
*
214+
* ⛔ Still not flipped, and by decision rather than by difficulty: this package
215+
* is inside the #5499 investment freeze. Note also what the ruling assumed and
216+
* this file disproves — it says "driver-memory already reads has-value" and
217+
* "driver-memory and SQL already agree", which is true of the reference
218+
* matcher and FALSE of the live query path users actually reach. That is the
219+
* reason this pin exists.
205220
*/
206-
describe('known two-face divergences on a value-less fieldpinned, see #5299', () => {
221+
describe('[#5299] the ruled cells, live vs referencebehaviour frozen (#5499)', () => {
207222
const liveVsReference = async (where: unknown) => ({
208223
live: await idsFrom(nulled, where),
209224
reference: NULLED.filter((r) => match(r, where)).map((r) => r.id),

packages/drivers/driver-memory/src/memory-matcher-not-null-safe.test.ts

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -128,30 +128,68 @@ describe('[#5146] memory-matcher — $not over records with no value', () => {
128128
});
129129
});
130130

131-
// ── Where this matcher and `formula` disagree — pinned, not harmonised ─────
132-
133-
describe('known disagreements with formula.matchesFilterCondition (NOT ruled on by #5146)', () => {
131+
// ── The three cells #5299 ruled on — behaviour FROZEN, annotation current ──
132+
133+
/**
134+
* [#5299, ruled 2026-08-10] These three cells were filed as "known
135+
* disagreements with `formula`, not ruled on by #5146". They are ruled now:
136+
* SQL's native three-valued logic is the common denominator, so **negative
137+
* operators never match no-value rows; the only ways to select "no value" are
138+
* `$exists: false` / `$null: true`.**
139+
*
140+
* ⛔ Nothing below is flipped, and the reason is not inertia. This package is
141+
* inside the #5499 investment freeze, and the ruling itself says
142+
* `checkCondition`'s early-exit guard STAYS AS IT IS. What the ruling changed
143+
* is the annotation: the section is no longer "a divergence nobody has ruled
144+
* on", it is "a ruled target, with this matcher's distance from it measured".
145+
*
146+
* Re-measured on `60f0dd8`, because the old wording had gone stale in a way
147+
* that mattered — it named `formula` as the key-presence reader on `$exists`,
148+
* and `formula` stopped being that in PR #5962 (#5298 ③ / #5369). Where each
149+
* cell actually stands:
150+
*
151+
* `$exists` CONVERGED, and this matcher was already right. Both
152+
* evaluators read "has a value"; the formula-side assertion
153+
* lives in `matches-filter-not-null-safe.test.ts`. Kept here
154+
* because the two OTHER faces of this package — the live
155+
* mingo query path and the analytics face — still read
156+
* key-presence, so the package disagrees with itself and this
157+
* test is the face that is correct.
158+
* `$notContains` This matcher ALREADY answers the ruled semantics; every
159+
* other surface in the repo (including all four SQL
160+
* compilers, deliberately, via #5298's `nullSafeNegative`)
161+
* answers the opposite. Here the gap is theirs, not ours.
162+
* `$nin` HALF right: a missing key already does not match, a
163+
* present-but-null value still does. The ruled answer is "no"
164+
* for both. Frozen at this state.
165+
*/
166+
describe('[#5299] the ruled no-value cells — target recorded, behaviour frozen (#5499)', () => {
134167
it('$nin: an ABSENT field is treated differently from a null one', () => {
135168
// The early `value === undefined` guard in `checkCondition` exempts only
136169
// `$exists` / `$ne` / `$null`, so an absent field fails `$nin` outright
137-
// while a null field passes it. `formula` answers "not among" for both.
138-
// Pinned as measured; the ruling belongs to the issue that records it.
170+
// while a null field passes it. The ruling keeps this guard; the NULL half
171+
// is the part still short of the ruled answer, and it is frozen.
139172
expect(ids(NULLED, { $not: { stage: { $nin: ['won'] } } })).toEqual(['1']);
140173
expect(ids(MISSING, { $not: { stage: { $nin: ['won'] } } })).toEqual(['1', '3', '4']);
141174
});
142175

143-
it('$notContains: a value-less field does NOT satisfy it here', () => {
144-
// `typeof null !== 'string'` → false, so the negation matches. `formula`
145-
// answers true for the same record, and `driver-sql` follows `formula`.
176+
it('$notContains: a value-less field does NOT satisfy it here — the RULED answer', () => {
177+
// `typeof null !== 'string'` → false, so the negation matches. This is
178+
// what #5299 ruled canonical. `formula` and all four SQL compilers answer
179+
// the opposite today; moving them is a cross-backend programme, not a
180+
// change to this file.
146181
expect(matched({ $not: { stage: { $notContains: 'w' } } })).toEqual(['1', '3', '4']);
147182
});
148183

149-
it('$exists: a present-but-null field counts as NOT existing here', () => {
150-
// `formula` reads `$exists` as "the key is present" (a null value exists);
151-
// this matcher reads it as "has a value". Same answer for an absent field,
152-
// different for a null one.
184+
it('$exists: a present-but-null field counts as NOT existing here — CONVERGED', () => {
185+
// Both readings of "no value" answer alike, and `formula` now agrees:
186+
// "has a value", the strict mirror of `$null` (#5298 ③ / #5369, PR #5962).
187+
// No longer a disagreement — the assertion is kept because this package's
188+
// other two filter faces still read key-presence.
153189
expect(ids(NULLED, { $not: { stage: { $exists: true } } })).toEqual(['3', '4']);
154190
expect(ids(MISSING, { $not: { stage: { $exists: true } } })).toEqual(['3', '4']);
191+
expect(ids(NULLED, { stage: { $exists: true } })).toEqual(['1', '2']);
192+
expect(ids(NULLED, { stage: { $exists: true } })).toEqual(ids(NULLED, { stage: { $null: false } }));
155193
});
156194
});
157195
});

packages/formula/src/matches-filter-not-null-safe.test.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,12 @@ describe('[#5146] matchesFilterCondition — $not over records with no value', (
116116

117117
it('$not of $notContains does NOT match them — the mirror case', () => {
118118
// A value-less field satisfies `$notContains` here, so the negation
119-
// rejects it. `driver-sql` follows this answer; `driver-memory` answers
120-
// the opposite for a null-valued field, which is filed on its own.
119+
// rejects it. `driver-sql` follows this answer; `driver-memory`'s
120+
// REFERENCE matcher answers the opposite for a null-valued field.
121+
//
122+
// ⚠️ [#5299, ruled 2026-08-10] This is the SUPERSEDED direction — see the
123+
// block at the bottom of this file for the ruled target and the measured
124+
// reason nothing has moved yet.
121125
expect(matched({ $not: { stage: { $notContains: 'w' } } })).toEqual(['1']);
122126
});
123127

@@ -182,4 +186,59 @@ describe('[#5146] matchesFilterCondition — $not over records with no value', (
182186
}
183187
});
184188
});
189+
190+
// ── The NON-negated negatives — pinned against a ruling that has not landed ─
191+
192+
/**
193+
* [#5299, ruled 2026-08-10] The maintainer took SQL's native three-valued
194+
* logic as the common denominator: **negative operators never match no-value
195+
* rows; the only ways to select "no value" are `$exists: false` /
196+
* `$null: true`.** Under that rule this evaluator answers `['2']` below.
197+
*
198+
* It answers `['2','3','4']`, and that is pinned here rather than fixed,
199+
* because flipping it ALONE would re-open the exact hole PR #5962 closed. That
200+
* PR converged `formula` (the RLS write-side `check`) and `read-scope-sql`
201+
* (the read-side lowering) in ONE change precisely because they are
202+
* security-coupled: one policy string must not admit two row sets. Every SQL
203+
* face still emits `nullSafeNegative` for these two operators
204+
* (`col IS NULL OR col NOT IN (…)`), so a formula-only flip would make an RLS
205+
* `check` DENY a write on a null field that the read scope still RETURNS —
206+
* #5962's defect with the sign reversed.
207+
*
208+
* So these assertions are load-bearing in both directions. They say what this
209+
* evaluator does today, and they are the tripwire the cross-backend PR must
210+
* step on: whoever lands the ruled semantics changes these lines DELIBERATELY,
211+
* in the same PR that moves `driver-sql`, `read-scope-sql`, `filter-normalizer`
212+
* and `driver-turso`'s remote transport — not one evaluator at a time.
213+
*
214+
* The full eleven-surface measurement and the enrolment blocker (the
215+
* conformance ledger has no per-row DEBT, and two of the five scored drivers
216+
* are inside the #5499 freeze) are recorded on family 4 in
217+
* `@objectstack/spec`'s `filter-logic-conformance.ts` header.
218+
*/
219+
describe('[#5299] $notContains / $nin over a value-less field — the pre-ruling answer', () => {
220+
it('$notContains MATCHES a value-less field — ruled target is that it must NOT', () => {
221+
expect(matched({ stage: { $notContains: 'w' } })).toEqual(['2', '3', '4']);
222+
});
223+
224+
it('$nin MATCHES a value-less field — ruled target is that it must NOT', () => {
225+
expect(matched({ stage: { $nin: ['won'] } })).toEqual(['2', '3', '4']);
226+
});
227+
228+
it('$ne answers identically — one family, and the reason a partial flip is incoherent', () => {
229+
// `$nin` is the list form of `$ne`, and `$ne` is ENROLLED in
230+
// `FILTER_LOGIC_CASES` asserting exactly this row set. Moving `$nin`
231+
// without `$ne` splits this evaluator against itself and against the gate.
232+
expect(matched({ stage: { $ne: 'won' } })).toEqual(['2', '3', '4']);
233+
expect(matched({ stage: { $nin: ['won'] } })).toEqual(matched({ stage: { $ne: 'won' } }));
234+
});
235+
236+
it('the ruled ESCAPE HATCH already works, in both directions', () => {
237+
// Whatever happens to the three cells above, the rule's second half is
238+
// already true here: "no value" is selectable, precisely, today.
239+
expect(matched({ stage: { $exists: false } })).toEqual(['3', '4']);
240+
expect(matched({ stage: { $null: true } })).toEqual(['3', '4']);
241+
expect(matched({ stage: { $exists: true } })).toEqual(['1', '2']);
242+
});
243+
});
185244
});

0 commit comments

Comments
 (0)