Skip to content

$icontains has no counterpart in VIEW_FILTER_OPERATORS or VALID_AST_OPERATORS, so it is authorable only in the MongoDB-style dialect #8934

Description

@yinlianghui

Filed while implementing objectstack-ai/objectui#4023 (the FilterConditionField builder operator for $icontains). Measured against @objectstack/spec@17.0.0-rc.6 as pinned by objectui.

What is missing

FieldOperatorsSchema (data/filter.zod.ts) declares $icontains, and per its own docblock every driver and evaluation face the platform ships now executes it (#5702 + #6520). The spec's other two filter vocabularies do not have it:

node -e "console.log(require('@objectstack/spec/ui').VIEW_FILTER_OPERATORS)"
// equals, not_equals, contains, not_contains, starts_with, ends_with,
// greater_than, less_than, greater_than_or_equal, less_than_or_equal,
// in, not_in, is_empty, is_not_empty, is_null, is_not_null, before, after, between

node -e "console.log([...require('@objectstack/spec/data').VALID_AST_OPERATORS])"
// =, ==, equals, eq, !=, ..., contains, notcontains, not_contains, like,
// startswith, starts_with, endswith, ends_with, between, is_null, ...

Neither set has a case-insensitive contains, and neither has an alias that folds onto one.

Why that is a defect and not just an omission

The three vocabularies are three doors onto the same capability, and a filter authored at one door cannot always be expressed at another. Measured consequences in objectui, all on origin/main today:

  • packages/core/src/utils/filter-converter.ts — convertOperatorToAST('$icontains') returns null, so convertFiltersToAST throws FilterOperatorError (INVALID_FILTER, 400). Any MongoDB-style filter carrying the operator is refused before it reaches the wire.
  • packages/plugin-list/src/ListView.tsx — mapOperator has no row, so the operator is emitted verbatim into the array AST. Per that repo's own filter-operator-ast-parity.test.ts, an operator outside VALID_AST_OPERATORS costs either an unfiltered read or a 400 (see A filter with an operator outside VALID_AST_OPERATORS is silently dropped, not rejected — single-condition views return unfiltered results #3948) — either way not the filter the user asked for.
  • packages/app-shell/src/views/viewFilterFold.ts — a saved view rule is refused by ViewFilterRuleSchema's enum.

So a capability that the engine executes on every face is reachable from exactly one of three authoring dialects. objectui#4023 shipped the operator as opt-in per consumer for this reason: only the widget that persists MongoDB-style criteria offers it, and the other FilterBuilder consumers deliberately do not, because there is nothing correct for them to store.

Mapping $icontains onto plain contains in the bridges is not the workaround: case sensitivity is part of the contract as of the 2026-08-06 ruling on #5701, so that is a different question silently answered.

Suggested shape (not a decision)

Add a canonical case-insensitive contains to VIEW_FILTER_OPERATORS (e.g. icontains / contains_ignore_case) with the alias folds, and the matching AST spelling in VALID_AST_OPERATORS, so the three dialects declare one capability rather than one-and-a-bit. The naming and whether the view layer should carry it at all are product decisions this issue does not take.

Provenance

Measured during objectstack-ai/objectui#4023. Not a regression — a gap that card exposed while wiring the operator into the filter UI.

Activity

  1. os-project-manager commented on Aug 15, 2026

    @os-project-manager
    Collaborator

    Triage: lands in packages/spec — ui/view.zod.ts (VIEW_FILTER_OPERATORS + its alias table) and data/filter.zod.ts (AST_OPERATOR_MAP, from which VALID_AST_OPERATORS derives). Rationale: adding an operator to either vocabulary widens the accepted authoring set, so this is domain:spec (semantic lane), native type Feature, and a contract-shape change on the manual-decision floor — routed to needs-user-decision, not the queue. Dedup checked: #5701 (closed) added $icontains to FieldOperatorsSchema only; #6337 (closed) was the objectui authoring half; no open card covers the two remaining vocabularies.

    Premise re-checked against origin/main (the card measured @objectstack/spec@17.0.0-rc.6 as pinned by objectui):

    Decision block (four prisms):

    1. Platform long-term coherence — the three authoring dialects are three doors onto one executed capability, and today $icontains is reachable from exactly one. Closing the gap removes a special case (objectui#4023 had to ship the operator opt-in per consumer). The coherent alternative also exists: declare the view vocabulary deliberately smaller and record the disagreement in filter-view-operator-parity.test.ts's deliberate list.
    2. Measured business pull — real, not speculative: two of three FilterBuilder consumers in objectui have nothing correct to store for a capability every driver executes, and objectui: FilterConditionField cannot author spec’s $icontains — the case-insensitive contains is unreachable from the filter UI #6337 documented users unable to author it from the filter UI.
    3. AI-agent error-resistance — the current asymmetry invites bridges to fold $icontains onto plain contains, silently flipping case semantics against the spec: $regex 按 ADR-0049 退役 + $icontains 入算子词表与 FILTER_LOGIC_CASES(#4706 裁决 B 案 · 契约半边,先行) #5701/$regex on driver-sql is not a regex — it compiles to a substring LIKE, so it both over-matches and silently matches nothing #4706 ruling that case sensitivity is contract. A declared spelling with alias folds in each vocabulary is the closed-enumeration answer; the status quo is the standing temptation.
    4. Startup scope discipline — bounded addition: one canonical spelling plus alias folds plus parity-test rows; no new subsystem. Doing nothing keeps a documented dialect asymmetry every future consumer must re-learn.

    Recommendation (A): add a canonical case-insensitive contains to both remaining vocabularies — icontains in VIEW_FILTER_OPERATORS (name chosen for congruence with ilike, already admitted by #7536) plus its alias folds, and an icontains → $icontains row in AST_OPERATOR_MAP — extending the existing parity and comparand-type tests. Alternative (B): rule the view vocabulary deliberately smaller and pin that as an intentional disagreement. The four prisms all point at A, but this is a feature/contract change, so it stays on the manual floor — not auto-adjudicated.

    Size/model suggestion: S, claude-fable-5 mandatory if ruled (contract acceptance-set change).

    本评论来自分诊座位 Routine。


    Generated by Claude Code

  2. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor

    Adjudicated — Option A (maintainer, in-session chat with the spec-seat PM, 2026-08-16, verbatim, untranslated: 「8934 同意A」).

    Ruling: add the canonical case-insensitive contains to both remaining authoring vocabularies —

    • icontains joins VIEW_FILTER_OPERATORS (ui/view.zod.ts), with alias folds curated per the existing alias-table conventions;
    • an icontains → $icontains row joins AST_OPERATOR_MAP (data/filter.zod.ts);
    • the parity (filter-view-operator-parity.test.ts), comparand-type, and text-conformance tables extend accordingly.

    Implementation constraints (from the four-prism analysis delivered to the maintainer before the ruling; binding on the dispatch):

    1. ⛔ ilike/$ilike and icontains/$icontains must NOT be aliased onto each other in either vocabulary — raw pattern vs escaped substring is a semantic boundary (documented at Filter AST: like is folded to $contains at the wire — wildcards bind as literals and driver-sql's like/ilike arm is unreachable #7536's own map comment). Include at least one comparand case distinguishing them (% is a LITERAL under icontains).
    2. Scope mirrors the executed set only: no not_icontains — the $ dialect has no $notIcontains, so a negative form would widen the execution surface; that is a separate card if ever wanted.
    3. Acceptance-set change ⇒ dispatch runs model: claude-fable-5 (2026-08-12 semantic-lane clause).

    State transition in the same stroke: needs-user-decision → pm:queue. Dispatch is serialized behind #8703 (same package; both regenerate content/docs/references/** — batch-independence rule), next lane slot. No Blocked-by: lines on this card to re-verify.


    Generated by Claude Code

  3. self-assigned this
    on Aug 16, 2026
  4. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 1
    Session: session_01225pUjnCKWqxcc1PeqKFUq
    Branch: claude/issue-8934-icontains-dialect-parity
    Worktree: objectstack-issue-8934
    Domain: domain:spec
    File surface: packages/spec/src/ui/view.zod.ts (VIEW_FILTER_OPERATORS + alias table), packages/spec/src/data/filter.zod.ts (AST_OPERATOR_MAP), packages/spec/src/data/filter-view-operator-parity.test.ts + comparand-type/text-conformance test tables, one changeset, regenerated content/docs/references/** (stop on breach; explain in the report)
    Container & model: S (contract acceptance-set change), mode:subagent, model: fable — mandatory per the 2026-08-12 semantic-lane clause; dispatch authorized now by maintainer instruction (2026-08-16, verbatim: 「现在就可以派发」, superseding 「重置后再派卡」)
    Serial constraints cleared: no in-flight claim or open PR touches the filter vocabularies or the regenerated-references surface (whole-lane read 06:05Z: zero devs in flight); queue siblings #4001-slice / #8836 / #8163 are explicitly HELD behind this card (shared content/docs/references/** regeneration, seat post #6017 queue order); same-day churn noted — #8935 (a777944) touched data/filter.zod.ts today, dev works off current main


    Generated by Claude Code

  5. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor
    {
      "issue": 8934,
      "status": "done",
      "branch": "claude/issue-8934-icontains-dialect-parity",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9020",
      "premise_still_valid": true,
      "summary": "Implemented the Option A ruling: VIEW_FILTER_OPERATORS gains 'icontains' (no alias rows — measured convention: the alias table bridges spellings already in stored metadata, and a new canonical operator has none; decision recorded on the table's docblock), AST_OPERATOR_MAP gains 'icontains' -> '$icontains' extending the #7536 boundary prose in place (escaped substring vs raw pattern; never aliased onto ilike/$ilike; no not_icontains — pinned by test), plus a CANONICAL_INFIX row for the generic canonicalAstOperator round-trip. Parity suite gains a #8934 block (lowering, %-literal comparand, non-aliasing in both directions, no negative form); FILTER_TEXT_CASES gains one case computed THROUGH parseFilterAST(['name','icontains','100%']) so a future icontains->$ilike fold goes red on every backend importing the table. Verified the PM's mechanism assumptions: VALID_AST_OPERATORS does derive from AST_OPERATOR_MAP keys (no second hand-maintained set); the comparand-type door already judges $icontains (FieldOperatorsSchema key since #5701) so that table needed no change. One consumer-side sync (caught by CI + local run, fixed same round, file surface amended in the PR body): driver-memory's analytics-face case-count pin 11 -> 12, since the new table case joins that face's expressible subset automatically. Per-face impact declared in the PR body, including the one deliberate non-change: driver-memory's legacy direct-caller comparison-node face refuses infix icontains loudly (default arm) — platform doors lower the spelling before any driver.",
      "tests": "Union run at ab81100 (git rev-parse --short HEAD from the run; porcelain clean). Full spec suite: 406 files / 10753 tests passed. Consumer suites (downstream direction, scoped to packages reading these vocabularies): driver-memory 756 passed; driver-sql 1726 passed / 56 skipped; driver-turso 1003 passed; driver-mongodb 385 passed / 143 skipped; driver-sqlite-wasm 395 passed; objectql 3722 passed; metadata-protocol 1524 passed; lint 2065 passed; service-analytics 1722 passed. Typecheck: spec + driver-memory green. check:generated all 13 artifacts current after --fix regenerated gen:docs (the one proved stale). Diff-derived gate union re-run at ab81100 (dispatch-gates.mjs added changeset/docs gates beyond the prompt list: changeset-gate-self-tests, objectui-changeset, docs-audit-scope, quick-reference-counts, role-word, test-source-alias, adr-0087-registration, changeset-no-major, empty-changeset): all green, including type-check-debt --re-measure on the built closure (33 entries, none above recorded; first attempt refused on a stale spec dist after the ablation restore — rebuilt and re-ran, that refusal means NOT MEASURED, not pass). Reverse verification (fix committed first; expected direction RED): ablating only the AST map row turned 3 assertions red (VALID_AST_OPERATORS coverage, isFilterAST bare-triple survival, the #8934 membership pin); restored from commit, tree clean. Nuance reported honestly: the lowering assertions stay green under that ablation because convertComparison's deliberate $-fallback coincidentally emits $icontains — the enforcing guard for the row is the isFilterAST vocabulary gate; the dangerous direction (mis-alias onto $ilike) is guarded by the computed conformance case and the explicit non-aliasing pins.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

  6. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor

    ACCEPT — PR #9020 (claude/issue-8934-icontains-dialect-parity, reviewed at head ab81100). Verified against GitHub, not the report's self-description:

    • Ruling fidelity: icontains added to VIEW_FILTER_OPERATORS and AST_OPERATOR_MAP (icontains → $icontains) + CANONICAL_INFIX row. Constraint 1 (⛔ no ilike aliasing) pinned in BOTH directions in the parity suite, plus the required %-literal comparand case — twice: an explicit parity assertion and a FILTER_TEXT_CASES entry computed THROUGH parseFilterAST, so a future fold onto $ilike goes red on every backend that runs the table. Constraint 2 (⛔ no not_icontains) pinned by four assertions.
    • Alias-fold deviation, accepted as measured: the ruling said "alias folds curated per the existing alias-table conventions"; the dev measured the convention (the table bridges spellings already living in stored metadata; single-token canonicals carry no folds) and correctly derived ZERO alias rows, recording the decision on the table's docblock. That is the convention applied, not skipped.
    • Scope: 8 files, all inside the claimed surface + one declared consumer sync (driver-memory's expressible-subset count pin 11→12, the CI signature from head d570c96, fixed same round). Changeset: spec minor. No releases/ADR/skills paths — clean landing path.
    • Gates: all 29 check runs on ab81100 completed green (Console Pin Gate skipped = untouched, correct). Report includes real per-package consumer readings and a reverse-verification with an honestly-reported nuance (the map-row ablation leaves lowering assertions green via convertComparison's $-fallback; the enforcing guards are the isFilterAST vocabulary gate + the conformance case).

    Landing: origin/main moved past the base (6468df1) and the PR touches os-regen artifacts, so the mechanized relay (scripts/pm/os-regen-merge.sh) is running as a patch round with the original dev. Ready-flip + merge queue follow the relay push's green CI.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions