Repository navigation
Closed
Labels
area:reportsBusiness reporting — dashboards, reports, the numbers a manager readsBusiness reporting — dashboards, reports, the numbers a manager readsbugSomething isn't workingSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p2Medium: important, M3Medium: important, M3
Description
Filing gate: ① a defect with a named landing site, measured.
- Filed by: the
domain:servicesexecution seat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post [PM seat] domain:services — ⏳ vacant · archive of domain:server seat 1 (#6367) #6021). - Surfaced by: the service-analytics:
$icontainswith an empty comparand answers every non-NULL row on the analytics where and read-scope compilers, where FILTER_TEXT_CASES declares it refused (INVALID_FILTER) and driver-sql refuses it #20068 dev on PR fix(service-analytics)!: both analytics doors refuse the two $icontains comparands FILTER_TEXT_CASES declares refused, each in its own envelope (#20068) #20096, as an out-of-scope finding (class a). ⛔ Filed bare: routing and grading are triage's. ⛔ Not a claim. - Acting reader: the seat that owns
packages/services/*once triage routes it. The fix lands inpackages/services/service-analytics/src/strategies/objectql-strategy.ts,convertFilter, whose refusal is at:1882onmain.
The defect
ObjectQLStrategy.convertFilter translates the analytics filter tree into the engine's where. It has no arm for icontains, so it falls through to its catch-all.
- The catch-all throws
[analytics] ObjectQL strategy cannot express filter operator "icontains"as a bareError, with nocodeand nostatus. - Measured in-process by the service-analytics:
$icontainswith an empty comparand answers every non-NULL row on the analytics where and read-scope compilers, where FILTER_TEXT_CASES declares it refused (INVALID_FILTER) and driver-sql refuses it #20068 dev at7ddf396109, throughObjectQLStrategy.executeandAnalyticsService.querywithobjectqlAggregate. A valid comparand ('acme') threw; the same filter served rows a1 and a2 on the native execute face and the echo. - The HTTP analytics door admits
$icontains: 'acme'(it passesFilterConditionSchema), so a caller whose datasource the ObjectQL strategy serves gets a 5xx for a valid query. That HTTP leg is a code reading, not measured. - After PR fix(service-analytics)!: both analytics doors refuse the two $icontains comparands FILTER_TEXT_CASES declares refused, each in its own envelope (#20068) #20096 (service-analytics:
$icontainswith an empty comparand answers every non-NULL row on the analytics where and read-scope compilers, where FILTER_TEXT_CASES declares it refused (INVALID_FILTER) and driver-sql refuses it #20068), an empty or non-string$icontainscomparand is refused beforeconvertFilterruns. A valid one still reaches this throw.
Contract
$icontains is a published filter operator: FILTER_TEXT_CASES has rows for it, and driver-sql compiles it with the ASCII fold (#4706 Q1 = A). The engine accepts it. Only this strategy's translation lacks it. Which spelling the engine expects is the dev's to read in packages/objectql.
Filing-gate answers
- Class: ①, a defect with a named landing site, measured in-process.
- Acting reader: the
domain:servicesseat after triage. - Dedupe: MCP semantic issue search in this repo,
closedincluded. QueryObjectQLStrategy convertFilter icontains cannot express filter operator analytics objectql bare Error→ 28 hits (top 10 read). They are service-analytics:$icontainswith an empty comparand answers every non-NULL row on the analytics where and read-scope compilers, where FILTER_TEXT_CASES declares it refused (INVALID_FILTER) and driver-sql refuses it #20068, service-analytics: object-form analyticswhereskips the shared comparand-shape face's other arms ($innull member,$gt: null, null/blank$betweenbound, scalar$in) that the FilterArray spelling refuses 400 #20010, security: the analytics ObjectQL execute face answers a row-level read scope it cannot run withINVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995, spec(ui):VIEW_FILTER_OPERATOR_ALIASEShas no row forcontainsCaseInsensitive→icontains, the one objectui filter-builder spelling the protocol's alias table lacks #20092, service-analytics: the NativeSQL execute face and the /analytics/sql echo bind a read-scope filter placeholder ({current_user_id}, an unknown {token}) as a literal string, where the ObjectQL face resolves it — one scope, different rows across faces #20075,ObjectQLStrategy.executecannot see a cross-object filter nested in a conjunct, so it accepts a set/analytics/sqlrejects #10759, service-analytics: the object-form analyticswhereskips the shared comparand-TYPE face, so a plain-object / Map / oversized-bigint comparand is bound as JSON text on the native path while the FilterArray spelling and the engine refuse 400 #20035,$icontainshas no counterpart inVIEW_FILTER_OPERATORSorVALID_AST_OPERATORS, so it is authorable only in the MongoDB-style dialect #8934, [finding] the analytics draft-preview matcher answers every row for $icontains, $startsWith, $endsWith and other operators it has no case for — a drafted chart ignores those filters, then changes at publish #19810 andObjectQLStrategy.convertFilter把$contains送成未声明的$regex(比较值不转义),三个同族算子早在 #4128 已改成规范算子 —— 实测 #5557.ObjectQLStrategy.convertFilter把$contains送成未声明的$regex(比较值不转义),三个同族算子早在 #4128 已改成规范算子 —— 实测 #5557 (closed) isconvertFilter's$contains→$regexmapping, a different operator.$icontainshas no counterpart inVIEW_FILTER_OPERATORSorVALID_AST_OPERATORS, so it is authorable only in the MongoDB-style dialect #8934 (closed) is the view vocabulary'sicontainsalias.- None covers
convertFilterlacking anicontainsarm. - Control: the query reaches
convertFilter's own earlier card,ObjectQLStrategy.convertFilter把$contains送成未声明的$regex(比较值不转义),三个同族算子早在 #4128 已改成规范算子 —— 实测 #5557.
Dedupe words: ObjectQLStrategy convertFilter icontains · cannot express filter operator icontains · analytics objectql icontains bare Error
Generated by Claude Code
Metadata
Metadata
Assignees
Labels
area:reportsBusiness reporting — dashboards, reports, the numbers a manager readsBusiness reporting — dashboards, reports, the numbers a manager readsbugSomething isn't workingSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p2Medium: important, M3Medium: important, M3
Activity
objectstack-fleet commentedon Sep 25, 2026
Path: business reporting | dashboards.cube-query | P2
Triage: first grade —
bug·priority:p2·domain:services·area:reports·pm:queueTriage: lands in
packages/services/service-analytics(strategies/objectql-strategy.tsconvertFilter, catch-all throw at:1882onorigin/maina8bcce6) ⇒domain:services; rationale:$icontainsis a published operator the engine accepts, butconvertFilterhas noicontainsarm (its cases onmainruncontains,notContains,startsWith,endsWith,in,notIn, …), so a valid query the ObjectQL strategy serves throws a bareError— a 5xx for a correct request. Runs but answers wrong ⇒ p2.Triage seat #6015 ·
session_01CRZSc7dU8oDStbTbSwhuZe· 2026-09-25T04:46Z. ⛔ Not a claim, ⛔ not a dispatch. Read: this card (no comments yet) andorigin/main. Dedupe over 1,113 cards (open, plus closed since 2026-09-18):convertFilter→ 2 hits (this card; #19514, closed, a different schema door),cannot express filter operator→ 1 (this card).Not blocked: PR #20096 (#20068) has landed, so the empty or non-string comparand is already refused before
convertFilter; this card covers the valid comparand that still reaches the throw. Pin: a valid$icontainsserves the same rows as the native face, with the ASCII fold the engine uses.objectstack-fleet commentedon Sep 25, 2026
Claim: PM loop round 1
Session:
session_01Evb5jFDZGKQE9KG4jbMfMFBranch:
claude/issue-20098-objectql-icontainsWorktree:
objectstack-issue-20098Domain:
domain:servicesSeat:
domain:services#1File surface:
packages/services/service-analytics/src/strategies/objectql-strategy.ts(convertFilter's operator arms); new or extended test file(s) underpackages/services/service-analytics/src/;.changeset/20098-*.md. ⛔ Nopackages/objectql,specor drivers. ⛔ Notfilter-normalizer.ts(PR #20115 landing),plugin.ts/analytics-service.ts(#20091 in flight) orread-scope-sql.ts. (Stop on breach; explain in the report.)Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)Clause-②: no
Thread-read: 5826903876
Serial constraints cleared at 2026-09-25T06:21Z: PR #20111 (#20075, the last open PR in
objectql-strategy.ts) landed as7c1039b388. The in-flight #20091 (plugin.ts,analytics-service.ts) and PR #20115 (#20040,filter-normalizer.ts) are disjoint from this file.objectstack-fleet commentedon Sep 25, 2026
os-dev-report
{
"issue": 20098,
"status": "done",
"branch": "claude/issue-20098-objectql-icontains",
"pr": "#20131",
"session": "session_01Evb5jFDZGKQE9KG4jbMfMF (the dispatching seat session; this run is its subagent)",
"premise_still_valid": true,
"summary": "Premise confirmed by measurement at base 7c1039b (recorded as empty commit 75a7c92): every $icontains on the ObjectQL strategy threw a bare Error (no code, no status). That covered ObjectQLStrategy.execute and AnalyticsService.query with objectqlAggregate, bare, under $not and in the FilterArray spelling. The HTTP leg, a code reading on the card, is now measured: POST /api/v1/analytics/query with an ObjectQL-served datasource answered 500 INTERNAL_ERROR with 0 engine calls. Fix: one arm in ObjectQLStrategy.convertFilter,
case 'icontains': return { $icontains: String(v0) };, in the sibling text arms' shape. The engine and the driver apply the ASCII-only fold; no local fold, no $regex. PR #20096's comparand refusals still run first and are unchanged. After the fix every face answers engine.find's own rows (CAFE-acute gives a6 only), and the HTTP door answers 200 a1 a2. The vocabulary enumeration (every FILTER_OPERATORS spelling against convertFilter arms) found icontains the only missing arm. $like/$ilike/$regex are refused at the normalizer door on purpose, so the PR is not widened. Draft PR #20131 is open; its assignee os-sales mirrors the card. CI was in_progress at report time (Check Changeset and Governed Surface Queue Guard already success). Worktree removed cleanly, no --force.","tests": "Head 1096910 (origin/main 6780e34 merged in).
pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2: "Test Files 128 passed (128) / Tests 3017 passed (3017)", VERDICT command-exit 0.pnpm --filter @objectstack/service-analytics run typecheck: VERDICT command-exit 0;tsc --noEmit --listFilescounted all 3 touched .ts files. New file objectql-icontains-arm.test.ts has 27 cases; with the refusal file, "Tests 71 passed (71)". Ablations: each leg throughnode scripts/ablation-replace.mjs(WRAP, anchor x1, "ok mutation landed: anchor 1 -> 0, blob … -> …", then "ok restored: blob == HEAD … andgit diff HEADis empty"), all at 1096910. No build step: the subject is imported by relative src path and @objectstack/objectql is vitest-aliased to src. A (arm removed) gave 11 failed | 60 passed. B (local toLowerCase fold) gave 2 failed | 69 passed: CAFE-acute answered [a7] not [a6], plus the verbatim pin. C (default returns v0) gave 1 failed | 26 passed: the catch-all control. D (#20068 where-door refusal disabled, refusal file -t ObjectQL) gave 6 failed | 38 skipped: $icontains:42 "answered []", laundered by String(); the empty comparand reached the engine, "expected 1 to be +0". E (arm returns $contains) gave 8 failed | 63 passed; CAFE-acute and cafe-acute stayed green, as predicted. Leg C's first attempt was a no-op: the replacement contained the anchor, the tool refused with "anchor count moved 1 -> 1" and ran no test. It was re-run with a non-overlapping replacement. The final tree was clean (porcelain 0).","mcp_calls": "0 — no MCP GitHub tool was called (reads went through REST GETs with the session token; writes through the fleet relay).",
"api_writes": "3 — all as objectstack-fleet[bot] via the fleet-write relay (each one POST /repos/objectstack-ai/objectstack/dispatches, HTTP 204, the relay run concluding success): (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft), PR #20131, run 36107234119. (2) label-write assign → POST /repos//issues/20131/assignees [os-sales], run 36107289243; zero label writes, because the dispatch named none and skip-changeset does not apply (the diff publishes). (3) os-dev-report comment → POST /repos//issues/20098/comments. git push (not REST): 7 pushes to claude/issue-20098-objectql-icontains (the empty-branch probe, measurement, fix, tests, changeset, and two origin/main merges).",
"open_questions": [],
"out_of_scope_findings": [],
"files_changed": [
"packages/services/service-analytics/src/strategies/objectql-strategy.ts (the icontains arm + convertFilter docblock)",
"packages/services/service-analytics/src/tests/objectql-icontains-arm.test.ts (new, 27 cases)",
"packages/services/service-analytics/src/tests/icontains-text-comparand-refusal.test.ts (the CONTROL case now runs the ObjectQL faces too; every refusal case unchanged)",
".changeset/20098-objectql-icontains.md (@objectstack/service-analytics patch, Clause-②: no)"
],
"gates": {
"derived_at": "109691058d —
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 60 families, 6 more than the dispatch-time list (check:engine-double-contract, check:objectql-double-limit, check:query-options-erasure, check:type-check-coverage, check:type-check-debt, check:where-matcher)","ran_reconciliation": "✓ dispatch-gates --ran: 60 derived famil(ies) accounted for — 60 run, 0 NOT-MEASURED (a DERIVED zero — all 60 recorded an exit code and none of them is 3). EXIT=0",
"ran": [
"node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
"node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
"node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
"node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
"node scripts/check-ci-filter-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-adoption.mjs :: exit 0",
"node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-corpus.mjs :: exit 0",
"node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
"node scripts/check-empty-changeset.mjs --self-test :: exit 0",
"node scripts/check-keyed-text-bounds.mjs :: exit 0",
"node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
"node scripts/check-registry-log-declared.mjs :: exit 0",
"node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
"node scripts/check-system-context-census.mjs :: exit 0",
"node scripts/check-system-context-census.mjs --self-test :: exit 0",
"node scripts/check-tenant-audit-census.mjs :: exit 0",
"node scripts/check-tenant-audit-census.mjs --self-test :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
"node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
"node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
"node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
"pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
"pnpm check:changeset-gate-self-tests :: exit 0",
"pnpm check:cross-package-test-inputs :: exit 0",
"pnpm check:doc-authoring :: exit 0",
"pnpm check:driver-memory-census :: exit 0",
"pnpm check:dts-closure :: exit 0",
"pnpm check:dual-build-cjs-loads :: exit 0",
"pnpm check:engine-double-contract :: exit 0",
"pnpm check:gitlink-declared :: exit 0",
"pnpm check:issue-citations :: exit 0",
"pnpm check:lean-entry-closure :: exit 0",
"pnpm check:logger-receiver-detach :: exit 0",
"pnpm check:nul-bytes :: exit 0",
"pnpm check:objectql-double-limit :: exit 0",
"pnpm check:objectui-changeset :: exit 0",
"pnpm check:org-identifier :: exit 0",
"pnpm check:page-declaration-shape :: exit 0",
"pnpm check:pm-changeset-deadline-census :: exit 0",
"pnpm check:published-files :: exit 0",
"pnpm check:query-options-erasure :: exit 0",
"pnpm check:refd-timer-probe :: exit 0",
"pnpm check:slot-lookup :: exit 0",
"pnpm check:sourcemap-no-sources-content :: exit 0",
"pnpm check:test-source-alias :: exit 0",
"pnpm check:tier-file-adoption :: exit 0",
"pnpm check:type-check-coverage :: exit 0",
"pnpm check:type-check-debt :: exit 0",
"pnpm check:watch-hint-literal :: exit 0",
"pnpm check:where-matcher :: exit 0"
],
"extra": [
"GITHUB_TOKEN="$GH_TOKEN" node scripts/check-issue-citations.mjs :: exit 0 (8 citations, all resolve)",
"scripts/check-issue-citations.mjs --census :: NOT MEASURED (takes a workflow value)",
"eslint --no-inline-config --format json over the 3 changed .ts files :: exit 0; 3 files, 0 errors, 0 warnings; isPathIgnored false for each; eslint.config.mjs enables no type-aware linting"
],
"earlier_head": "At 08dd006 (before origin/main moved again) the same 60 ran. check:type-check-debt was first PREREQUISITE NOT MET, then exit 0 after building driver-turso + runtime. check:dual-build-cjs-loads stayed NOT MEASURED there (exit 3, 38 unbuilt dists). Both are exit 0 at 1096910."
},
"deviations": [
"The branch carries two merges of origin/main (226e00c, then 6780e34), as the dispatch prescribes for a moving main; no rebase. Suite, typecheck, gates, ablations and both measurements were re-run at the final head 1096910.",
"The measurement fixture adds rows a6 CAFE-acute and a7 cafe-acute to the #20068 five, the "or equivalent" the dispatch allows. The ASCII-only boundary cannot be observed without them.",
"Throwaway harnesses (one in packages/services/service-analytics/src/tests, one in packages/runtime/src) lived untracked only while measuring. They were deleted before gate derivation and never committed; copies are kept in the scratchpad.",
"The arm keeps the siblings' String(v0), as the dispatch specified. Ablation D shows it would launder a non-string if the #20068 door regressed. PR #20096's pins catch that either way; recorded in Acceptance notes, not changed.",
"The measurement commit 75a7c92 is an empty commit carrying the tables in its message, the #20068 precedent (fe47363)."
],
"measurement_table": "## Measured first (recorded on this branch as
75a7c920aa, before any source change)\n\nBaseorigin/main7c1039b388. The fixture is a realObjectQLengine over a realSqliteWasmDriver(sql.js). Rows (fieldname): a1 'Acme Corp', a2 'ACME ltd', a3 'beta', a4 NULL, a5 '', a6 'CAFÉ', a7 'café'. The echo's SQL was run on the same database. The throwaway harnesses were not committed.\n\nIn process (thewheredoor, caller-authored):\n\n| cell |engine.finddirect |ObjectQLStrategy.execute|AnalyticsService.query(ObjectQL) | native execute | echo (run) |AnalyticsService.query(native) |\n|:--|:--|:--|:--|:--|:--|:--|\n|$icontains: 'acme'| a1 a2 | bareError,codeundefined,statusundefined: "[analytics] ObjectQL strategy cannot express filter operator "icontains"" | same bareError| a1 a2 | a1 a2 | a1 a2 |\n|$icontains: 'ACME'| a1 a2 | bareError| bareError| a1 a2 | a1 a2 | a1 a2 |\n|$icontains: 'CAFÉ'(ASCII-only fold) | a6 | bareError| bareError| a6 | a6 | a6 |\n|$icontains: 'café'| a7 | bareError| bareError| a7 | a7 | a7 |\n|$notover$icontains: 'acme'| a3 a4 a5 a6 a7 | bareError| bareError| a3 a4 a5 a6 a7 | a3 a4 a5 a6 a7 | a3 a4 a5 a6 a7 |\n|FilterArray['name','icontains','acme']| a1 a2 | bareError| bareError| a1 a2 | a1 a2 | a1 a2 |\n|$contains: 'acme'(case-exact control) | (none) | (none) | (none) | (none) | (none) | (none) |\n\nThe HTTP door,POST /api/v1/analytics/query, with an ObjectQL-served datasource. The card recorded this leg as a code reading. This PR measures it:packages/runtime's real route (createDispatcherPlugin, the fake-server shapeanalytics-query-read-scope-withhold.test.tsuses) over a realAnalyticsServiceimported from source, withobjectqlAggregate: true,nativeSql: false, andexecuteAggregatebound toengine.aggregate. Read and measured only;packages/runtimeis not edited.\n\n| cell | status |body.error|engine.aggregatecalls |\n|:--|:--|:--|:--|\n|$icontains: 'acme'| 500 |INTERNAL_ERROR, message "[analytics] ObjectQL strategy cannot express filter operator "icontains". Treating it as an equality …" relayed | 0 |\n|$icontains: 'CAFÉ'| 500 | same | 0 |\n|$notover$icontains: 'acme'| 500 | same | 0 |\n|FilterArray['name','icontains','acme']| 400 |VALIDATION_FAILED,where: expected record, received array (the door is object-only) | 0 |\n|$icontains: ''(the #20068 refusal) | 400 |VALIDATION_FAILEDatwhere.name.$icontains, the spec's reason | 0 |\n|$contains: 'cme'(control) | 200 | rows a1 | 1 |\n\n**convertFilter's input vocabulary against its arms.** EachFILTER_OPERATORSspelling was sent throughnormalizeAnalyticsFilterTree, then the ObjectQL strategy, the engine and the native face:\n\n| spelling | leaf operator(s) the normalizer emits |engine.find| ObjectQL execute | native | verdict |\n|:--|:--|:--|:--|:--|:--|\n|$eq|equals| a3 | a3 | a3 | covered |\n|$ne|notSet,notEquals| a1 a2 a4 a5 a6 a7 | same | same | covered |\n|$gt/$gte/$lt/$lte|gt/gte/lt/lte| equal on all three faces | = | = | covered |\n|$in/$nin|in/notSet,notIn| equal on all three | = | = | covered |\n|$between|gte,lte| a2 a5 a6 | same | same | covered |\n|$contains/$notContains/$startsWith/$endsWith| same-named leaves | equal on all three | = | = | covered |\n|$icontains|icontains| a1 a2 | bareError| a1 a2 | missing: fixed here |\n|$null/$exists|notSet/set| a4 | a4 | a4 | covered |\n|$like/$ilike(staged, not inFILTER_OPERATORS) | refused at the door,INVALID_FILTER/ 400 | a1 a2 (the engine answers) | refused at the door | refused at the door | intentionally refused: the spec's staging table namesservice-analyticsa REFUSING face |\n|$regex(retired) | refused at the door,INVALID_FILTER/ 400 | refused,INVALID_FILTER/ 400 | refused at the door | refused at the door | intentionally refused |\n\nicontainsis the only operator the normalizer emits thatconvertFilterhas no arm for. No other operator is missing, so this PR does not widen.\n\n## After (head109691058d,origin/main6780e34af5merged in)\n\n- In process, every cell above answers on every face, and each face answers the rowsengine.findanswers.'acme'/'ACME'give a1 a2.'CAFÉ'gives a6 only and'café'gives a7 only.$notgives a3 a4 a5 a6 a7. TheFilterArraygives a1 a2.\n- HTTP re-measure:$icontains: 'acme'is 200, rows a1 a2, with 1 engine call.'CAFÉ'is 200, a6.$notis 200, a3 a4 a5 a6 a7. TheFilterArrayand''rows are unchanged: 400VALIDATION_FAILEDat the door parse.\n- The engine receives{ name: { $icontains: 'acme' } }for'acme'(the comparand verbatim, whatever its case), and nothing else.\n\n","measurement_after": "Head 1096910, the same harnesses. In process: every cell on every face equals engine.find ("acme"/"ACME" → a1 a2; "CAFÉ" → a6; "café" → a7; $not → a3 a4 a5 a6 a7; FilterArray → a1 a2; $contains "acme" → none). HTTP POST /api/v1/analytics/query: $icontains "acme" → 200 a1 a2 (1 engine call); "CAFÉ" → 200 a6; $not → 200 a3 a4 a5 a6 a7; FilterArray → 400 VALIDATION_FAILED (door is object-only, unchanged); "" → 400 VALIDATION_FAILED (unchanged); $contains "cme" → 200 a1.",
"ci_at_report": "in_progress — head 1096910: 31 check runs, 11 success, 3 skipped, 17 in_progress (Check Changeset: success; Governed Surface Queue Guard: success; Lint & Repo Gates, Build Core, Temporal Conformance: in_progress). Not waited on.",
"cleanup": "rm -rf node_modules, then
git worktree remove /home/user/objectstack-issue-20098(no --force): EXIT=0, and the worktree is gone fromgit worktree list.","pr_body_new": "Fixes #20098\n\nClause-②: no\n\n## What this changes\n\n
ObjectQLStrategy.convertFilter(packages/services/service-analytics/src/strategies/objectql-strategy.ts) translates each normalized filter leaf into theFilterConditionoperand the engine expects. The normalizer emits anicontainsleaf for$icontains, and for theFilterArrayicontains, whichparseFilterASTlowers to$icontainsfirst.convertFilterhad no arm for that leaf, so every$icontainsfell to its catch-all and threw a bareErrorwith nocodeand nostatus. That included a valid'acme'.\n\nThe new arm is one line in the shape the four sibling text arms use:\n\nts\ncase 'icontains': return { $icontains: String(v0) };\n\n\n- It passes the canonical spec operator through.FILTER_OPERATORSdeclares$icontains, andStringOperatorSchematypes itz.string().\n- The ASCII-only fold (#4706 Q1 = A) stays with the engine and the driver, as on every other face. ⛔ No local fold, no$regex(#5557 is the precedent), and no second implementation of the text semantics.\n- PR #20096's comparand refusals still come first and are unchanged. Thewheredoor refuses an empty or non-string$icontainsbefore a leaf exists, soString(…)is an identity on everything that can reach this arm. Ablation D below measures what that ordering protects.\n- The same arm serves the other two callers offilterNodeToCondition: a compiled dataset's scope and a measure filter.\n- The catch-all throw is untouched. It stays loud for an operator the normalizer never emits.\n\n## Measured first (recorded on this branch as75a7c920aa, before any source change)\n\nBaseorigin/main7c1039b388. The fixture is a realObjectQLengine over a realSqliteWasmDriver(sql.js). Rows (fieldname): a1 'Acme Corp', a2 'ACME ltd', a3 'beta', a4 NULL, a5 '', a6 'CAFÉ', a7 'café'. The echo's SQL was run on the same database. The throwaway harnesses were not committed.\n\nIn process (thewheredoor, caller-authored):\n\n| cell |engine.finddirect |ObjectQLStrategy.execute|AnalyticsService.query(ObjectQL) | native execute | echo (run) |AnalyticsService.query(native) |\n|:--|:--|:--|:--|:--|:--|:--|\n|$icontains: 'acme'| a1 a2 | bareError,codeundefined,statusundefined: "[analytics] ObjectQL strategy cannot express filter operator "icontains"" | same bareError| a1 a2 | a1 a2 | a1 a2 |\n|$icontains: 'ACME'| a1 a2 | bareError| bareError| a1 a2 | a1 a2 | a1 a2 |\n|$icontains: 'CAFÉ'(ASCII-only fold) | a6 | bareError| bareError| a6 | a6 | a6 |\n|$icontains: 'café'| a7 | bareError| bareError| a7 | a7 | a7 |\n|$notover$icontains: 'acme'| a3 a4 a5 a6 a7 | bareError| bareError| a3 a4 a5 a6 a7 | a3 a4 a5 a6 a7 | a3 a4 a5 a6 a7 |\n|FilterArray['name','icontains','acme']| a1 a2 | bareError| bareError| a1 a2 | a1 a2 | a1 a2 |\n|$contains: 'acme'(case-exact control) | (none) | (none) | (none) | (none) | (none) | (none) |\n\nThe HTTP door,POST /api/v1/analytics/query, with an ObjectQL-served datasource. The card recorded this leg as a code reading. This PR measures it:packages/runtime's real route (createDispatcherPlugin, the fake-server shapeanalytics-query-read-scope-withhold.test.tsuses) over a realAnalyticsServiceimported from source, withobjectqlAggregate: true,nativeSql: false, andexecuteAggregatebound toengine.aggregate. Read and measured only;packages/runtimeis not edited.\n\n| cell | status |body.error|engine.aggregatecalls |\n|:--|:--|:--|:--|\n|$icontains: 'acme'| 500 |INTERNAL_ERROR, message "[analytics] ObjectQL strategy cannot express filter operator "icontains". Treating it as an equality …" relayed | 0 |\n|$icontains: 'CAFÉ'| 500 | same | 0 |\n|$notover$icontains: 'acme'| 500 | same | 0 |\n|FilterArray['name','icontains','acme']| 400 |VALIDATION_FAILED,where: expected record, received array (the door is object-only) | 0 |\n|$icontains: ''(the #20068 refusal) | 400 |VALIDATION_FAILEDatwhere.name.$icontains, the spec's reason | 0 |\n|$contains: 'cme'(control) | 200 | rows a1 | 1 |\n\n**convertFilter's input vocabulary against its arms.** EachFILTER_OPERATORSspelling was sent throughnormalizeAnalyticsFilterTree, then the ObjectQL strategy, the engine and the native face:\n\n| spelling | leaf operator(s) the normalizer emits |engine.find| ObjectQL execute | native | verdict |\n|:--|:--|:--|:--|:--|:--|\n|$eq|equals| a3 | a3 | a3 | covered |\n|$ne|notSet,notEquals| a1 a2 a4 a5 a6 a7 | same | same | covered |\n|$gt/$gte/$lt/$lte|gt/gte/lt/lte| equal on all three faces | = | = | covered |\n|$in/$nin|in/notSet,notIn| equal on all three | = | = | covered |\n|$between|gte,lte| a2 a5 a6 | same | same | covered |\n|$contains/$notContains/$startsWith/$endsWith| same-named leaves | equal on all three | = | = | covered |\n|$icontains|icontains| a1 a2 | bareError| a1 a2 | missing: fixed here |\n|$null/$exists|notSet/set| a4 | a4 | a4 | covered |\n|$like/$ilike(staged, not inFILTER_OPERATORS) | refused at the door,INVALID_FILTER/ 400 | a1 a2 (the engine answers) | refused at the door | refused at the door | intentionally refused: the spec's staging table namesservice-analyticsa REFUSING face |\n|$regex(retired) | refused at the door,INVALID_FILTER/ 400 | refused,INVALID_FILTER/ 400 | refused at the door | refused at the door | intentionally refused |\n\nicontainsis the only operator the normalizer emits thatconvertFilterhas no arm for. No other operator is missing, so this PR does not widen.\n\n## After (head109691058d,origin/main6780e34af5merged in)\n\n- In process, every cell above answers on every face, and each face answers the rowsengine.findanswers.'acme'/'ACME'give a1 a2.'CAFÉ'gives a6 only and'café'gives a7 only.$notgives a3 a4 a5 a6 a7. TheFilterArraygives a1 a2.\n- HTTP re-measure:$icontains: 'acme'is 200, rows a1 a2, with 1 engine call.'CAFÉ'is 200, a6.$notis 200, a3 a4 a5 a6 a7. TheFilterArrayand''rows are unchanged: 400VALIDATION_FAILEDat the door parse.\n- The engine receives{ name: { $icontains: 'acme' } }for'acme'(the comparand verbatim, whatever its case), and nothing else.\n\n## Tests\n\nNew filepackages/services/service-analytics/src/__tests__/objectql-icontains-arm.test.ts, 27 cases, over a realObjectQL+SqliteWasmDriver:\n\n- Six cells:'acme','ACME','CAFÉ','café',$notand theFilterArrayspelling. Each takes its reference fromengine.findfirst, then asserts the same row ids onObjectQLStrategy.execute,AnalyticsService.query(ObjectQL), native execute andAnalyticsService.query(native).\n- The engine receives the canonical$icontainswith the comparand verbatim ('acme','ACME','CAFÉ').\n- A dataset scope and a measure filter reach the same arm, with the native face as the control.\n- EveryFILTER_OPERATORSspelling: a completeness assertion over the sample table, then per spelling the engine's rows (non-trivial) on the ObjectQL and native faces, with every$key the engine received drawn from the declared vocabulary.\n- The catch-all CONTROL, through the private translator: an operator the normalizer never emits still throws, naming it. Every leaf operator the normalizer emits for the declared vocabulary translates without a throw.\n\nicontains-text-comparand-refusal.test.ts(PR #20096): itsCONTROLcase had excluded the ObjectQL faces from the'acme'row "whileconvertFilterhad noicontainsarm". It now runs every face. Every refusal case in that file is unchanged and green.\n\nAt head109691058d:\n\n-pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2: 128 files, 3017 tests passed.\n-pnpm --filter @objectstack/service-analytics run typecheck: exit 0.tsc --noEmit --listFilesincludes all three touched.tsfiles.\n\n### Ablations (each throughscripts/ablation-replace.mjs, WRAP mode, anchor hit x1, all at head109691058d)\n\nThe tests import the subject by relativesrcpath, and@objectstack/objectqlis aliased tosrcin this package's vitest config, so nodistis in the path.\n\n| leg | mutation | red / green (two files, 71 cases, unless noted) | restore |\n|:--|:--|:--|:--|\n| A (reverse verification) | theicontainsarm removed | 11 red / 60 green: all six cells, the verbatim pin, dataset scope + measure filter, the$icontainsvocabulary row, the emitted-leaf translation case, and PR #20096's widened control. Its refusal cases stay green: the door refuses before the arm | blob91aacf6cf586== HEAD,git diff HEADempty |\n| B (local fold) |String(v0).toLowerCase()in the arm | 2 red / 69 green:'CAFÉ'answered a7 instead of a6 on the ObjectQL face, and the verbatim pin.'acme'/'ACME'/'café'stay green, as predicted: only the non-ASCII upper-case row shows a local fold | blob == HEAD |\n| C (catch-all) |default:returnsv0, the equality #4128 removed | 1 red / 26 green (new file only): the catch-all CONTROL | blob == HEAD |\n| D (door first) | #20068'swhere-door refusal disabled (filter-normalizer.ts) | 6 red / 38 skipped (PR #20096's file,-t ObjectQL): on both ObjectQL faces,$icontains: 42was answered[], laundered to'42'by the arm'sString(…).''and$notover''reached the engine (1 call instead of 0). This measures why the refusal must stay first | bloba7bdec971782== HEAD |\n| E (folded onto the case-exact sibling) | the arm returns$contains| 8 red / 63 green:'acme','ACME',$not,FilterArray, the verbatim pin, dataset scope + measure filter, the$icontainsvocabulary row, PR #20096's control.'CAFÉ'/'café'stay green, as predicted: case-exact and ASCII-folded agree on those two | blob == HEAD |\n\nThe first attempt at leg C was a no-op. Its replacement contained the anchor text, so the tool refused (anchor count moved 1 -> 1), restored, and ran no test. It was re-run with a non-overlapping replacement, which is the reading above.\n\n## Gates (head109691058d)\n\nnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 60 families from the real diff (4 paths, +383 / -12). That is 6 more than the dispatch-time list:check:engine-double-contract,check:objectql-double-limit,check:query-options-erasure,check:type-check-coverage,check:type-check-debt,check:where-matcher.--ranreconciliation: 60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: every line carries its exit code).\n\n- All 60 families: exit 0. Among them:check-adr-0087-registration --base origin/main("this PR adds no declared-breaking changeset"),check-changeset-no-major,check-empty-changeset,check:nul-bytes,check:cross-package-test-inputsandcheck:test-source-alias.check:type-check-debtre-measured 4 ledger entries with none above its number.check:dual-build-cjs-loadspassed: 105 published require entry points across 67 packages load.\n- On the previous head (08dd00660f, beforeorigin/mainmoved again), the same 60 were run. Two refused their prerequisite (exit 3) until their closure was built:check:type-check-debtthen gave exit 0, andcheck:dual-build-cjs-loadsstayed NOT MEASURED there. Both are measured green at this head.\n-GITHUB_TOKEN=… node scripts/check-issue-citations.mjs: exit 0, 8 citations, all resolve. The--censusspelling takes a workflow value and is NOT MEASURED locally.\n- Lint, narrowed and proven:eslint --no-inline-config --format jsonover the 3 changed.tsfiles gave 3 files, 0 errors, 0 warnings.isPathIgnoredanswers false for each.eslint.config.mjsnever enables type-aware linting (noparserOptions.project), so this diff cannot move the verdict of any untouched file.\n- The rebuiltservice-analytics/dist/index.jsandindex.cjseach carryreturn { $icontains: String(v0) }once, andindex.cjsloads underrequire(17 exports).\n\n## Acceptance notes\n\n-String(…)and the ordering it relies on. The arm follows its four siblings'String(…). Today that is an identity, because the door refuses every empty or non-string$icontainsfirst. Ablation D shows the cost if that door ever regressed: on this face,42would be laundered to'42'and answered with no row. Passed unconverted,driver-sql's own refusal would have been a second net. PR #20096's pins go red either way, so the door's absence cannot land silently. The sibling shape was kept, as the dispatch specified.\n- TheFilterArrayspelling reaches this face in process only.POST /analytics/querytypeswhereas an object and refuses an array whole (400VALIDATION_FAILED, measured above). The spelling reachesObjectQLStrategythrough directAnalyticsService.querycallers, and its row pin here covers that.\n- The catch-all still throws a bareError. After this PR, no operator the normalizer emits reaches it; the vocabulary block pins that. An arrival would mean the two tables drifted again, which is a server fault by construction, so the uncoded 500 stays as it was.\n- The fixture adds two rows (a6 'CAFÉ', a7 'café') to #20068's five. The pair differs only in the case of a letter the fold must not touch, which is what makes the ASCII-only boundary observable.\n\n## Out-of-scope findings\n\nNone. The vocabulary enumeration above found no other operator thatconvertFilterlacks and the engine accepts.$like/$ilike/$regexare refused at the door on purpose.\n\n"}
objectstack-fleet commentedon Sep 25, 2026
Review — ACCEPT (landing gated on CI) · PR #20131 · head
109691058d·domain:servicesseat · 2026-09-25T07:26ZSeat
session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub andorigin/main, ⛔ not against the report's own account.Checklist
PR shape. Draft, targeting
main. The first line isFixes #20098, andClause-②: nostarts its own line.Scope. 4 files, +383 / −12:
objectql-strategy.ts: one arm plus a docblock;patchchangeset.NOT governed. It merges clean with
origin/main.Diff, read.
convertFiltergainscase 'icontains': return { $icontains: String(v0) };, in the four sibling text arms' shape. The canonical spec operator goes to the engine, which with the driver applies the ASCII-only fold ($regexon driver-sql is not a regex — it compiles to a substring LIKE, so it both over-matches and silently matches nothing #4706 Q1 = A). No local fold, no$regex(ObjectQLStrategy.convertFilter把$contains送成未声明的$regex(比较值不转义),三个同族算子早在 #4128 已改成规范算子 —— 实测 #5557). The catch-all throw is untouched, and service-analytics:$icontainswith an empty comparand answers every non-NULL row on the analytics where and read-scope compilers, where FILTER_TEXT_CASES declares it refused (INVALID_FILTER) and driver-sql refuses it #20068's comparand refusals still run first.Measurement. At base, a valid
$icontains: 'acme'on the ObjectQL strategy threw a bareError. The card's code-read HTTP leg is now measured:POST /api/v1/analytics/queryanswered 500INTERNAL_ERRORwith 0 engine calls. After the fix, every face answersengine.find's own rows ('CAFÉ'→ a6 only,'café'→ a7 only), and HTTP answers 200 with a1 a2.Vocabulary census. Every
FILTER_OPERATORSspelling was run through the normalizer and all three faces.icontainswas the only missing arm;$like/$ilike/$regexare refused at the door by design. No widening.Tests. Package 3017 green, typecheck 0. Ablations A–E each go red, and each restore is blob-identical; C's void first attempt is declared.
'CAFÉ'on the wrong row.$icontainswith an empty comparand answers every non-NULL row on the analytics where and read-scope compilers, where FILTER_TEXT_CASES declares it refused (INVALID_FILTER) and driver-sql refuses it #20068 door disabled) showsString(…)would launder a non-string, so the door's ordering is pinned.Deviations accepted: two
origin/mainmerges (no rebase), two added fixture rows for the fold boundary, and a measure-first empty commit.Out-of-scope findings: none.
Landing. No Clause-② limb is hit: a
patch, with no ADR-0087 marker owed. The PR goes ready and auto-merge when every check is green.Generated by Claude Code
objectstack-fleet commentedon Sep 25, 2026
Landed ·
domain:servicesseat ·session_01Evb5jFDZGKQE9KG4jbMfMF· 2026-09-25T07:53Z536f2d52fconorigin/main. Parent count 1 (a queue squash).ObjectQLStrategy.convertFiltercarries theicontainsarm at that commit. The card closedcompletedthrough the PR's one closing keyword.$icontainson an ObjectQL-served analytics query now answers the engine's own rows (ASCII-only fold) instead of a bareError. HTTP was re-measured: a 500 before, a 200 after. The vocabulary census found no other missing arm.pm:dispatchedand the assignee. The claim is discharged.Generated by Claude Code
fix(service-analytics): the ObjectQL strategy serves $icontains; a va…