Skip to content

Refuse the query-language filter, on a document that satisfies P and not-P - #12

Merged
aoreshkov merged 1 commit into
mainfrom
refuse-the-query-language-filter
Aug 9, 2026
Merged

Refuse the query-language filter, on a document that satisfies P and not-P#12
aoreshkov merged 1 commit into
mainfrom
refuse-the-query-language-filter

Conversation

@aoreshkov

Copy link
Copy Markdown
Owner

§10.1b proposes a filter predicate in the query language. The entry recorded it as a decision the module layout was arranged to keep open: a query-language filter would put RFC 9535's comparison semantics in front of the planner, and rabosh-jsonpath sits beside the chain precisely so that cannot happen by refactoring.

The decision did not have to be taken, because the feature is not well-formed. Same shape as §10.2 and found the same way — check whether the thing is definable before deciding whether it is allowed.

The counterexample is two lines

A Predicate is a boolean over a document. A filter selector is a selector over a nodelist. The only bridge between them is "the document matches when the nodelist is non-empty" — the obvious one, and the one anybody building this would reach for.

on {"tags":["a","b"]} selects non-empty?
$.tags[?@ == 'b'] $['tags'][1] yes
$.tags[?!(@ == 'b')] $['tags'][0] yes

A filter and its negation both select from the same document. Under the bridge, P and ¬P are both true of it, and where(f) and where(not(f)) would return the same row. That is not a subtlety to document around: the law of the excluded middle is what not(…) means, and no planner change or index could repair it, because the defect is in the meaning rather than in the evaluation.

Neither design is at fault, which is exactly why neither can adopt the other. A filter's ! negates a test about one node while the selector stays existential over the rest; a Predicate's not negates the answer for the document, after the existential has been folded. Both quantify correctly, over different things.

Two more shapes, and the third is the dangerous one:

  • @.tags == 'b' compares the array with a string and is false, where path("$.tags[*]") eq "b" is existential over the elements and is true. Same document, same apparent question, opposite answers — and RFC 9535's answer is the right one for RFC 9535.
  • An absent field compares false and its negation selects every child, which matches what the engine's not does for a missing path. A bridge that is right most of the time is the shape that ships.

Where it is pinned, and why that location is part of the answer

FilterIsNotAPredicateTest lives in rabosh-jsonpath and needs nothing else — the differential that would compare the two semantics directly cannot be written anywhere in this repository without acquiring the very edge the module layout forbids. So what is pinned is the half that decides the question: the RFC side alone shows a filter and its negation both selecting. The storage side's complement is already pinned in DocumentMatcherTest, where not(path("$.tags[*]") eq "b") is false on that document, exactly as a document predicate must be. Two files, no edge, and the pair is the whole argument.

This is .claude/rules/index-and-query.md's "a negated leaf is never a flipped operator" arriving from outside, in a syntax users already know. That rule notes the rewrite is the most natural-looking simplification in the layer and that it deletes documents from a result silently.

What would revive it: a spelling that says which way it quantifies — which is what elemMatch already is. elemMatch(p, operand) is the filter selector expressed as a document predicate, with the existential written down and the negation defined over the document. 0.2.0 built the feature under a different name, and the name is the part that carries the semantics.

This closes phase 20 §10

All nine tracked items are now settled: four built, four refused on evidence, one answered by a test.

The pattern worth keeping is that three of the refusals were the same defect — R.2's prefix scan returned a subset where a superset was required, §10.2's witness set was empty for negated shapes, and §10.1b's bridge made a filter and its negation both true. Each was filed as needing a decision or a measurement; each turned out to be a question with no well-formed answer, detectable in an afternoon. The three adversarial shapes that did all the work were the same every time: an empty result, a negation, and a value of the wrong type.

Gate

  • ./gradlew build green, checkKotlinAbi green with no dump rewritten, -p build-logic check green.
  • FilterIsNotAPredicateTest: 3 cases, 0 failures, confirmed under --rerun-tasks.
  • No format change, no .api entry, no main source changed — one test file, as a refusal's deliverable should be.

🤖 Generated with Claude Code

…not-P

§10.1b proposes a filter predicate in the query language. The entry recorded it
as a decision the module layout was arranged to keep open: a query-language
filter would put RFC 9535's comparison semantics in front of the planner, and
rabosh-jsonpath sits beside the chain precisely so that cannot happen by
refactoring.

The decision did not have to be taken, because the feature is not well-formed.
Same shape as §10.2 and found the same way: check whether the thing is definable
before deciding whether it is allowed.

A Predicate is a boolean over a document. A filter selector is a selector over a
nodelist. The only bridge between them is "the document matches when the
nodelist is non-empty", which is the one anybody building this would reach for,
and it does not work. On {"tags":["a","b"]} the filter $.tags[?@ == 'b'] selects
the b and its negation $.tags[?!(@ == 'b')] selects the a. Both non-empty, so P
and not-P are both true of one document, and where(f) and where(not(f)) would
return the same row. No planner change or index could repair that, because the
defect is in the meaning rather than in the evaluation.

Neither design is at fault, which is why neither can adopt the other. A filter's
! negates a test about one node while the selector stays existential over the
rest; a Predicate's not negates the answer for the document, after the
existential has been folded. Both quantify correctly, over different things.

The second disagreement is the one a user meets first: @.tags == 'b' compares the
array with a string and is false, where path("$.tags[*]") eq "b" is existential
over the elements and is true. The third agrees -- an absent field compares false
and its negation selects every child, matching what the engine's not does for a
missing path -- and that is the dangerous part rather than the reassuring one. A
bridge that is right most of the time is the shape that ships.

FilterIsNotAPredicateTest pins this in rabosh-jsonpath and needs nothing else,
which is itself part of the answer: the differential that would compare the two
semantics directly cannot be written anywhere in this repository without
acquiring the very edge the module layout forbids. So what is pinned is the half
that decides it. The storage side's complement is already in DocumentMatcherTest,
where not(path("$.tags[*]") eq "b") is false on that document, exactly as a
document predicate must be. Two files, no edge, and the pair is the argument.

This is index-and-query.md's "a negated leaf is never a flipped operator"
arriving from outside, in a syntax users already know. That rule notes the
rewrite is the most natural-looking simplification in the layer and that it
deletes documents from a result silently.

What would revive it is a spelling that says which way it quantifies, and that
is what elemMatch already is: the filter selector expressed as a document
predicate, with the existential written down and the negation defined over the
document. 0.2.0 built it under a different name, and the name is the part that
carries the semantics.

That closes the last of phase 20 §10. No format change, no .api dump rewritten,
no main source changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aoreshkov
aoreshkov merged commit 06b29fe into main Aug 9, 2026
2 checks passed
@aoreshkov
aoreshkov deleted the refuse-the-query-language-filter branch August 9, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant