Split out of #4664 (PR #6946). Pre-existing on every consumer of the shared
filter sink; that card newly makes it reachable by an author following the
spec's own type, which is why it is being written down now.
What happens
convertFiltersToAST (packages/core/src/utils/filter-converter.ts) — the
repo's ONE lowering from the MongoDB-style filter object to the ObjectQL AST,
reached through toFilterNode / mergeFilterNodes — has no branch for the
logical combinators. Measured against packages/core/dist at
40c479af2, composing each condition with a parent scope:
$or -> ["and",["task_version","=","tv-1"],["$or","=",[{"status":"open"},{"status":"blocked"}]]]
$and -> ["and",["task_version","=","tv-1"],["$and","=",[{"status":"open"},{"is_active":true}]]]
$not -> THROWS FilterOperatorError: Unknown filter operator 'status' for field '$not'.
plain -> ["and",["task_version","=","tv-1"],["status","!=","archived"]] (correct)
Two distinct failures, neither of them the right answer:
$and / $or fall through to the simple-equality branch (their value is
an array, so the operator loop is skipped) and produce a leaf naming a field
literally called $and / $or. That is a well-formed AST node carrying a
nonsense field, so the server refuses it (400 INVALID_FILTER) — loud, but
the diagnostic points at the author's field list rather than at the missing
lowering.$not enters the operator loop with its OWN nested object's keys read as
operators, so the thrown message names a nonsense operator
(Unknown filter operator 'status' for field '$not') and lists the supported
operators — none of which is what the author needs to hear.
Why it matters now
Field.relatedListFilter (@objectstack/spec 17.1.0, objectstack#8704 /
PR #8955) is typed FilterConditionSchema, and FilterCondition explicitly
declares all three:
$and?: FilterCondition[];
$or?: FilterCondition[];
$not?: FilterCondition;// NULL-safe per #5146
So the spec tells a metadata author these are legal in the very key #4664 wired
up, while this repo's lowering cannot carry any of them to the wire. The same
gap applies to every other producer that reaches this sink — the component-level
record:related_list.filter (objectstack#7118), ListView.filter /
ViewTab.filter via buildEffectiveFilter, and plugin-view's ObjectView —
so this is a sink-level bug, not a related-list one.
Also worth deciding as part of the fix: $not is NULL-safe by ruling
(objectstack#5146, maintainer 2026-08-04 — NOT (…) OR col IS NULL, with the
guard on each LEAF inside the negation, not hoisted). Whatever lowering is
chosen has to preserve that or hand the negation to the server intact.
Why it was not fixed in #4664
Changing the shared sink changes what every one of those callers puts on the
wire, so it needs its own card, its own fixture set and its own reverse
verification. Working around it renderer-side — accepting the combinators in
one consumer and lowering them there — is precisely the second de-facto
contract AGENTS.md #0.1 forbids, so #4664 carried the value verbatim to the
existing sink and named this boundary in its PR body instead.
Where
packages/core/src/utils/filter-converter.ts — convertFiltersToAST
(the missing combinator branch), toFilterNode, mergeFilterNodes
Filed unassigned by the #4664 execution seat. No dedupe hit: a targeted issue
search for the combinator lowering returned only objectui#4744 (a different
function, ListView.convertFilterGroupToAST, closed), with a control query in
the same session returning #4664 itself.
Generated by Claude Code
Split out of #4664 (PR #6946). Pre-existing on every consumer of the shared
filter sink; that card newly makes it reachable by an author following the
spec's own type, which is why it is being written down now.
What happens
convertFiltersToAST(packages/core/src/utils/filter-converter.ts) — therepo's ONE lowering from the MongoDB-style filter object to the ObjectQL AST,
reached through
toFilterNode/mergeFilterNodes— has no branch for thelogical combinators. Measured against
packages/core/distat40c479af2, composing each condition with a parent scope:Two distinct failures, neither of them the right answer:
$and/$orfall through to the simple-equality branch (their value isan array, so the operator loop is skipped) and produce a leaf naming a field
literally called
$and/$or. That is a well-formed AST node carrying anonsense field, so the server refuses it (
400 INVALID_FILTER) — loud, butthe diagnostic points at the author's field list rather than at the missing
lowering.
$notenters the operator loop with its OWN nested object's keys read asoperators, so the thrown message names a nonsense operator
(
Unknown filter operator 'status' for field '$not') and lists the supportedoperators — none of which is what the author needs to hear.
Why it matters now
Field.relatedListFilter(@objectstack/spec17.1.0, objectstack#8704 /PR #8955) is typed
FilterConditionSchema, andFilterConditionexplicitlydeclares all three:
So the spec tells a metadata author these are legal in the very key #4664 wired
up, while this repo's lowering cannot carry any of them to the wire. The same
gap applies to every other producer that reaches this sink — the component-level
record:related_list.filter(objectstack#7118),ListView.filter/ViewTab.filterviabuildEffectiveFilter, andplugin-view's ObjectView —so this is a sink-level bug, not a related-list one.
Also worth deciding as part of the fix:
$notis NULL-safe by ruling(objectstack#5146, maintainer 2026-08-04 —
NOT (…) OR col IS NULL, with theguard on each LEAF inside the negation, not hoisted). Whatever lowering is
chosen has to preserve that or hand the negation to the server intact.
Why it was not fixed in #4664
Changing the shared sink changes what every one of those callers puts on the
wire, so it needs its own card, its own fixture set and its own reverse
verification. Working around it renderer-side — accepting the combinators in
one consumer and lowering them there — is precisely the second de-facto
contract AGENTS.md #0.1 forbids, so #4664 carried the value verbatim to the
existing sink and named this boundary in its PR body instead.
Where
packages/core/src/utils/filter-converter.ts—convertFiltersToAST(the missing combinator branch),
toFilterNode,mergeFilterNodesFiled unassigned by the #4664 execution seat. No dedupe hit: a targeted issue
search for the combinator lowering returned only objectui#4744 (a different
function,
ListView.convertFilterGroupToAST, closed), with a control query inthe same session returning #4664 itself.
Generated by Claude Code