Repository navigation
objectui: FilterConditionField cannot author spec’s $icontains — the case-insensitive contains is unreachable from the filter UI #4023
Description
Activity
Dispatched by the
repo:objectuiPM seat (sessionsession_01RnQd8iMMUwXQEV1crFmQiQ), branchclaude/issue-4023-icontains-builder-operator— unblocked: the card's stated return-to-queue condition has been met.Blocker re-check (objectstack origin/main, today). objectstack#5702 closed completed 2026-08-08 (PR objectstack#6549: SQL family — sql/turso/sqlite-wasm), and objectstack#6520 ("the other half of #5702") landed
$icontainson every JS evaluation face includingdriver-memory(memory-matcher.ts,memory-icontains.test.ts) anddriver-mongodb(mongodb-filter.ts,mongodb-icontains.test.ts). No backend now fails the operator — the "filter every backend fails" blocking condition is gone.Adjudication. Implement the card's recorded default exactly: a new case-insensitive contains operator; today's
containssemantics untouched. The open product question (whethercontainsshould flip to case-insensitive) stays with the maintainer — not this card, not this PR. Semantics note for labels/tests: the casefold is ASCII-only by deliberate contract decision (objectstack#4706 Q1 = A;cafédoes not matchCAFÉ) — align any operator help text with how existing operators describe themselves, and don't invent a new help surface.Mechanism assumptions.
FieldOperatorsSchemahas carried$icontainssince 17.0.0-rc.5; the parity testFilterConditionField.operators.test.tsholds it as a ratchetedKNOWN_UNREACHABLEentry (objectui#3560), so deliverable 4 (remove the entry) makes the parity assertion take over and simultaneously validates the removal.Suggested route. The card's four deliverables in order: builder operator;
condToMongoemits{ field: { $icontains: value } };kvToConditionreads it back (round-trip test — a saved filter view must not degrade on reopen); ten-pack label key per thet()call-site key gate;KNOWN_UNREACHABLEentry removed. Changeset:fieldspatch (+i18npatch). No skip-changeset mechanism exists in objectui. Note: #4028 is dispatched concurrently and may also touch the ten packs in a different namespace — on queue conflict, rebase.
Generated by Claude Code
{ "issue": 4023, "branch": "claude/issue-4023-icontains-builder-operator", "pr_number": 4737, "head_sha": "67f441280", "status": "done", "premise_still_valid": true, "mechanism_assumptions_checked": [ "PM's blocker re-check CONFIRMED at the pinned dependency, not just on the issue tree: @objectstack/spec@17.0.0-rc.6's StringOperatorSchema docblock states $icontains is executable on every backend and evaluation face (objectstack#5702 SQL family + objectstack#6520 JS faces), and names the ASCII-only fold (objectstack#4706 Q1 = A, 'café' does NOT match 'CAFÉ').", "FieldOperatorsSchema.shape read at runtime: 16 keys, $icontains present. KNOWN_UNREACHABLE's ratchet premise held.", "The parity test's ratchet list ($eq/$between/$icontains) was a SECOND hand-written copy of KNOWN_UNREACHABLE: removing the entry from the Set alone would have left the ratchet vouching for it and still passing. Hoisted to one module-scope Set; the ratchet now iterates it.", "VIEW_FILTER_OPERATORS (19 members) has NO icontains, and no alias folds onto one — so view-operator-builder-parity.test.ts is unaffected by the new builder id (no canonical operator folds to 'containscaseinsensitive'). Verified by running the suite: green.", "MEASURED, and it changed the implementation: the FilterBuilder dropdown is SHARED by three consumers with three at-rest dialects. FilterConditionField writes MongoDB-style criteria the server engine evaluates (can carry $icontains); viewFilterFold writes ViewFilterRule over VIEW_FILTER_OPERATORS and ListView sends the array AST over VALID_AST_OPERATORS — neither vocabulary has any case-insensitive contains. Adding the operator to the one global list would have made it selectable in the list-filter popover, where mapOperator emits it verbatim and, per this repo's own filter-operator-ast-parity.test.ts, the query comes back unfiltered or 400. That is the exact hazard #4023 was blocked over, relocated from the drivers to objectui's own bridges. Shipped opt-in per consumer instead (FilterBuilder.extraOperators); root cause filed upstream.", "convertOperatorToAST('$icontains') returns null, so convertFiltersToAST throws FilterOperatorError (INVALID_FILTER/400) — the downstream validator FilterConditionField's own docblock names. Confirmed the card's path never reaches it: the criteria is stored as JSON on sys_sharing_rule.criteria_json and evaluated server-side, never lowered through objectui's array AST.", "Ten locale packs verified individually for quote style (en/zh single, other eight double) and placement inside filterBuilder.operators; check-i18n-en-drift reports '1 key added, 0 en values changed', so the drift gate correctly abstains and all-locales-key-parity owns it.", "objectui has no skip-changeset mechanism — a changeset was written instead (components + fields + i18n, all patch), and check-changeset-presence confirms 14 guarded source files against 1 declaration." ], "gates": { "build_closure": "pnpm --filter '@object-ui/components^...' --filter '@object-ui/fields^...' build — green (run FIRST, before any judgement)", "fields+i18n tests": "vitest run packages/i18n packages/fields — 136 files, 2224 tests passed (includes FilterConditionField.operators.test.ts and all-locales-key-parity)", "components+plugin-view tests": "vitest run packages/components packages/plugin-view — 142 files, 1232 tests passed (includes view-operator-builder-parity and the new filter-builder-opt-in-operators)", "type-check": "components/fields/i18n — Done", "type-check downstream sweep": "app-shell/plugin-list/plugin-view — Done. Direction is DOWNSTREAM consumers of @object-ui/components (the three that render FilterBuilder); first run failed on unbuilt plugin-grid/plugin-form dists, re-run green after building their closures", "lint": "components/fields/i18n — 0 errors (warnings pre-existing; changed files add none beyond the file's existing `as any` style)", "check-changeset-presence": "green — 14 source file(s) of 3 released packages, 1 changeset", "check-i18n-call-site-keys": "green — 2407/2407 literal keys resolve", "check-i18n-en-drift": "green — 0 en values changed (1 key added)", "check-i18n-dead-keys": "report-only, no new entries", "check-control-bytes": "green — 4250 tracked text files", "check-changeset-fixed / no-major / lint-coverage / type-check-coverage": "all green", "reverse_verification": "Predicted RED, observed RED. From the COMMITTED state, deleted the containsCaseInsensitive arm from condToMongo: 3 failures, including the parity sweep's `expected [ '$icontains' ] to deeply equal []` — the assertion deliverable 4 hands the ratchet. Restored with `git checkout <branch> -- <path>`; never stashed.", "ci": "in_progress — draft PR #4737 opened at 67f441280; per the dispatch contract the CI-convergence wait is the PM's" }, "deviations": [ "SCOPE ADDITION, deliberate and named in the PR body: the operator is opt-in per consumer rather than added to the one global FilterBuilder list. FilterBuilder gains an optional `extraOperators` prop and an OPT_IN_OPERATORS set; only FilterConditionField passes it, so every other FilterBuilder offers exactly what it offered before. Reason under the three axes — (1) real business need: the measured need is authoring case-insensitive sharing-rule criteria; there is no need in list views, whose dialect cannot even express it; (2) long-term soundness: the global list would knowingly widen a defect class that already cost objectstack#3948 an unfiltered query, and mapping onto plain `contains` in the bridges is a different question silently answered (the same collapse view-operator-builder-parity.test.ts refuses for is_null/isEmpty); (3) hard to get wrong: a dropdown row whose save path rejects it is exactly the lenient-consumer trap. The card's own wording ('the FilterConditionField builder vocabulary') reads consistently with this. If the maintainer prefers the global list, the change is one line plus deleting the OPT_IN_OPERATORS entry.", "Three small in-file improvements beyond the four deliverables, each named in the PR body: KNOWN_UNREACHABLE hoisted to module scope with the ratchet reading it (otherwise the ratchet's duplicate list keeps vouching for a removed entry); the parity sweep iterates FILTER_BUILDER_OPERATORS instead of a hand-written copy; getOperatorsForField's bucket selection extracted as the pure `operatorsForFieldType` so the opt-in gate is testable without a Radix popover in jsdom.", "The report template's cross-package reverse-verification ('paste a key the new type rejects') does not fit this change and was NOT fabricated: nothing was narrowed — `extraOperators` is an added optional prop and FilterBuilderOperator gained a member — so there is no key the new types reject. The ablation above is the reverse verification that does apply.", "No new help/tooltip surface for the ASCII-only fold, per the dispatch note: no existing operator has one, so the boundary is stated in code comments, the changeset and the PR body, and the label says 'ignore case' rather than promising an accent-blind search." ], "followups_filed": [ "objectstack-ai/objectstack#8934: `$icontains` has no counterpart in VIEW_FILTER_OPERATORS or VALID_AST_OPERATORS, so the capability is authorable only in the MongoDB-style dialect — the root cause that made the opt-in design necessary. Filed in objectstack because the fix lands in packages/spec; unassigned, unlabeled, backlinked to #4023.", "objectstack-ai/objectui#4736: the list filter builder already offers `exists`/`notExists`, which ListView.mapOperator has no row for, so the id goes out verbatim and the query breaks. Pre-existing since #2942; this PR does not widen it, and the `extraOperators` mechanism it adds is one of the two candidate fixes. Unassigned, unlabeled." ] }
Generated by Claude Code
Part of #3567 (transferred to the main backlog by the triage seat — feature-level work lives in
objectstackwhatever repo the code lands in; see the multi-repo coordination rule "One main backlog").Blocked-by: objectstack-ai/objectstack#5702
What is missing
packages/spec'sFieldOperatorsSchemagained$icontains(case-insensitivecontains, spec describe: "folds ASCII case") between17.0.0-rc.2andrc.5.objectui'sFilterConditionFieldhas no builder operator that can author it, so the capability is unreachable from the filter UI. The existing bidirectional parity test inpackages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts("every spec field operator is reachable from the builder", objectui#2942) went red on the rc.5 bump and was handled by objectui#3560 as an explicit, ratchetedKNOWN_UNREACHABLEentry rather than a silent exclusion.Deliverable (lands in
objectui)FilterConditionFieldbuilder vocabulary;condToMongoemits{ field: { $icontains: value } }.kvToConditionreads it back — without the reverse mapping a saved filter view degrades on reopen.t()call-site key gate, objectui#3547).$icontainsentry fromKNOWN_UNREACHABLEinFilterConditionField.operators.test.ts; the parity assertion then takes over, and objectui#3560's ratchet simultaneously validates that the removal was legitimate.Why this is blocked, and it is not the reason the filing issue gave
$icontains. Half true, and the half that is false is the blocking half. Measured againstobjectstackorigin/main:$icontainsis present inpackages/speconly —filter.zod.ts,filter-operator-vocabulary.test.ts,filter-text-conformance.ts, the authorable-surface baselines and the CHANGELOG.git grep -l icontains origin/main -- packages/driversreturns nothing. No backend implements it.pm:queue+pm:blocked+domain:drivers), itself blocked by spec:$regex按 ADR-0049 退役 +$icontains入算子词表与 FILTER_LOGIC_CASES(#4706 裁决 B 案 · 契约半边,先行) objectstack#5701 (contract half: vocabulary +FILTER_LOGIC_CASES) and plugin-auth: better-auth 的contains被译成裸$regex,用户输入当正则求值 —— 且是$regex退役唯一挡路的生产者 objectstack#5710 (producer-first:plugin-auth'scontainsbranch).driver-memory/driver-mongodbimplementations sit inside the [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) objectstack#5499 freeze.So the spec vocabulary accepts the token while no driver can execute it. Shipping the builder operator first would put a filter in users' hands that every backend fails — strictly worse than the capability being absent. This card returns to the queue when objectstack-ai/objectstack#5702 merges.
Open product question (default recorded, not blocking)
Should today's
containshave been case-insensitive all along? If so, the better shape is to havecontainsemit$icontainsand add an explicitcontainsCaseSensitivefor$contains— but that changes the behaviour of stored filter views, so it needs the maintainer. Default for this card: add a new operator, leavecontainssemantics untouched. A dev implements the default; if the maintainer wants the flip, say so here first and this card is re-scoped (it would then move to the decision inbox).Provenance
Measured during objectui#3560 (
@objectstack/*family bump to17.0.0-rc.5). Not a regression introduced by that bump — a capability gap it exposed. Original filing: #3567.Generated by Claude Code