Skip to content

service-analytics: ObjectQLStrategy.convertFilter has no icontains arm, so any $icontains (a valid comparand included) on a query the ObjectQL strategy serves throws a bare Error with no code or status #20098

Closed

Description

@objectstack-fleet

Filing gate: ① a defect with a named landing site, measured.

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.

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

Dedupe words: ObjectQLStrategy convertFilter icontains · cannot express filter operator icontains · analytics objectql icontains bare Error


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: business reporting | dashboards.cube-query | P2

    Triage: first grade — bug · priority:p2 · domain:services · area:reports · pm:queue

    Triage: lands in packages/services/service-analytics (strategies/objectql-strategy.ts convertFilter, catch-all throw at :1882 on origin/main a8bcce6) ⇒ domain:services; rationale: $icontains is a published operator the engine accepts, but convertFilter has no icontains arm (its cases on main run contains, notContains, startsWith, endsWith, in, notIn, …), so a valid query the ObjectQL strategy serves throws a bare Error — 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) and origin/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 $icontains serves the same rows as the native face, with the ASCII fold the engine uses.

  2. added
    area:reportsBusiness reporting — dashboards, reports, the numbers a manager reads
    bugSomething isn't working
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    pm:queueReady for the PM dispatch loop
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 25, 2026
  3. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1
    Session: session_01Evb5jFDZGKQE9KG4jbMfMF
    Branch: claude/issue-20098-objectql-icontains
    Worktree: objectstack-issue-20098
    Domain: domain:services
    Seat: domain:services#1
    File surface: packages/services/service-analytics/src/strategies/objectql-strategy.ts (convertFilter's operator arms); new or extended test file(s) under packages/services/service-analytics/src/; .changeset/20098-*.md. ⛔ No packages/objectql, spec or drivers. ⛔ Not filter-normalizer.ts (PR #20115 landing), plugin.ts / analytics-service.ts (#20091 in flight) or read-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 as 7c1039b388. The in-flight #20091 (plugin.ts, analytics-service.ts) and PR #20115 (#20040, filter-normalizer.ts) are disjoint from this file.

  4. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    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 --listFiles counted 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 through node scripts/ablation-replace.mjs (WRAP, anchor x1, "ok mutation landed: anchor 1 -> 0, blob … -> …", then "ok restored: blob == HEAD … and git diff HEAD is 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\nBase origin/main 7c1039b388. The fixture is a real ObjectQL engine over a real SqliteWasmDriver (sql.js). Rows (field name): 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 (the where door, caller-authored):\n\n| cell | engine.find direct | ObjectQLStrategy.execute | AnalyticsService.query (ObjectQL) | native execute | echo (run) | AnalyticsService.query (native) |\n|:--|:--|:--|:--|:--|:--|:--|\n| $icontains: 'acme' | a1 a2 | bare Error, code undefined, status undefined: "[analytics] ObjectQL strategy cannot express filter operator "icontains"" | same bare Error | a1 a2 | a1 a2 | a1 a2 |\n| $icontains: 'ACME' | a1 a2 | bare Error | bare Error | a1 a2 | a1 a2 | a1 a2 |\n| $icontains: 'CAFÉ' (ASCII-only fold) | a6 | bare Error | bare Error | a6 | a6 | a6 |\n| $icontains: 'café' | a7 | bare Error | bare Error | a7 | a7 | a7 |\n| $not over $icontains: 'acme' | a3 a4 a5 a6 a7 | bare Error | bare Error | a3 a4 a5 a6 a7 | a3 a4 a5 a6 a7 | a3 a4 a5 a6 a7 |\n| FilterArray ['name','icontains','acme'] | a1 a2 | bare Error | bare Error | 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 shape analytics-query-read-scope-withhold.test.ts uses) over a real AnalyticsService imported from source, with objectqlAggregate: true, nativeSql: false, and executeAggregate bound to engine.aggregate. Read and measured only; packages/runtime is not edited.\n\n| cell | status | body.error | engine.aggregate calls |\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| $not over $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_FAILED at where.name.$icontains, the spec's reason | 0 |\n| $contains: 'cme' (control) | 200 | rows a1 | 1 |\n\n**convertFilter's input vocabulary against its arms.** Each FILTER_OPERATORS spelling was sent through normalizeAnalyticsFilterTree, 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 | bare Error | a1 a2 | missing: fixed here |\n| $null / $exists | notSet / set | a4 | a4 | a4 | covered |\n| $like / $ilike (staged, not in FILTER_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 names service-analytics a 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\nicontains is the only operator the normalizer emits that convertFilter has no arm for. No other operator is missing, so this PR does not widen.\n\n## After (head 109691058d, origin/main 6780e34af5 merged in)\n\n- In process, every cell above answers on every face, and each face answers the rows engine.find answers. 'acme' / 'ACME' give a1 a2. 'CAFÉ' gives a6 only and 'café' gives a7 only. $not gives a3 a4 a5 a6 a7. The FilterArray gives a1 a2.\n- HTTP re-measure: $icontains: 'acme' is 200, rows a1 a2, with 1 engine call. 'CAFÉ' is 200, a6. $not is 200, a3 a4 a5 a6 a7. The FilterArray and '' rows are unchanged: 400 VALIDATION_FAILED at 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 from git worktree list.",
    "pr_body_new": "Fixes #20098\n\nClause-②: no\n\n## What this changes\n\nObjectQLStrategy.convertFilter (packages/services/service-analytics/src/strategies/objectql-strategy.ts) translates each normalized filter leaf into the FilterCondition operand the engine expects. The normalizer emits an icontains leaf for $icontains, and for the FilterArray icontains, which parseFilterAST lowers to $icontains first. convertFilter had no arm for that leaf, so every $icontains fell to its catch-all and threw a bare Error with no code and no status. 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_OPERATORS declares $icontains, and StringOperatorSchema types it z.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. The where door refuses an empty or non-string $icontains before a leaf exists, so String(…) 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 of filterNodeToCondition: 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 as 75a7c920aa, before any source change)\n\nBase origin/main 7c1039b388. The fixture is a real ObjectQL engine over a real SqliteWasmDriver (sql.js). Rows (field name): 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 (the where door, caller-authored):\n\n| cell | engine.find direct | ObjectQLStrategy.execute | AnalyticsService.query (ObjectQL) | native execute | echo (run) | AnalyticsService.query (native) |\n|:--|:--|:--|:--|:--|:--|:--|\n| $icontains: 'acme' | a1 a2 | bare Error, code undefined, status undefined: "[analytics] ObjectQL strategy cannot express filter operator "icontains"" | same bare Error | a1 a2 | a1 a2 | a1 a2 |\n| $icontains: 'ACME' | a1 a2 | bare Error | bare Error | a1 a2 | a1 a2 | a1 a2 |\n| $icontains: 'CAFÉ' (ASCII-only fold) | a6 | bare Error | bare Error | a6 | a6 | a6 |\n| $icontains: 'café' | a7 | bare Error | bare Error | a7 | a7 | a7 |\n| $not over $icontains: 'acme' | a3 a4 a5 a6 a7 | bare Error | bare Error | a3 a4 a5 a6 a7 | a3 a4 a5 a6 a7 | a3 a4 a5 a6 a7 |\n| FilterArray ['name','icontains','acme'] | a1 a2 | bare Error | bare Error | 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 shape analytics-query-read-scope-withhold.test.ts uses) over a real AnalyticsService imported from source, with objectqlAggregate: true, nativeSql: false, and executeAggregate bound to engine.aggregate. Read and measured only; packages/runtime is not edited.\n\n| cell | status | body.error | engine.aggregate calls |\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| $not over $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_FAILED at where.name.$icontains, the spec's reason | 0 |\n| $contains: 'cme' (control) | 200 | rows a1 | 1 |\n\n**convertFilter's input vocabulary against its arms.** Each FILTER_OPERATORS spelling was sent through normalizeAnalyticsFilterTree, 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 | bare Error | a1 a2 | missing: fixed here |\n| $null / $exists | notSet / set | a4 | a4 | a4 | covered |\n| $like / $ilike (staged, not in FILTER_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 names service-analytics a 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\nicontains is the only operator the normalizer emits that convertFilter has no arm for. No other operator is missing, so this PR does not widen.\n\n## After (head 109691058d, origin/main 6780e34af5 merged in)\n\n- In process, every cell above answers on every face, and each face answers the rows engine.find answers. 'acme' / 'ACME' give a1 a2. 'CAFÉ' gives a6 only and 'café' gives a7 only. $not gives a3 a4 a5 a6 a7. The FilterArray gives a1 a2.\n- HTTP re-measure: $icontains: 'acme' is 200, rows a1 a2, with 1 engine call. 'CAFÉ' is 200, a6. $not is 200, a3 a4 a5 a6 a7. The FilterArray and '' rows are unchanged: 400 VALIDATION_FAILED at 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 file packages/services/service-analytics/src/__tests__/objectql-icontains-arm.test.ts, 27 cases, over a real ObjectQL + SqliteWasmDriver:\n\n- Six cells: 'acme', 'ACME', 'CAFÉ', 'café', $not and the FilterArray spelling. Each takes its reference from engine.find first, then asserts the same row ids on ObjectQLStrategy.execute, AnalyticsService.query (ObjectQL), native execute and AnalyticsService.query (native).\n- The engine receives the canonical $icontains with 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- Every FILTER_OPERATORS spelling: 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): its CONTROL case had excluded the ObjectQL faces from the 'acme' row "while convertFilter had no icontains arm". It now runs every face. Every refusal case in that file is unchanged and green.\n\nAt head 109691058d:\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 --listFiles includes all three touched .ts files.\n\n### Ablations (each through scripts/ablation-replace.mjs, WRAP mode, anchor hit x1, all at head 109691058d)\n\nThe tests import the subject by relative src path, and @objectstack/objectql is aliased to src in this package's vitest config, so no dist is in the path.\n\n| leg | mutation | red / green (two files, 71 cases, unless noted) | restore |\n|:--|:--|:--|:--|\n| A (reverse verification) | the icontains arm removed | 11 red / 60 green: all six cells, the verbatim pin, dataset scope + measure filter, the $icontains vocabulary row, the emitted-leaf translation case, and PR #20096's widened control. Its refusal cases stay green: the door refuses before the arm | blob 91aacf6cf586 == HEAD, git diff HEAD empty |\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: returns v0, the equality #4128 removed | 1 red / 26 green (new file only): the catch-all CONTROL | blob == HEAD |\n| D (door first) | #20068's where-door refusal disabled (filter-normalizer.ts) | 6 red / 38 skipped (PR #20096's file, -t ObjectQL): on both ObjectQL faces, $icontains: 42 was answered [], laundered to '42' by the arm's String(…). '' and $not over '' reached the engine (1 call instead of 0). This measures why the refusal must stay first | blob a7bdec971782 == 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 $icontains vocabulary 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 (head 109691058d)\n\nnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 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. --ran reconciliation: 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-inputs and check:test-source-alias. check:type-check-debt re-measured 4 ledger entries with none above its number. check:dual-build-cjs-loads passed: 105 published require entry points across 67 packages load.\n- On the previous head (08dd00660f, before origin/main moved again), the same 60 were run. Two refused their prerequisite (exit 3) until their closure was built: check:type-check-debt then gave exit 0, and check:dual-build-cjs-loads stayed 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 --census spelling takes a workflow value and is NOT MEASURED locally.\n- Lint, narrowed and proven: eslint --no-inline-config --format json over the 3 changed .ts files gave 3 files, 0 errors, 0 warnings. isPathIgnored answers false for each. eslint.config.mjs never enables type-aware linting (no parserOptions.project), so this diff cannot move the verdict of any untouched file.\n- The rebuilt service-analytics/dist/index.js and index.cjs each carry return { $icontains: String(v0) } once, and index.cjs loads under require (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 $icontains first. Ablation D shows the cost if that door ever regressed: on this face, 42 would 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- The FilterArray spelling reaches this face in process only. POST /analytics/query types where as an object and refuses an array whole (400 VALIDATION_FAILED, measured above). The spelling reaches ObjectQLStrategy through direct AnalyticsService.query callers, and its row pin here covers that.\n- The catch-all still throws a bare Error. 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 that convertFilter lacks and the engine accepts. $like / $ilike / $regex are refused at the door on purpose.\n\n"
    }

  5. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Review — ACCEPT (landing gated on CI) · PR #20131 · head 109691058d · domain:services seat · 2026-09-25T07:26Z

    Seat session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub and origin/main, ⛔ not against the report's own account.

    Checklist

    Deviations accepted: two origin/main merges (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

  6. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat · session_01Evb5jFDZGKQE9KG4jbMfMF · 2026-09-25T07:53Z

    • PR fix(service-analytics): the ObjectQL strategy serves $icontains; a valid comparand no longer answers an uncoded 500 (#20098) #20131 → 536f2d52fc on origin/main. Parent count 1 (a queue squash). ObjectQLStrategy.convertFilter carries the icontains arm at that commit. The card closed completed through the PR's one closing keyword.
    • A valid $icontains on an ObjectQL-served analytics query now answers the engine's own rows (ASCII-only fold) instead of a bare Error. HTTP was re-measured: a 500 before, a 200 after. The vocabulary census found no other missing arm.
    • Removed in this stroke, with read-back: pm:dispatched and the assignee. The claim is discharged.
    • Carried off this card: none.

    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

No one assigned

    Labels

    area:reportsBusiness reporting — dashboards, reports, the numbers a manager readsbugSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions