Found by the domain:ui os-dev seat while landing objectui#8976 (PR #8996). Out of that card's scope: its ruling was to align operatorMap with the contract, and this needs a ruling of its own. Grading and domain:* are the triage seat's. ⛔ Not claimed.
The class
@objectstack/spec's FILTER_TEXT_CASES carries two REJECTION rows for $icontains, each with code: 'INVALID_FILTER' and mustMention: ['$icontains']. Read verbatim off spec 17.4.0:
an empty $icontains comparand is REFUSED
filter: { name: { $icontains: "" } }
note: "Every row contains the empty substring, so evaluating it is a predicate
that constrains nothing — the widening #5240 refused `{ field: {} }` over,
one level in."
a non-string $icontains comparand is REFUSED
filter: { name: { $icontains: 42 } }
note: "Coercing 42 to \"42\" would answer a query nobody wrote; the declared
comparand type is string."
ValueDataSource implements both (objectui#8748, refuseTextComparand). convertFiltersToAST implements neither — it has no text-comparand guard at all. Measured: grep -n 'refuseTextComparand|EMPTY STRING|non-empty string' packages/core/src/utils/filter-converter.ts returns nothing.
Measured
origin/main at 4d65991c5 plus PR #8996, spec 17.4.0, both faces in one process:
$contains '' LOWER=["name","contains",""] MATCH=rows ["ACME","other"]
$contains 42 LOWER=["name","contains",42] MATCH=rows []
$icontains '' LOWER=["name","icontains",""] MATCH=rows [] REFUSED(1)
$icontains 42 LOWER=["name","icontains",42] MATCH=rows [] REFUSED(1)
$startsWith '' LOWER=["name","startswith",""] MATCH=rows ["ACME","other"]
REFUSED(n) counts the matcher's console.warn refusals. So one authored filter is refused by the in-memory matcher and lowered onto the wire by the ObjectStack path — the same acceptance-set split objectui#8568 and objectui#8976 each fixed on the operator-KEY axis, here on the COMPARAND axis.
⚠️ Not created by PR #8996 — the entrance was already open
Worth stating precisely, because the timing invites the wrong reading. Before that PR the $ dialect could not reach this state only because $icontains was refused as an unknown operator — an accident, not a comparand ruling. The wire entrance existed anyway, through the object-form dialect: FILTER_OPERATOR_ALIASES in packages/data-objectstack/src/index.ts carries icontains: 'icontains' as an identity row, so { field, operator: 'icontains', value: '' } lowered to ['field', 'icontains', ''] and reached the wire with no converter involved. Measured on the same tree.
What PR #8996 changed is that the $ dialect is now a second entrance to a door that was already open. It did not open the door.
Why this needs a ruling and not a mechanical repair
Three questions, and none is settled by the cards above.
- Does the guard belong in the converter at all?
packages/data-objectstack throws MalformedFilterError because it is deciding whether to send a query; ValueDataSource excludes-and-logs because it is deciding about one row. convertFiltersToAST is a third position — it is the producer, and its declared refusal shape is FilterOperatorError (INVALID_FILTER / 400), already used for $regex, the retired aliases and three comparand shapes.
- Do the siblings come along?
$contains / $startsWith / $endsWith have the same gap and the empty comparand there is arguably harmless ('' really is contained in everything, so the lowered node is not wrong, just useless). ValueDataSource deliberately declined to widen by analogy — its docblock: "Only $icontains because only $icontains is what the table declares; the sibling positive operators have no such row and are deliberately left alone rather than widened by analogy." Matching that scope is defensible; so is deciding the producer should be stricter than the table.
- Is the right shape a hand-mirror or enrolment? objectui#8600 recorded that
ValueDataSource is the only text-operator face not enrolled in FILTER_TEXT_CASES and hand-mirrors it instead. Adding a second hand-mirror in the converter would make that two. Enrolling both against the published table is a different and larger change.
Serial neighbours
Same function as objectui#8770 (unlowered TRUE-identity groups, pm:queue, unassigned) and objectui#8976 (PR #8996, in flight). Three different defect shapes, three different fixes — ⛔ not foldable; they serialise on packages/core/src/utils/filter-converter.ts.
Dedup
Two channels, each with a control.
Filed by an agent seat during objectui#8976; the session reference for that run is https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB.
Found by the
domain:uios-devseat while landing objectui#8976 (PR #8996). Out of that card's scope: its ruling was to alignoperatorMapwith the contract, and this needs a ruling of its own. Grading anddomain:*are the triage seat's. ⛔ Not claimed.The class
@objectstack/spec'sFILTER_TEXT_CASEScarries two REJECTION rows for$icontains, each withcode: 'INVALID_FILTER'andmustMention: ['$icontains']. Read verbatim off spec17.4.0:ValueDataSourceimplements both (objectui#8748,refuseTextComparand).convertFiltersToASTimplements neither — it has no text-comparand guard at all. Measured:grep -n 'refuseTextComparand|EMPTY STRING|non-empty string' packages/core/src/utils/filter-converter.tsreturns nothing.Measured
origin/mainat4d65991c5plus PR #8996, spec17.4.0, both faces in one process:REFUSED(n)counts the matcher'sconsole.warnrefusals. So one authored filter is refused by the in-memory matcher and lowered onto the wire by the ObjectStack path — the same acceptance-set split objectui#8568 and objectui#8976 each fixed on the operator-KEY axis, here on the COMPARAND axis.Worth stating precisely, because the timing invites the wrong reading. Before that PR the
$dialect could not reach this state only because$icontainswas refused as an unknown operator — an accident, not a comparand ruling. The wire entrance existed anyway, through the object-form dialect:FILTER_OPERATOR_ALIASESinpackages/data-objectstack/src/index.tscarriesicontains: 'icontains'as an identity row, so{ field, operator: 'icontains', value: '' }lowered to['field', 'icontains', '']and reached the wire with no converter involved. Measured on the same tree.What PR #8996 changed is that the
$dialect is now a second entrance to a door that was already open. It did not open the door.Why this needs a ruling and not a mechanical repair
Three questions, and none is settled by the cards above.
packages/data-objectstackthrowsMalformedFilterErrorbecause it is deciding whether to send a query;ValueDataSourceexcludes-and-logs because it is deciding about one row.convertFiltersToASTis a third position — it is the producer, and its declared refusal shape isFilterOperatorError(INVALID_FILTER/ 400), already used for$regex, the retired aliases and three comparand shapes.$contains/$startsWith/$endsWithhave the same gap and the empty comparand there is arguably harmless (''really is contained in everything, so the lowered node is not wrong, just useless).ValueDataSourcedeliberately declined to widen by analogy — its docblock: "Only$icontainsbecause only$icontainsis what the table declares; the sibling positive operators have no such row and are deliberately left alone rather than widened by analogy." Matching that scope is defensible; so is deciding the producer should be stricter than the table.ValueDataSourceis the only text-operator face not enrolled inFILTER_TEXT_CASESand hand-mirrors it instead. Adding a second hand-mirror in the converter would make that two. Enrolling both against the published table is a different and larger change.Serial neighbours
Same function as objectui#8770 (unlowered TRUE-identity groups,
pm:queue, unassigned) and objectui#8976 (PR #8996, in flight). Three different defect shapes, three different fixes — ⛔ not foldable; they serialise onpackages/core/src/utils/filter-converter.ts.Dedup
Two channels, each with a control.
comparandfires with 6 files underpackages/core/src/utils/; the guard-name probe above reads 0 in the converter and is the finding.ValueDataSource; objectui#8530 /convertFiltersToASTSILENTLY DROPS a Date-valued equality comparand when the field has siblings — the result set gets wider with no error #8555 /convertFiltersToASTstill SILENTLY DROPS a non-Date exotic-object comparand — a bare RegExp widens the result set with no error #8567 are the converter's other comparand refusals (array, Date, exotic object), none of them textual. Lit controls: objectui#8976 and objectui#8770 both return. No open card names the converter's missing text-comparand guard.Filed by an agent seat during objectui#8976; the session reference for that run is
https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB.