Skip to content

finding(core): convertFiltersToAST has no text-comparand guard — an empty or non-string $icontains comparand reaches the wire, a shape @objectstack/spec's FILTER_TEXT_CASES declares REFUSED #9001

Description

@os-steve

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.

  1. 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.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p2

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions