Repository navigation
Description
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')returnsnull, soconvertFiltersToASTthrowsFilterOperatorError(INVALID_FILTER, 400). Any MongoDB-style filter carrying the operator is refused before it reaches the wire.packages/plugin-list/src/ListView.tsx—mapOperatorhas no row, so the operator is emitted verbatim into the array AST. Per that repo's ownfilter-operator-ast-parity.test.ts, an operator outsideVALID_AST_OPERATORScosts 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 byViewFilterRuleSchema'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
os-project-manager commentedon Aug 15, 2026
Triage: lands in
packages/spec—ui/view.zod.ts(VIEW_FILTER_OPERATORS+ its alias table) anddata/filter.zod.ts(AST_OPERATOR_MAP, from whichVALID_AST_OPERATORSderives). Rationale: adding an operator to either vocabulary widens the accepted authoring set, so this isdomain:spec(semantic lane), native typeFeature, and a contract-shape change on the manual-decision floor — routed toneeds-user-decision, not the queue. Dedup checked: #5701 (closed) added$icontainstoFieldOperatorsSchemaonly; #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.6as pinned by objectui):VIEW_FILTER_OPERATORS(ui/view.zod.ts:181) — confirmed: no case-insensitive contains, and no alias folds onto one.AST_OPERATOR_MAP(data/filter.zod.ts) — confirmed: noicontainsrow. One refinement since rc.6: Filter AST:likeis folded to$containsat the wire — wildcards bind as literals and driver-sql'slike/ilikearm is unreachable #7536 addedilike→$ilike, so the AST dialect now has a case-insensitive pattern operator. Adjacent, but not the same capability:$icontainsLIKE-escapes the comparand and wraps it in%…%, while$ilikepasses a raw pattern. The view dialect still has neither.Decision block (four prisms):
$icontainsis 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 infilter-view-operator-parity.test.ts's deliberate list.$icontainsonto plaincontains, silently flipping case semantics against the spec:$regex按 ADR-0049 退役 +$icontains入算子词表与 FILTER_LOGIC_CASES(#4706 裁决 B 案 · 契约半边,先行) #5701/$regexon 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.Recommendation (A): add a canonical case-insensitive contains to both remaining vocabularies —
icontainsinVIEW_FILTER_OPERATORS(name chosen for congruence withilike, already admitted by #7536) plus its alias folds, and anicontains→$icontainsrow inAST_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-5mandatory if ruled (contract acceptance-set change).本评论来自分诊座位 Routine。
Generated by Claude Code
os-zhuang commentedon Aug 16, 2026
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 —
icontainsjoinsVIEW_FILTER_OPERATORS(ui/view.zod.ts), with alias folds curated per the existing alias-table conventions;icontains→$icontainsrow joinsAST_OPERATOR_MAP(data/filter.zod.ts);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):
ilike/$ilikeandicontains/$icontainsmust NOT be aliased onto each other in either vocabulary — raw pattern vs escaped substring is a semantic boundary (documented at Filter AST:likeis folded to$containsat the wire — wildcards bind as literals and driver-sql'slike/ilikearm is unreachable #7536's own map comment). Include at least one comparand case distinguishing them (%is a LITERAL undericontains).not_icontains— the$dialect has no$notIcontains, so a negative form would widen the execution surface; that is a separate card if ever wanted.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 regeneratecontent/docs/references/**— batch-independence rule), next lane slot. NoBlocked-by:lines on this card to re-verify.Generated by Claude Code
os-zhuang commentedon Aug 16, 2026
Claim: PM loop round 1
Session:
session_01225pUjnCKWqxcc1PeqKFUqBranch:
claude/issue-8934-icontains-dialect-parityWorktree:
objectstack-issue-8934Domain:
domain:specFile 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, regeneratedcontent/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) toucheddata/filter.zod.tstoday, dev works off currentmainGenerated by Claude Code
feat(spec): icontains joins the view and infix filter vocabularies (#…
test(driver-memory): analytics-face expressible subset is twelve case…
os-zhuang commentedon Aug 16, 2026
{ "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
os-zhuang commentedon Aug 16, 2026
ACCEPT — PR #9020 (
claude/issue-8934-icontains-dialect-parity, reviewed at headab81100). Verified against GitHub, not the report's self-description:icontainsadded toVIEW_FILTER_OPERATORSandAST_OPERATOR_MAP(icontains→$icontains) +CANONICAL_INFIXrow. Constraint 1 (⛔ noilikealiasing) pinned in BOTH directions in the parity suite, plus the required%-literal comparand case — twice: an explicit parity assertion and aFILTER_TEXT_CASESentry computed THROUGHparseFilterAST, so a future fold onto$ilikegoes red on every backend that runs the table. Constraint 2 (⛔ nonot_icontains) pinned by four assertions.d570c96, fixed same round). Changeset: spec minor. No releases/ADR/skills paths — clean landing path.ab81100completed 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 viaconvertComparison's$-fallback; the enforcing guards are theisFilterASTvocabulary gate + the conformance case).Landing:
origin/mainmoved 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
chore(spec): regenerate content/docs/references on the merged tree (#…
feat(spec): icontains joins the view and infix filter vocabularies (o…
fix(spec): author-visible refusals and prescriptions state each decis…
fix(spec): the protocol 17 → 18 conversion summaries state each decis…
fix(spec): conformance-case notes and names state each decision in wo…
test(spec): the third data/ file group's test titles state each cited…