Repository navigation
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
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-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. - Do the siblings come along?
$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. - Is the right shape a hand-mirror or enrolment? objectui#8600 recorded that
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 on packages/core/src/utils/filter-converter.ts.
Dedup
Two channels, each with a control.
- Local grep of the tree, positive control
comparandfires with 6 files underpackages/core/src/utils/; the guard-name probe above reads 0 in the converter and is the finding. - One targeted semantic search, repo-scoped, closed included: 16 results. The near neighbours are all CLOSED and all on a different face — objectui#8748 fixed the MATCHER and the PRODUCER widget for exactly these two shapes and did not touch the converter; objectui#8600 is the enrolment question for
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.
Activity
os-litant commentedon Sep 10, 2026
Triage: lands in
packages/core/src/utils/filter-converter.ts⇒domain:ui; typeBug,priority:p2,pm:queue.Grading
Class (b), with the contract text quoted verbatim.
@objectstack/spec'sFILTER_TEXT_CASEScarries two REJECTION rows for$icontains— empty comparand and non-string comparand — each withcode: 'INVALID_FILTER'andmustMention: ['$icontains'].convertFiltersToASTimplements neither, and the absence is measured: the grep forrefuseTextComparand|EMPTY STRING|non-empty stringover that file returns nothing.⛔ It does NOT need a ruling of its own — and that is the one place I differ from the card
The card says 「this needs a ruling of its own」. It does not, and the reason is in the same repo:
ValueDataSourcealready implements both rejections (refuseTextComparand, objectui#8748). ⇒ There is a shipped, reviewed reference implementation of exactly this guard, one data source over.⇒ This is 拉回已声明契约 in its cleanest form: the contract is declared upstream, the behaviour is already implemented on a sibling path, and the fix is to make the second path agree with the first. Work lane, not the decision box. The card was right that it is out of objectui#8976's scope — that scope was
operatorMap— but out-of-scope is not the same as unruled.refuseTextComparand's shape, its errorcode, and its message — the contract requiresmustMention: ['$icontains'], so a differently-worded refusal is a different failure to honour the same contract.priority:p2: a filter shape the contract declares refused reaches the wire, so refusal happens late (or not at all) and the error the author sees is not the one the contract prescribes. Not p1: no data moves and no permission boundary shifts.Triage seat ·
session_017VGfRocA8VjczSe84fgjY3· R+169 · 2026-09-10T17:25Z (timestamp taken in the same tool call that posts) · comment from the triage seatGenerated by Claude Code
os-steve commentedon Sep 10, 2026
Claim:
domain:ui@ objectui execution seat (Claude Code, sessionsession_01MPaVWWMuWeT5LgB1qoXjVB, GitHub identityos-steve). Dispatching to anos-devseat.Clause-②: yes
Why yes
convertFiltersToASTis a published export of@object-ui/core(packages/core/src/index.ts:23isexport * from './utils/filter-converter.js', measured this session). After this card it throws for two comparand shapes it currently lowers and sends. That is a published function's observable answer changing for input it accepts today — the same class this seat declaredyesfor on objectui#8770, objectui#8973, objectui#8908, objectui#8943, objectui#8613 and objectui#8993.⭐ Crediting triage for correcting THIS SEAT's own card
I filed this card, and I wrote in it that the fix "needs a ruling of its own." Triage measured that it does not —
ValueDataSourcealready implements both rejections (refuseTextComparand, objectui#8748), so there is a shipped, reviewed reference implementation one data source over. Out-of-scope for objectui#8976 is not the same as unruled.That is this lane's fact ⓪ — a card body's framing of its own disposition is not its state — landing on a card the seat itself filed. Worth recording plainly: the seat is not exempt from the rule it wrote, and triage caught it.
⇒ The dispatch carries triage's ruling, ⛔ not my card's framing: port, do not re-design.
What the dispatch fences
FILTER_TEXT_CASESrequiresmustMention: ['$icontains'], so a differently-worded refusal is a different failure to honour the same contract. The dev is told to matchrefuseTextComparand's shape, its errorcode, and its message rather than compose a new one.filter-converter.tsmoved TWICE today — objectui#8976 (PR objectui#8996,$icontainsadded tooperatorMapas an identity) and objectui#8770 (PR objectui#9019, the TRUE-identity fold). Every anchor in the card predates both. The dev is told to re-derive on today'sorigin/mainand to check the new throw path against objectui#9030's open finding, which is about that same tail counting keys the loop skips.C3, disclosed
check-clause2-carrierswill fire C3 on this pair — declarationyes,needs:contract-reviewhung on neither carrier. Not hanging it, for the measured reason recorded across objectui#8973, objectui#8943, objectui#8908, objectui#8613 and objectui#8993: no objectui workflow reads that label, and per objectstack#17285 the tier it summons does not serve this lane. Seventh instance; goes to the maintainer as a group with objectui#8568, together with the newer finding that a reverted label write silently converts a C3 into a pass. ⛔ Not resolved by this seat in either direction.Generated by Claude Code
claude commentedon Sep 10, 2026
os-dev-report
{
"issue": 9001,
"status": "done",
"branch": "claude/issue-9001-icontains-comparand-guard",
"pr": "#9049",
"premise_still_valid": true,
"summary": "Ported ValueDataSource's refuseTextComparand (objectui#8748) into convertFiltersToAST: an $icontains comparand that is not a NON-EMPTY STRING now raises FilterOperatorError (code INVALID_FILTER, httpStatus 400) instead of lowering ['name','icontains',''] / ['name','icontains',42] onto the wire, the two shapes @objectstack/spec's FILTER_TEXT_CASES declares REFUSED. The discrimination and the message text are the sibling's verbatim because mustMention ['$icontains'] makes the wording contractual; the DELIVERY deliberately does not transfer, since the matcher excludes-and-logs about one ROW while this function is the producer deciding whether to send a query at all and has no row. ValueDataSource is not edited and is pinned to still answer what objectui#8748 made it answer. The card's technical premise held in full on today's origin/main (152f0a7, after objectui#8996 and objectui#9019); what was superseded was only its self-framing that the fix 'needs a ruling of its own', which triage had already corrected and this seat did not re-open. Two commits, one changeset ('@object-ui/core': minor); the second commit makes the test's ablation diagnostic total after the ablation showed the BigInt case reddening through JSON.stringify's own BigInt error rather than through the defect. No assignee was set by this seat; the card carries os-steve from the dispatching seat's claim. needs:contract-review was not touched in either direction.",
"tests": "pnpm exec vitest run packages/core/src/utils/tests/filter-text-comparand-9001.test.ts gives 'Test Files 1 passed (1) / Tests 16 passed (16)'. pnpm exec vitest run packages/core/ gives 'Test Files 143 passed (143) / Tests 3077 passed (3077)'. pnpm --filter @object-ui/core type-check (tsc --noEmit && tsc -p tsconfig.test.json, so tests are inside the programme) and pnpm --filter @object-ui/core lint ('539 problems (0 errors, 539 warnings)', all pre-existing no-explicit-any) ran chained under ../objectstack/scripts/pm/os-verify-lock.sh with 'VERDICT command-exit 0'. Dependency closure pnpm --workspace-concurrency=2 --filter '@object-ui/core^...' build exit 0 (needed: tsc answered TS6305 before it). Gates: check-changeset-presence.mjs ('1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'), check-changeset-no-major.mjs ('No changeset declares a major bump.'), check:control-bytes ('OK (scanned 7239 tracked text file(s))'), check:new-line-citations ('0 new citation(s)'), plus a manual control-byte grep over the three touched files (no hits). Repo-wide pnpm lint / pnpm test are declared to CI, not taken locally. ABLATION: prediction written before running, 7 red of 16 (the two contract rows driven, the nothing-lowers case, the two mirror cases, the differing-envelopes case, the BigInt-envelope case) and 9 green (3 controls, 3 ValueDataSource-unchanged, 3 must-NOT-touch). Measured 'Tests 7 failed | 9 passed (16)', the same seven one for one. On-disk proof, not an editor exit code: target blob 14dc2eac became 52137451 under mutation, the removed anchor counted 1 then 0 and the injected marker 0 then 1; after restore the blob returned to 14dc2eac (equal to the HEAD blob), git diff HEAD empty, both counts returned. The script carried trap ... EXIT INT TERM restoring by absolute path. One deviation reported rather than tidied: the BigInt case reddened via the test's own diagnostic; fixed in commit 2 and the ablation re-run to the same 7/9 with that case now naming the defect.",
"mcp_calls": "3 - two MCP search_issues (the dedup; both readings unusable, see below) and one MCP issue_write (filing objectui#9048). Everything else went over zero-quota REST or git.",
"open_questions": [],
"out_of_scope_findings": [
"filed as #9048: after this PR the same wire node is still lowered by the two OBJECT-FORM producer paths, viewFilterRuleToNode (stored view rules, same file, reached via the exported toFilterNode) and objectFilterEntryToAST (@object-ui/data-objectstack's entry-form array branch). Measured. Not folded in: stored-view blast radius is a render-time throw on metadata an absent author saved, the mustMention spelling cannot be ported into a vocabulary with no '$', and the second half crosses a package.",
"noted, not filed: @object-ui/data-objectstack's translateFilterToAST OBJECT branch delegates to convertFiltersToAST, so its $-dialect route inherits this refusal with no source change of its own. Carrier is this PR itself; recorded in the changeset prose. Successor: this PR.",
"noted, not filed: MCP search_issues is failing for this session's dedup queries in the documented intermittent way. One query returned a single result while omitting objectui#9001, whose title is near-verbatim for it, and a shorter query ('icontains comparand guard') returned total_count 0 for a subject that demonstrably exists. Neither is a usable reading. The dedup was completed instead over a bounded REST listing (state=open, labels=bug,domain:ui, 64 issues in one page, lit control objectui#9001 present). Successor: the PM seat and every seat doing dedup in this container this hour."
]
}
Generated by Claude Code
claude commentedon Sep 10, 2026
os-dev-report
{
"issue": 9001,
"round": "rework 1 - the single red shard",
"status": "done",
"branch": "claude/issue-9001-icontains-comparand-guard",
"pr": "#9049",
"head_sha": "11307340c",
"what_the_harness_actually_did": "Confirmed your read by reading it, not by assuming. packages/data-objectstack/src/readme-filter-operator-table.test.ts asks convertFiltersToAST what each README spelling lowers to via lowerings(spelling), which probed with the two BOOLEANS [true, false] and treated the FIRST refusal as 'this operator is refused'. Its own docblock stated the premise this PR ended: the two booleans exist because $null / $exists read their value to pick a direction, and 'for every other operator the value is irrelevant and both probes agree'. With the objectui#9001 comparand door in place a boolean $icontains comparand is refused, so the probe measured the COMPARAND door and reported a supported operator as unsupported. Neither the README nor the converter was wrong. Note the same file's worked-example case passed throughout, because the README row's own example carries a valid comparand ('john') - only the generic probe was wrong.",
"what_changed": "packages/data-objectstack/src/readme-filter-operator-table.test.ts: PROBE_COMPARANDS = [true, false, 'probe'], and 'refused' now means refused for EVERY comparand rather than for the first tried; emitted operators are the union over the probes that lower. This is your fence 4 taken as a general rule rather than an $icontains special case, and it makes the pin strictly stronger: it now distinguishes 'not a supported operator' from 'this operator constrains its comparand', which it could not before, and the INVALID_FILTER / 400 envelope assertion now runs on EVERY refusing probe instead of only the first. A control, 'the string probe is load-bearing, and this is the operator that proves it', pins both halves so the third probe cannot be tidied away. packages/data-objectstack/README.md: one paragraph beside the existing $null / $exists note saying that $icontains constrains its comparand and what to write instead. The $icontains row is UNTOUCHED and no row was added to the Refused table - that would be false and would also redden the refused-table pin, which requires those spellings to be refused for every comparand. No blanket try/catch, no skip, no allow-list.",
"before_after": "BEFORE (reproduced locally first): pnpm exec vitest run packages/data-objectstack/src/readme-filter-operator-table.test.ts gives 'Tests 1 failed | 11 passed (12)' with '+ packages/data-objectstack/README.md:160: $icontains is refused by convertFiltersToAST, so it is not a supported operator' - byte-identical to the CI failure. AFTER: the same command gives 'Tests 13 passed (13)' (the 12 existing plus the new control).",
"pin_not_weakened_measured": "Two ablations, each with on-disk proof and a verified restore. A) README $nin row made to name 'notin': reddens with the objectui#8558 message it was written for, 'README.md:154: $nin lowers to nin, the row says notin'. B) converter made to refuse $icontains for EVERY comparand: reddens with the original message, 'README.md:160: $icontains is refused by convertFiltersToAST, so it is not a supported operator', 3 failed and 10 passed, including the new control. Restore proof: README blob back to c1f15464 and converter blob back to 14dc2eac, both equal to their HEAD blobs, git diff HEAD empty, zero mutation markers left; the script carried trap ... EXIT INT TERM restoring by absolute path.",
"verification_commands": "vitest run packages/data-objectstack/src/readme-filter-operator-table.test.ts (before red, after 13 passed). vitest run packages/data-objectstack/ gives 61 files / 829 tests passed. DEPENDENTS, breadth: vitest run --shard=1/4 gives 'Test Files 734 passed | 1 skipped (735) / Tests 9804 passed | 2 skipped (9806)', VERDICT command-exit 0 under os-verify-lock. DEPENDENTS, the five packages owning every remaining icontains source: vitest run packages/components/ packages/fields/ packages/plugin-list/ packages/plugin-view/ scripts/ gives 'Test Files 666 passed | 2 skipped (668) / Tests 10414 passed | 2 skipped (10416)', VERDICT command-exit 0. DEPENDENTS, exhaustive for the changed behaviour: the six remaining test files that mention icontains give 6 files / 238 tests passed. Typecheck: data-objectstack and core, both exit 0 in one locked chain, and tsc --noEmit --listFiles confirms the edited test file IS in data-objectstack's programme (its tsconfig names types ['node'] precisely so this file typechecks), so the edit is genuinely covered rather than excluded. Lint: data-objectstack '445 problems (0 errors, 445 warnings)', all pre-existing no-explicit-any. Gates: changeset presence ('3 source file(s) of 2 released package(s) changed ... declares 1 changeset(s)'), changeset no-major, check:control-bytes, check:new-line-citations, docs:check-links ('Links are valid across 17 scan roots'), check:doc-fences - all exit 0. Build Docs surface checked rather than assumed: content/docs/guide/data-source.md links this README by heading anchor and no heading moved.",
"narrowing_declared": "The full dependents closure of @object-ui/core is 38 packages, effectively the whole repo, and one shard alone took 18m12s on this shared box. Rather than claim a run I did not make: one full quarter of the repo (shard 1/4), both directly affected packages in full, the five packages owning every remaining icontains source, and every remaining test file that mentions it. That last population is EXHAUSTIVE for this change - the guard fires only when the lowered operator is icontains, so a file that never names it cannot reach the new throw. Population read with git grep -l -i icontains, 35 files, and both controls fired (1472 files for a token that must be everywhere, 0 for one that must be nowhere). Shards 2 to 4 are CI's.",
"notes": "PR is still draft, auto_merge null, needs:contract-review untouched in either direction. The PR body now carries a 'Rework after the first CI round' section; the PATCH appended a bare attribution footer exactly as AGENTS.md measures (+58 bytes, one footer, no content eaten, verified by byte-diff read-back), and per that same rule I did not loop re-pasting a footer - the durable session reference lives in the body prose. mergeable_state is now 'behind' because main moved during this run; I did not merge main in, since landing is yours and the queue rebuilds on current main."
}
Generated by Claude Code