Skip to content

service-analytics: $icontains with 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

Closed

Description

@objectstack-fleet

Filing gate: ① a defect with a named landing site. The spec declares a refusal that this package's compilers do not enforce.

The contract

packages/spec/src/data/filter-text-comparand.ts (#18113, on the objectui#9048 ruling) says that FILTER_TEXT_CASES "declares two REJECTION rows for this operator (an empty comparand and a non-string one), each with code: 'INVALID_FILTER' and mustMention: ['$icontains']". It publishes the discrimination as isRefusedTextComparand. FilterConditionSchema calls it at parse (filter.zod.ts), and driver-sql refuses the empty comparand too (#5702).

The defect

service-analytics does not call it. Measured by the #20025 dev at 980bc05e5b, before and after PR #20067 (that PR does not move this cell):

  • An empty $icontains comparand compiles to a predicate that is true for every non-NULL value. It was lower(col) GLOB lower('**') before that PR, and it is instr(lower(col), lower('')) > 0 after.
  • It is not refused. That holds on compileScopedFilterToSql, NativeSQLStrategy's where and the ObjectQLStrategy echo, and in-process through AnalyticsService.query.

So one filter gets two answers: INVALID_FILTER at parse and on the driver face, and every row on the analytics compilers.

Not measured: whether an HTTP caller can reach it. The REST analytics where is typed FilterConditionSchema, whose parse runs the predicate, so the HTTP door may refuse it first. The in-process AnalyticsService.query and the exported compileScopedFilterToSql are measured.

Filing-gate answers

Dedupe words: analytics icontains empty comparand · isRefusedTextComparand service-analytics · FILTER_TEXT_CASES rejection analytics


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Blocked-by: #20035

    分诊首次定级:priority:p2 · security · bug · domain:services · pm:blocked —— 分析服务的 where 和读范围编译器,遇到比较值为空串的 $icontains 不拒绝,反而匹配所有非空行;而 spec 的 FILTER_TEXT_CASES 声明它要以 INVALID_FILTER 拒绝,driver-sql 也会拒绝

    Path: packages/services/service-analytics/src/strategies/filter-normalizer.ts(where 的降级)· packages/services/service-analytics/src/read-scope-sql.ts(读范围编译器)· 契约 packages/spec/src/data/filter-text-comparand.ts(isRefusedTextComparand)

    Triage: lands in service-analytics ⇒ domain:services, security, bug, priority:p2, pm:blocked Blocked-by #20035; rationale: the spec publishes isRefusedTextComparand and FilterConditionSchema and driver-sql refuse an empty $icontains, but this package never calls it, so an empty comparand compiles to a predicate true for every non-NULL row on compileScopedFilterToSql, the native where and the echo (measured by the #20025 dev before and after PR #20067) — on a read scope, a placeholder that resolves to an empty string would widen the scope to every row, hence p2 with security, the class of #20025; both files are being edited in flight (the #20035 branch rewrites filter-normalizer.ts, PR #20072 edits read-scope-sql.ts), so it waits.

    分诊席 #6015,2026-09-25T02:23Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectstack origin/main 7f1de2eb66 上核对。

    本席核对

    定级说明

    p2 加 security:读范围如果用了一个解析成空串的占位符(例如 {user.department} 为空),这条范围就会放行所有行。与 #20025 同级。

    解锁后的执行要点

    1. 排序:先等 service-analytics: the object-form analytics where skips 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 落地(它改的文件最大),再确认 security: the analytics ObjectQL execute face answers a row-level read scope it cannot run with INVALID_FILTER / 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995 的 PR fix(service-analytics): the ObjectQL face refuses a read scope carrying a placeholder the engine cannot resolve in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope (#19995) #20072 是否已经落地。仍在途的话,按领卡时的文件检查排在它后面。
    2. 两处都调用 spec 公布的 isRefusedTextComparand:where 降级和读范围编译器都用它拒绝,返回 INVALID_FILTER / 400,报错里带 $icontains。在读范围里的拒绝,按 [A of #7929] a spec-declared provenance mark set at both read-scope merge boundaries, so the driver can restore the author-facing cross-field diagnostic without re-disclosing policy #8220 的来源接缝走。
    3. 钉子:空 $icontains 在原生 where、回显、读范围编译器和进程内 AnalyticsService.query 上都被拒绝;非空比较值照常。

    Generated by Claude Code

  2. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock re-derivation: Blocked-by: #20035 is satisfied · domain:services seat · 2026-09-25T02:48Z

    Seat session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021. Read against origin/main 7ddf396109.


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1
    Session: session_01Evb5jFDZGKQE9KG4jbMfMF
    Branch: claude/issue-20068-analytics-icontains-empty
    Worktree: objectstack-issue-20068
    Domain: domain:services
    Seat: domain:services#1
    File surface: packages/services/service-analytics/src/strategies/filter-normalizer.ts (the where door's text-comparand gate); src/read-scope-sql.ts (the read-scope text arm's gate, case '$icontains'); src/preview-evaluator.ts only if the draft preview answers the cell differently; pins that assert today's $icontains: '' admission, re-judged with notes; new test file(s) under packages/services/service-analytics/src/; .changeset/20068-*.md. ⛔ No packages/spec (the predicate is already published), no packages/drivers/*, no packages/rest. ⛔ Only the two declared REJECTION rows (an empty and a non-string $icontains comparand); ⛔ no widening by analogy to $contains & co. (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 (narrowing)
    Thread-read: 5825862657
    Serial constraints cleared at 2026-09-25T02:51Z: PR #20058 (#20035, filter-normalizer.ts) landed as 7ddf396109, and PR #20072 (#19995, read-scope-sql.ts) as 60fdaa9e27. No open PR touches service-analytics. #20075 (also read-scope-sql.ts) is queued behind this card, and #19995 (decision box) has nothing in flight.

  4. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 20068,
    "status": "done",
    "branch": "claude/issue-20068-analytics-icontains-empty",
    "pr": "#20096",
    "session": "session_01Evb5jFDZGKQE9KG4jbMfMF",
    "premise_still_valid": true,
    "summary": "Both analytics doors now call the spec's isRefusedTextComparand for an $icontains comparand and seat textComparandRefusalReason in their own envelope. The where door (filter-normalizer.ts assertCompilableComparand, inside the #7693 fence after the renderability check) answers INVALID_FILTER / 400 naming $icontains. The read scope answers READ_SCOPE_COMPILE_FAILED / 500, withheld, on all three faces: compileOperator's case '$icontains' covers compileScopedFilterToSql, native applyReadScope and the echo; assertReadScopeComparandsRunnable covers the ObjectQL engine-bound merges. That second site is a declared in-place extension: measured, the ObjectQL face answered the same scope with driver-sql's relayed 400 naming the policy field and comparand, so the claim's file note needs 'assertReadScopeComparandsRunnable' added. Premise held (0 call sites on 7ddf396). One premise detail was off: the dispatch called the non-string comparand 'already refused', but measured only an OBJECT was refused (type face); 42 / true / null / Date compiled and were bound as text on both doors, and they are now refused as the table's non-string row. HTTP: /api/v1/analytics/query, /analytics/sql and /analytics/dataset/query already refused a caller-authored '' or 42 at body parse (400 VALIDATION_FAILED); a host getReadScope carrying $icontains '' was served 200 over the dataset route and now answers 500 READ_SCOPE_COMPILE_FAILED with 0 statements. Read-scope reach: CEL never emits $icontains and no placeholder resolves to ''; only a host getReadScope or a direct compileScopedFilterToSql caller carries it. The draft preview is not edited: it already refuses every $icontains in the same envelope (#19810). No labels written (dispatch named none, skip-changeset does not apply); PR assignee set to the card's os-sales. Worktree and node_modules removed. CI in_progress at report time.",
    "tests": "Head ec20b23 (after merging origin/main 338feda; 4 unrelated commits). service-analytics full suite: 'Test Files 124 passed (124) / Tests 2886 passed (2886)'; typecheck 'tsc --noEmit' exit 0, --listFiles counts all 3 touched test files. New icontains-text-comparand-refusal.test.ts: 44 passed. Before the fix the package suite ran 1 failed | 2841 passed, the failure being the 'fence is NARROW' pin (5 / null), which was re-judged (flipped to refusals, 'admin' control kept, note added). text-match-sqlite-nul.test.ts header prose re-judged. Census of pins admitting '' / non-string $icontains: service-analytics 2 assertions + 1 prose note; packages/rest 0; packages/runtime 0. packages/rest analytics suites against the rebuilt dist (consumer sanity): 5 files + the throwaway harness, 68 passed. Ablations via scripts/ablation-replace.mjs WRAP mode, anchor x1, restore proven blob == HEAD and git diff HEAD empty. Subject resolved through relative src imports, so no dist is in the path. A where gate 'if (false && ...)' -> 26 red / 136 green (new file + cross-field pin; the preview case stayed green, as predicted). B read-scope arm gate removed -> 1 red / 43 green (the lowering-time placement pin; the merge walk still refuses in the same envelope, as predicted). C merge walk disabled -> 6 red / 38 green (the ObjectQL faces fell back to the driver's relayed 400). D where gate widened to TEXT_PATTERN_OPERATORS -> 2 red / 42 green (both no-widening pins). E read-scope $contains arm given the gate -> 2 red / 42 green (both no-widening pins). Lint narrowed: eslint --no-inline-config --format json on the 5 changed .ts files -> 5 files, 0 errors, 0 warnings. isPathIgnored is false for them, and eslint.config.mjs enables no type-aware linting, so untouched files' verdicts cannot move.",
    "mcp_calls": "0 — no MCP GitHub tool called (reads went through curl GET on the REST API).",
    "api_writes": "3 — all through the fleet-write relay (scripts/pm/fleet-write/dispatch.mjs, transport dispatch, as objectstack-fleet[bot]): (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (draft) = PR #20096, run 36092367241; (2) label-write --assign os-sales -> POST /repos//issues/20096/assignees, run 36092426854, read-back matches; (3) os-dev-report comment -> POST /repos//issues/20068/comments. Plus git pushes (not REST): branch probe, measurement commit, fix, tests, changeset, merge.",
    "open_questions": [],
    "out_of_scope_findings": [
    "class: a · ObjectQLStrategy.convertFilter has no icontains arm: any $icontains, a valid 'acme' included, on a query the ObjectQL strategy serves throws a bare Error with no code/status ('[analytics] ObjectQL strategy cannot express filter operator "icontains"'). Measured in-process via ObjectQLStrategy.execute and AnalyticsService.query (objectqlAggregate) at 7ddf396; the HTTP door admits the filter, and the HTTP leg is a code reading. After this PR '' and non-strings are refused earlier; a valid comparand still fails. Lands in packages/services/service-analytics/src/strategies/objectql-strategy.ts · dedupe words: ObjectQLStrategy convertFilter icontains · cannot express filter operator icontains · analytics objectql icontains bare Error",
    "carrier: 承接者:无 · noted, not filed — CEL compileCelToFilter('name.contains(current_user.X)') with an empty X lowers to {name: {$contains: ''}}, which admits every non-NULL row on every read-scope face. $contains has no REJECTION row and this card must not widen by analogy; it is the table owner's call (Acceptance notes)",
    "carrier: 承接者:无 · noted, not filed — evaluateAnalyticsQueryOverRows (draft preview) does not lower a FilterArray where: matchesWhere reads the array as an object keyed 0/1/2, so every FilterArray answers no row while the published door lowers it. Reachability through queryDataset (object-only schema doors) was not proven (Acceptance notes)"
    ],
    "gates": [
    "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:dispatcher-error-vocabulary :: 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 3 PREREQUISITE NOT MET -> NOT MEASURED (38 packages unbuilt; proxy: service-analytics dist/index.cjs loads)",
    "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 3 (PREREQUISITE NOT MET) then exit 0 after building @objectstack/runtime",
    "pnpm check:watch-hint-literal :: exit 0",
    "pnpm check:where-matcher :: exit 0"
    ],
    "gates_reconciliation": "dispatch-gates --commands (from the real diff, merge base 338feda): 61 families. The dispatch-time list had 55; the new six are check:engine-double-contract, check:objectql-double-limit, check:query-options-erasure, check:type-check-coverage, check:type-check-debt, check:where-matcher. --ran with exit annotations: 61 derived, 60 run, 1 NOT-MEASURED (claimed with a reason), 0 UNRUN.",
    "files_changed": [
    ".changeset/20068-analytics-icontains-empty.md (new; minor + bang + Clause-②: no (narrowing); ADR-0087 marker not-required (already-registered filter-icontains-comparand-refused-at-parse))",
    "packages/services/service-analytics/src/strategies/filter-normalizer.ts",
    "packages/services/service-analytics/src/read-scope-sql.ts",
    "packages/services/service-analytics/src/tests/icontains-text-comparand-refusal.test.ts (new)",
    "packages/services/service-analytics/src/tests/cross-field-reference-refusal.test.ts (pin re-judged)",
    "packages/services/service-analytics/src/tests/text-match-sqlite-nul.test.ts (header prose re-judged)"
    ],
    "deviations": [
    "Declared in-place scope extension: assertReadScopeComparandsRunnable (read-scope-sql.ts, the claimed file) now also asks the predicate, so the ObjectQL execute face refuses in the withheld envelope instead of relaying driver-sql's 400 with policy content. It meets the four in-place conditions; objectql-strategy.ts is untouched. The seat is asked to amend the claim's file note. It closes one relayed-policy class on the ObjectQL face that #19995's decision list did not name; #19995 remains open.",
    "The dispatch's 'already-refused non-string' control was not already refused: only an object was. Scalars (42 / true / null / Date) compiled and bound as text; they are now refused under the table's non-string row, as the claim's scope ('the two declared REJECTION rows') intends.",
    "The ADR-0087 disposition is not-required (already-registered ...), not the #20035 no-migration-prescription form: the existing ledger entry filter-icontains-comparand-refused-at-parse already records this transition, and the gate accepts it.",
    "check:dual-build-cjs-loads NOT MEASURED (PREREQUISITE NOT MET: 38 packages unbuilt). The diff touches no build config, exports or package.json, and a narrow proxy loads service-analytics' CJS dist.",
    "The HTTP legs were measured with throwaway harnesses placed in packages/rest/src and packages/runtime/src, run and deleted, never committed (the #20072 precedent). The measurement was recorded as an empty commit fe47363.",
    "The draft preview (preview-evaluator.ts) is not edited: for this cell it already answers INVALID_FILTER / 400 naming $icontains."
    ],
    "measurement": "## Measured first (recorded on this branch as fe47363cd9, before any source change)\n\nBase origin/main 7ddf396109. A real sql.js engine (driver-sqlite-wasm) over rows a1 'Acme Corp', a2 'ACME ltd', a3 'beta', a4 NULL and a5 '' (field name), with a real ObjectQL engine behind the ObjectQL face. The echo's SQL was run on the same database. The HTTP legs were read and measured only: packages/runtime's dispatcher route and packages/rest's dataset route, each over a real AnalyticsService.\n\nThe where door (caller-authored):\n\n| cell | native execute | echo (run) | ObjectQL engine path | draft preview | AnalyticsService.query (native) |\n|:--|:--|:--|:--|:--|:--|\n| $icontains: '' | a1 a2 a3 a5 (every non-NULL row) | a1 a2 a3 a5 | bare Error, no code (see finding 1) | 400 INVALID_FILTER, names $icontains ("not evaluated") | a1 a2 a3 a5 |\n| $icontains: 'acme' (control) | a1 a2 | a1 a2 | bare Error, no code | 400, not evaluated | a1 a2 |\n| $icontains: 42 / true / null | no row (bound as text) | no row | bare Error | 400, not evaluated | no row |\n| $icontains: {a:1} | 400, type face (every face) | = | = | = | = |\n| $not over $icontains: '' | a4 only | a4 only | bare Error | 400, not evaluated | a4 only |\n| FilterArray ['name','icontains',''] | a1 a2 a3 a5 | a1 a2 a3 a5 | bare Error | no row (the preview does not lower a FilterArray) | a1 a2 a3 a5 |\n| $contains: '' (no-widening control) | a1 a2 a3 a5 | a1 a2 a3 a5 | a1 a2 a3 a5 | a1 through a5 | a1 a2 a3 a5 |\n\nThe read scope (policy, host getReadScope):\n\n| cell | compileScopedFilterToSql (run) | native applyReadScope | echo (run) | ObjectQL execute (withReadScope) |\n|:--|:--|:--|:--|:--|\n| $icontains: '' | a1 a2 a3 a5, instr(lower(\"acct\".\"name\"), lower(?)) > 0 with '' | a1 a2 a3 a5 | a1 a2 a3 a5 | 400 INVALID_FILTER from driver-sql, message relayed with field, path and comparand |\n| $icontains: 'acme' (control) | a1 a2 | a1 a2 | a1 a2 | a1 a2 |\n| $icontains: 42 / true / null | no row (bound as text) | no row | no row | 400 relayed |\n| $icontains: {a:1} | 500 READ_SCOPE_COMPILE_FAILED, withheld (every face) | = | = | = |\n| $not over $icontains: '' | a4 only | a4 only | a4 only | 400 relayed |\n| $contains: '' (no-widening control) | a1 a2 a3 a5 | = | = | = |\n\nThe HTTP door (read and measure only; packages/rest and packages/runtime are not edited):\n\n- POST /api/v1/analytics/query and /analytics/sql type where as FilterConditionSchema. $icontains: '' and 42 get 400 VALIDATION_FAILED carrying the spec's reason at where.name.$icontains; the service is never called. 'acme' gets 200. A FilterArray where is refused whole, because the door is object-only.\n- POST /analytics/dataset/query: selection.runtimeFilter or an inline dataset.filter carrying '' or 42 gets 400 VALIDATION_FAILED at the door parse. A host getReadScope carrying $icontains: '' got 200, served, with the statement's WHERE carrying the '' pattern. The read-scope cell reached HTTP callers; the caller-authored cell did not.\n\nRead-scope reach. compileCelToFilter never emits $icontains. name.icontains('x') and name.lowerAscii().contains('x') are refused as unsupported, and STRING_METHOD maps only contains / startsWith / endsWith. A placeholder cannot resolve to '': resolveFilterTokens throws FILTER_TOKEN_UNRESOLVED for an empty userId / tenantId, and the date macros resolve to dates. Verdict: today, only a host-supplied getReadScope, or a direct caller of the exported compileScopedFilterToSql, carries $icontains: '' into a read scope.",
    "pr_body_new": "Fixes #20068\n\nClause-②: no (narrowing)\n\n## What this changes\n\nFILTER_TEXT_CASES (@objectstack/spec/data) declares two REJECTION rows for $icontains: an EMPTY comparand and a NON-STRING one. Each has code: 'INVALID_FILTER' and mustMention: ['$icontains']. The spec publishes the discrimination as isRefusedTextComparand and the reason half as textComparandRefusalReason (#18113). FilterConditionSchema and driver-sql (#5702) refuse both rows. service-analytics never asked, so one filter got two answers. Both analytics doors now call the published predicate and seat the published reason in their own envelope. Neither restates the rule.\n\n- The where door (strategies/filter-normalizer.ts, assertCompilableComparand). The door is inside the #7693 text fence, after the renderability check. It refuses as INVALID_FILTER / 400, with the message [analytics] The + the published reason. The message names $icontains, the spelling that arrived; a FilterArray icontains has been lowered to $icontains before any comparand gate runs. This covers every consumer of the tree at once: the native SQL execute path, the /analytics/sql echo, the ObjectQL engine path, AnalyticsService.query, and dataset and measure filters through queryDataset.\n- The read scope, lowering (read-scope-sql.ts, compileOperator's case '$icontains', via the new assertIcontainsComparandNotRefused). The check comes after assertRenderableText and before textOverNonTextColumn and any bind. It refuses as READ_SCOPE_COMPILE_FAILED / 500, with the message withheld (#5367, re-affirmed as #7598 Q2 = A). This covers compileScopedFilterToSql, NativeSQLStrategy.applyReadScope and the echo.\n- The read scope, at the engine-bound merges (read-scope-sql.ts, assertReadScopeComparandsRunnable, via the new walk findRefusedIcontainsComparand). It runs after the two shared faces, in the same envelope. ObjectQLStrategy.withReadScope and resolveFkAttr already call this function, so objectql-strategy.ts is untouched. This bullet goes past the claim's file note (case '$icontains'); why it is here is under Declared scope extension below.\n\nOnly the two declared rows. $contains, $notContains, $startsWith and $endsWith keep their answer to ''. The module says widening by analogy is the table's decision.\n\n### The order chosen, and why\n\n- Where door. Existing refusals keep their order. The shape and type faces in lowerAnalyticsWhere still answer first: an object is refused by the type face, in its sentence. The #5234 renderability check still answers an array or a { $field } next. The new question comes third, still inside fieldLeaves, so it runs before any emitter. The 2026-09-05 non-text-column constant is untouched and answers only a comparand the contract accepts. driver-sql takes the same stance: its constant is reached only after its validating walk. The predicate's undefined carve-out is owned upstream: the type face and assertDefinedComparands refuse an undefined before this line can see one.\n- Read scope, lowering. The same order inside the arm: assertRenderableText first, so an object or array keeps its #5234 sentence. Then the table's rows. Then the non-text constant. undefined is refused earlier, by assertDefinedComparands in compileField.\n- Read scope, merges. After assertListComparandShapes and normalizeFilterComparandTypes. A doubly-refused scope therefore carries the faces' sentence, as it would on the engine, and the type face has already refused undefined, the carve-out.\n\n### Declared scope extension (in-place, one file)\n\nThe claim names the read scope's case '$icontains'. Measured before the fix, the same cell on the ObjectQL execute face did not serve rows. It reached engine.aggregate, and driver-sql's #5702 refusal came back as INVALID_FILTER / 400, with the policy's field name, path and comparand in the relayed message. That is the envelope the dispatch rules out for a read scope: never a 4xx carrying policy content. A gate in compileOperator alone would have left one scope with two envelopes. All four in-place conditions hold:\n\n1. It is the same defect class and the same cell.\n2. It is mechanical: the same published predicate, re-raised through readScopeCompileError, in the function #19995 and #20018 built for exactly this.\n3. read-scope-sql.ts is already in this claim, and #20075 is queued behind it.\n4. It adds no verification surface.\n\nIt needs no engine-lane export, which is what separates it from the four classes in #19995's decision. #19995 remains open. The seat is asked to amend the claim's file note to include assertReadScopeComparandsRunnable.\n\n## Measured first (recorded on this branch as fe47363cd9, before any source change)\n\nBase origin/main 7ddf396109. A real sql.js engine (driver-sqlite-wasm) over rows a1 'Acme Corp', a2 'ACME ltd', a3 'beta', a4 NULL and a5 '' (field name), with a real ObjectQL engine behind the ObjectQL face. The echo's SQL was run on the same database. The HTTP legs were read and measured only: packages/runtime's dispatcher route and packages/rest's dataset route, each over a real AnalyticsService.\n\nThe where door (caller-authored):\n\n| cell | native execute | echo (run) | ObjectQL engine path | draft preview | AnalyticsService.query (native) |\n|:--|:--|:--|:--|:--|:--|\n| $icontains: '' | a1 a2 a3 a5 (every non-NULL row) | a1 a2 a3 a5 | bare Error, no code (see finding 1) | 400 INVALID_FILTER, names $icontains ("not evaluated") | a1 a2 a3 a5 |\n| $icontains: 'acme' (control) | a1 a2 | a1 a2 | bare Error, no code | 400, not evaluated | a1 a2 |\n| $icontains: 42 / true / null | no row (bound as text) | no row | bare Error | 400, not evaluated | no row |\n| $icontains: {a:1} | 400, type face (every face) | = | = | = | = |\n| $not over $icontains: '' | a4 only | a4 only | bare Error | 400, not evaluated | a4 only |\n| FilterArray ['name','icontains',''] | a1 a2 a3 a5 | a1 a2 a3 a5 | bare Error | no row (the preview does not lower a FilterArray) | a1 a2 a3 a5 |\n| $contains: '' (no-widening control) | a1 a2 a3 a5 | a1 a2 a3 a5 | a1 a2 a3 a5 | a1 through a5 | a1 a2 a3 a5 |\n\nThe read scope (policy, host getReadScope):\n\n| cell | compileScopedFilterToSql (run) | native applyReadScope | echo (run) | ObjectQL execute (withReadScope) |\n|:--|:--|:--|:--|:--|\n| $icontains: '' | a1 a2 a3 a5, instr(lower(\"acct\".\"name\"), lower(?)) > 0 with '' | a1 a2 a3 a5 | a1 a2 a3 a5 | 400 INVALID_FILTER from driver-sql, message relayed with field, path and comparand |\n| $icontains: 'acme' (control) | a1 a2 | a1 a2 | a1 a2 | a1 a2 |\n| $icontains: 42 / true / null | no row (bound as text) | no row | no row | 400 relayed |\n| $icontains: {a:1} | 500 READ_SCOPE_COMPILE_FAILED, withheld (every face) | = | = | = |\n| $not over $icontains: '' | a4 only | a4 only | a4 only | 400 relayed |\n| $contains: '' (no-widening control) | a1 a2 a3 a5 | = | = | = |\n\nThe HTTP door (read and measure only; packages/rest and packages/runtime are not edited):\n\n- POST /api/v1/analytics/query and /analytics/sql type where as FilterConditionSchema. $icontains: '' and 42 get 400 VALIDATION_FAILED carrying the spec's reason at where.name.$icontains; the service is never called. 'acme' gets 200. A FilterArray where is refused whole, because the door is object-only.\n- POST /analytics/dataset/query: selection.runtimeFilter or an inline dataset.filter carrying '' or 42 gets 400 VALIDATION_FAILED at the door parse. A host getReadScope carrying $icontains: '' got 200, served, with the statement's WHERE carrying the '' pattern. The read-scope cell reached HTTP callers; the caller-authored cell did not.\n\nRead-scope reach. compileCelToFilter never emits $icontains. name.icontains('x') and name.lowerAscii().contains('x') are refused as unsupported, and STRING_METHOD maps only contains / startsWith / endsWith. A placeholder cannot resolve to '': resolveFilterTokens throws FILTER_TOKEN_UNRESOLVED for an empty userId / tenantId, and the date macros resolve to dates. Verdict: today, only a host-supplied getReadScope, or a direct caller of the exported compileScopedFilterToSql, carries $icontains: '' into a read scope.\n\n## After (head ec20b23f97)\n\n- Every where face refuses '', 42, true, null and a Date under $icontains as INVALID_FILTER / 400, in both spellings and at any depth. Nothing reaches the database or the engine: 0 statements, 0 engine.aggregate calls. queryDataset refuses the same comparand in a stored dataset filter or a measure filter.\n- Every read-scope face (native, echo, ObjectQL execute, AnalyticsService.query either way) refuses as READ_SCOPE_COMPILE_FAILED / 500. The provenance is declared, declaredRefusalMessage is undefined, and nothing reaches the database or the engine.\n- HTTP re-measure: the dataset route with a host getReadScope carrying $icontains: '' now answers 500 {\"error\":\"Internal server error\",\"code\":\"READ_SCOPE_COMPILE_FAILED\"}, with 0 statements. Every caller-authored cell is unchanged: 400 at the door parse.\n- Controls: 'acme' still serves a1 a2 on every face that could before. $contains: '' still serves a1 a2 a3 a5 on every face, in both the where and the read scope.\n- The draft preview is not edited. It already refuses every $icontains in the same envelope, INVALID_FILTER / 400 naming $icontains (#19810), so the verdict for this cell agrees; only the sentence differs.\n\n## Tests\n\nNew file packages/services/service-analytics/src/__tests__/icontains-text-comparand-refusal.test.ts, 44 cases:\n\n- the where door's rows, both spellings, every position;\n- sentence precedence for an array and an object;\n- every face over a real engine, with statement and engine-call counters;\n- the non-text-column order;\n- every read-scope face, withheld;\n- the export's lowering-time placement;\n- stored dataset and measure filters;\n- the no-widening block.\n\nEvery refusal asserts code + status, and the where ones also assert that the message contains textComparandRefusalReason(field, '$icontains', comparand).\n\nPins re-judged, none deleted. The census was over service-analytics, packages/rest and packages/runtime, taken before the change; rest and runtime had 0 hits.\n\n- cross-field-reference-refusal.test.ts, "the fence is NARROW": it asserted $icontains: 5 and $icontains: null compile as legitimate comparands. The table declares both refused. Flipped to refusals, with a note; the 'admin' control is kept.\n- text-match-sqlite-nul.test.ts: its header said these compilers answer $icontains: '' with every non-NULL row. The prose is re-judged. The grid's exclusion of that cell stays, since there are no rows to hold.\n\nHead ec20b23f97:\n\n- pnpm --filter @objectstack/service-analytics suite: 124 files, 2886 tests passed.\n- typecheck: exit 0. --listFiles includes all 3 touched test files.\n- packages/rest's five analytics route suites, run against the rebuilt dist: 68 passed. That is a consumer sanity run, not a pin.\n\n### Ablations (each through scripts/ablation-replace.mjs, WRAP mode, anchor hit ×1)\n\nservice-analytics tests import the subject by relative src paths, so no dist is in the path.\n\n| leg | mutation | red / green | restore |\n|:--|:--|:--|:--|\n| A | where gate disabled (if (false && …)) | 26 red / 136 green, over the new file plus cross-field-reference-refusal.test.ts. The re-judged pin is red too; the preview case stays green, as predicted | blob 164f4ad4fa40 == HEAD, git diff HEAD empty |\n| B | read-scope arm gate removed | 1 red / 43 green: the lowering-time placement pin (a doubly-refused scope is answered for the text comparand). The merge walk still refuses every other scope in the same envelope, so this is the arm's only observable, as predicted | blob 403440964f36 == HEAD |\n| C | merge walk disabled | 6 red / 38 green: the six read-scope refusal cases, where the ObjectQL faces fell back to the driver's relayed 400 | blob == HEAD |\n| D | where gate widened to all four case-exact operators | 2 red / 42 green: both no-widening pins | blob == HEAD |\n| E | read-scope $contains arm given the gate | 2 red / 42 green: both no-widening pins | blob == HEAD |\n\n## Gates (head ec20b23f97)\n\nnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 61 families from the real diff. 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.\n\n- 60 families: exit 0. Among them are check-adr-0087-registration --base origin/main (disposition not-required (already-registered filter-icontains-comparand-refused-at-parse)), check-changeset-no-major, check-empty-changeset, check:nul-bytes and check:issue-citations (22 citations, all resolve).\n- check:type-check-debt: exit 0, after building @objectstack/runtime (the first run was PREREQUISITE NOT MET).\n- check:dual-build-cjs-loads: NOT MEASURED (exit 3, PREREQUISITE NOT MET). It reads every package's dist, and 38 are unbuilt in this worktree. The diff changes no build config, exports or package.json. As a narrow proxy, service-analytics/dist/index.cjs loads under require (17 exports). CI builds the whole closure.\n- --ran reconciliation: 61 derived, 60 run, 1 NOT-MEASURED (claimed, with its reason), 0 UNRUN.\n- Lint, narrowed and proven: eslint --no-inline-config --format json over the 5 changed .ts files gave 5 files, 0 errors, 0 warnings. isPathIgnored answers false for them. eslint.config.mjs never enables type-aware linting, so this diff cannot move the verdict of any untouched file.\n\n## Acceptance notes\n\n- $contains: '' in a read scope, from CEL (not filed). Measured: compileCelToFilter('name.contains(current_user.department)') with an empty department lowers to { name: { $contains: '' } }, which admits every non-NULL row on every face. $contains has no REJECTION row, and this card must not widen by analogy. Whether the table should add one, or the CEL lowering should refuse an empty string-method argument, is the table owner's call. It is recorded here, not filed; carrier: none.\n- The draft preview does not lower a FilterArray where (not filed). evaluateAnalyticsQueryOverRows hands the array to matchesWhere as an object keyed 0 / 1 / 2, so every FilterArray answers no row. The published door lowers it. Reachability through queryDataset (dataset and runtime filters are object-only at every schema door) was not proven, so this stays a note; carrier: none.\n- Precedence change on the read scope. A scope carrying both $icontains: '' and a shape only the shared faces refuse (such as a null $in member) is now answered for the text comparand, at lowering time. The envelope is the same and only the log sentence differs. That is the placement the ruling asked for.\n\n## Out-of-scope findings (reported to the seat, not filed here)\n\n1. class (a). ObjectQLStrategy.convertFilter has no icontains arm. Any $icontains, including a valid 'acme', on a query the ObjectQL strategy serves throws a bare Error with no code or status: "ObjectQL strategy cannot express filter operator "icontains"". Measured in-process through ObjectQLStrategy.execute and AnalyticsService.query with objectqlAggregate, which returned no rows. The HTTP door admits the filter, so a caller on a datasource served by the ObjectQL strategy gets a 5xx for a valid query; that HTTP leg is read from the code, not measured. After this PR, '' and non-strings are refused before convertFilter; a valid comparand still fails there. Dedupe words: ObjectQLStrategy convertFilter icontains · cannot express filter operator icontains · analytics objectql icontains bare Error.\n"
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Review — ACCEPT (landing gated on CI) · PR #20096 · head ec20b23f97 · domain:services seat · 2026-09-25T04:02Z

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

    Checklist

    • PR shape. Draft, targeting main. The first line is Fixes #20068, and Clause-②: no (narrowing) starts its own line.

    • Scope. 6 files, +689 / −10:

      • filter-normalizer.ts and read-scope-sql.ts;
      • one new 44-case test file;
      • two re-judged test files;
      • the changeset.

      No packages/spec, drivers, rest or runtime source; preview-evaluator.ts is untouched (it already refuses the cell). NOT governed. It merges clean with origin/main.

    • Diff, read line by line.

    • Measurement. Executed at base on sql.js with a real ObjectQL.

      • $icontains: '' served every non-NULL row on the native face, the echo and the export.
      • On the ObjectQL face the same read-scope cell came back as driver-sql's relayed 400 naming the policy field and comparand.
      • Over HTTP, a host getReadScope carrying it was served 200. The caller-authored cell was already refused at the door parse.
      • After the fix, each is refused in its own envelope, and HTTP returns the withheld 500 with 0 statements.
    • Tests. Package 2886 green, typecheck 0. The packages/rest analytics suites (68) pass against the rebuilt dist. Ablations A–E each go red, and each restore is blob-identical. D and E are the two no-widening pins.

    Deviations accepted.

    • The claim's file note is amended to include read-scope-sql.ts assertReadScopeComparandsRunnable (this line is the revision). It is the claimed file, the same cell, and the same predicate, and it closes the ObjectQL face's relayed-policy 400 for this cell.
    • The non-string scalars (42, true, null, a Date) are refused as the table's non-string row. The dispatch had wrongly called them already refused; the report corrects that.
    • The ADR-0087 marker is already-registered (filter-icontains-comparand-refused-at-parse).
    • check:dual-build-cjs-loads NOT MEASURED locally, with a stated proxy; CI builds it.

    Seat observation (Acceptance notes, not blocking). The engine-bound walk descends $and / $or / $not and reads field.$icontains. A nested-relation scope, such as a relation key wrapping { name: { $icontains: '' } }, is not walked, so on the ObjectQL face it would still reach the engine's relayed 400. That is the same class as #19995's engine-bound residue, which the decision there (option C) covers in one judgement. Recorded, not a patch round.

    Out-of-scope findings, one line each:

    Landing. No Clause-② limb is hit: no packages/spec/src. It is a narrowing to the spec's already-declared rows, with the honest ADR-0087 marker. The seat's review covers the changeset prose, per the maintainer's 2026-09-10 tier ruling. The PR goes ready and auto-merge when every check is green. #20075 (also read-scope-sql.ts) is dispatched after this lands.


    Generated by Claude Code

  6. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat · session_01Evb5jFDZGKQE9KG4jbMfMF · 2026-09-25T04:31Z


    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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions