Repository navigation
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
Description
Filing gate: ① a defect with a named landing site. The spec declares a refusal that this package's compilers do not enforce.
- Filed by: the
domain:servicesexecution seat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post [PM seat] domain:services — ⏳ vacant #6021). - Surfaced by: the service-analytics (SQLite): the shared text-match arm emits
GLOB, so a$contains/$endsWith/$startsWithcomparand holding U+0000 is cut at the NUL; on the read scope a leading U+0000 widens$contains/$endsWithto every row #20025 dev on PR fix(service-analytics): on SQLite, the text operators compare the whole stored value and comparand, not only up to their first U+0000 (#20025) #20067, as an out-of-scope finding (class b). ⛔ 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 in the analytics text-comparand gates: thewherelowering instrategies/filter-normalizer.tsand the read-scope compilerread-scope-sql.ts. Both would adopt the spec's published predicate.
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
$icontainscomparand compiles to a predicate that is true for every non-NULL value. It waslower(col) GLOB lower('**')before that PR, and it isinstr(lower(col), lower('')) > 0after. - It is not refused. That holds on
compileScopedFilterToSql,NativeSQLStrategy'swhereand theObjectQLStrategyecho, and in-process throughAnalyticsService.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
- Class: ①, a defect with a named landing site (above); finding class (b), a declared contract not held on a sibling face.
- Acting reader: the
domain:servicesseat after triage. - Dedupe: MCP semantic issue search in this repo,
closedincluded. Queryservice-analytics $icontains empty comparand refused INVALID_FILTER analytics where read scope→ 26 hits (top 10 read). They are 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, 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, 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, service-analytics (SQLite): the shared text-match arm emitsGLOB, so a$contains/$endsWith/$startsWithcomparand holding U+0000 is cut at the NUL; on the read scope a leading U+0000 widens$contains/$endsWithto every row #20025, service-analytics: the caller-where lowering reads$null/$existsflags by identity, so a non-boolean flag ({ $null: "x" }) is answered 200 as IS NOT NULL where every other face refuses it (#5347 / #5369) #20040, service-analytics: the NativeSQL read-scope compiler and the/analytics/sqlecho compile two scope shapes the shared comparand faces refuse (plain-object comparand under$eq, null member in$in): one scope, two answers across faces #20018, [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, service-analytics: the three SQL compilers emittranslate()for$icontains, a function SQLite does not have — the statement fails to parse instead of answering wrong rows #15780, service-analytics: all three SQL compilers emit a plain LIKE for the case-sensitive $contains family, which folds ASCII case on SQLite — the read scope and the native where admit rows the #4706 contract excludes #15684 and runtime:POST /analytics/queryrefuses the arraywherethe objectui adapter now sends for every array-form filter —AnalyticsQueryRequestSchema.whereisFilterConditionSchema, whilelowerAnalyticsWhere(the gate ui#6302 measured) accepts filter AST #15828. None covers an empty$icontainscomparand on the analytics compilers.- [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 (closed) is the draft-preview matcher missing the operator.
- service-analytics: the three SQL compilers emit
translate()for$icontains, a function SQLite does not have — the statement fails to parse instead of answering wrong rows #15780 / service-analytics: all three SQL compilers emit a plain LIKE for the case-sensitive $contains family, which folds ASCII case on SQLite — the read scope and the native where admit rows the #4706 contract excludes #15684 are the fold andtranslate()constructs. - Control: the query reaches the analytics comparand-door cards.
Dedupe words: analytics icontains empty comparand · isRefusedTextComparand service-analytics · FILTER_TEXT_CASES rejection analytics
Generated by Claude Code
Activity
objectstack-fleet commentedon Sep 25, 2026
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:blockedBlocked-by #20035; rationale: the spec publishesisRefusedTextComparandandFilterConditionSchemaanddriver-sqlrefuse an empty$icontains, but this package never calls it, so an empty comparand compiles to a predicate true for every non-NULL row oncompileScopedFilterToSql, the nativewhereand 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 withsecurity, the class of #20025; both files are being edited in flight (the #20035 branch rewritesfilter-normalizer.ts, PR #20072 editsread-scope-sql.ts), so it waits.分诊席 #6015,2026-09-25T02:23Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectstack
origin/main7f1de2eb66上核对。本席核对
service-analytics/src里isRefusedTextComparand出现 0 次,与卡面一致。GLOB, so a$contains/$endsWith/$startsWithcomparand holding U+0000 is cut at the NUL; on the read scope a leading U+0000 widens$contains/$endsWithto every row #20025)已合并为bf37b99a67,改的是text-match-sql.ts。卡面说它没有改变这一格。whereskips 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 的分支claude/issue-20035-analytics-where-type-face改了filter-normalizer.ts318 行;INVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995)改了read-scope-sql.ts87 行。FilterConditionSchema解析,可能先把它拒掉;进程内的AnalyticsService.query和导出的compileScopedFilterToSql是实测过的。定级说明
p2 加
security:读范围如果用了一个解析成空串的占位符(例如{user.department}为空),这条范围就会放行所有行。与 #20025 同级。解锁后的执行要点
whereskips 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 withINVALID_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 是否已经落地。仍在途的话,按领卡时的文件检查排在它后面。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 的来源接缝走。$icontains在原生where、回显、读范围编译器和进程内AnalyticsService.query上都被拒绝;非空比较值照常。Generated by Claude Code
objectstack-fleet commentedon Sep 25, 2026
Unlock re-derivation:
Blocked-by: #20035is satisfied ·domain:servicesseat · 2026-09-25T02:48ZSeat
session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021. Read againstorigin/main7ddf396109.whereskips 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 closedcompleted: PR fix(service-analytics)!: the analytics where door runs the shared comparand-TYPE face on the object spelling (#20035) #20058 →7ddf396109, thefilter-normalizer.tsrewrite. Triage's second reason, 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 editingread-scope-sql.ts, is gone too: it merged as60fdaa9e27. No open PR touches either file.isRefusedTextComparandhas 0 call sites inpackages/services/service-analytics/srcon7ddf396109. The card's defect stands.pm:blocked→pm:queue;domain:services,priority:p2andsecurityare unchanged. The seat claims it below in the same pass.read-scope-sql.tsand is released in the same pass. It is dispatched after this card's PR lands.Generated by Claude Code
objectstack-fleet commentedon Sep 25, 2026
Claim: PM loop round 1
Session:
session_01Evb5jFDZGKQE9KG4jbMfMFBranch:
claude/issue-20068-analytics-icontains-emptyWorktree:
objectstack-issue-20068Domain:
domain:servicesSeat:
domain:services#1File surface:
packages/services/service-analytics/src/strategies/filter-normalizer.ts(thewheredoor's text-comparand gate);src/read-scope-sql.ts(the read-scope text arm's gate,case '$icontains');src/preview-evaluator.tsonly if the draft preview answers the cell differently; pins that assert today's$icontains: ''admission, re-judged with notes; new test file(s) underpackages/services/service-analytics/src/;.changeset/20068-*.md. ⛔ Nopackages/spec(the predicate is already published), nopackages/drivers/*, nopackages/rest. ⛔ Only the two declared REJECTION rows (an empty and a non-string$icontainscomparand); ⛔ 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 as7ddf396109, and PR #20072 (#19995,read-scope-sql.ts) as60fdaa9e27. No open PR touchesservice-analytics. #20075 (alsoread-scope-sql.ts) is queued behind this card, and #19995 (decision box) has nothing in flight.objectstack-fleet commentedon Sep 25, 2026
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\nBaseorigin/main7ddf396109. A real sql.js engine (driver-sqlite-wasm) over rows a1 'Acme Corp', a2 'ACME ltd', a3 'beta', a4 NULL and a5 '' (fieldname), with a realObjectQLengine 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 andpackages/rest's dataset route, each over a realAnalyticsService.\n\nThewheredoor (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 | bareError, no code (see finding 1) | 400INVALID_FILTER, names$icontains("not evaluated") | a1 a2 a3 a5 |\n|$icontains: 'acme'(control) | a1 a2 | a1 a2 | bareError, no code | 400, not evaluated | a1 a2 |\n|$icontains: 42/true/null| no row (bound as text) | no row | bareError| 400, not evaluated | no row |\n|$icontains: {a:1}| 400, type face (every face) | = | = | = | = |\n|$notover$icontains: ''| a4 only | a4 only | bareError| 400, not evaluated | a4 only |\n|FilterArray['name','icontains','']| a1 a2 a3 a5 | a1 a2 a3 a5 | bareError| no row (the preview does not lower aFilterArray) | 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, hostgetReadScope):\n\n| cell |compileScopedFilterToSql(run) | nativeapplyReadScope| echo (run) | ObjectQL execute (withReadScope) |\n|:--|:--|:--|:--|:--|\n|$icontains: ''| a1 a2 a3 a5,instr(lower(\"acct\".\"name\"), lower(?)) > 0with''| a1 a2 a3 a5 | a1 a2 a3 a5 | 400INVALID_FILTERfromdriver-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}| 500READ_SCOPE_COMPILE_FAILED, withheld (every face) | = | = | = |\n|$notover$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/restandpackages/runtimeare not edited):\n\n-POST /api/v1/analytics/queryand/analytics/sqltypewhereasFilterConditionSchema.$icontains: ''and42get 400VALIDATION_FAILEDcarrying the spec's reason atwhere.name.$icontains; the service is never called.'acme'gets 200. AFilterArraywhereis refused whole, because the door is object-only.\n-POST /analytics/dataset/query:selection.runtimeFilteror an inlinedataset.filtercarrying''or42gets 400VALIDATION_FAILEDat the door parse. A hostgetReadScopecarrying$icontains: ''got 200, served, with the statement'sWHEREcarrying the''pattern. The read-scope cell reached HTTP callers; the caller-authored cell did not.\n\nRead-scope reach.compileCelToFilternever emits$icontains.name.icontains('x')andname.lowerAscii().contains('x')are refused asunsupported, andSTRING_METHODmaps onlycontains/startsWith/endsWith. A placeholder cannot resolve to'':resolveFilterTokensthrowsFILTER_TOKEN_UNRESOLVEDfor an emptyuserId/tenantId, and the date macros resolve to dates. Verdict: today, only a host-suppliedgetReadScope, or a direct caller of the exportedcompileScopedFilterToSql, carries$icontains: ''into a read scope.","pr_body_new": "Fixes #20068\n\nClause-②: no (narrowing)\n\n## What this changes\n\n
FILTER_TEXT_CASES(@objectstack/spec/data) declares two REJECTION rows for$icontains: an EMPTY comparand and a NON-STRING one. Each hascode: 'INVALID_FILTER'andmustMention: ['$icontains']. The spec publishes the discrimination asisRefusedTextComparandand the reason half astextComparandRefusalReason(#18113).FilterConditionSchemaanddriver-sql(#5702) refuse both rows.service-analyticsnever 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- Thewheredoor (strategies/filter-normalizer.ts,assertCompilableComparand). The door is inside the #7693 text fence, after the renderability check. It refuses asINVALID_FILTER/ 400, with the message[analytics] The+ the published reason. The message names$icontains, the spelling that arrived; aFilterArrayicontainshas been lowered to$icontainsbefore any comparand gate runs. This covers every consumer of the tree at once: the native SQL execute path, the/analytics/sqlecho, the ObjectQL engine path,AnalyticsService.query, and dataset and measure filters throughqueryDataset.\n- The read scope, lowering (read-scope-sql.ts,compileOperator'scase '$icontains', via the newassertIcontainsComparandNotRefused). The check comes afterassertRenderableTextand beforetextOverNonTextColumnand any bind. It refuses asREAD_SCOPE_COMPILE_FAILED/ 500, with the message withheld (#5367, re-affirmed as #7598 Q2 = A). This coverscompileScopedFilterToSql,NativeSQLStrategy.applyReadScopeand the echo.\n- The read scope, at the engine-bound merges (read-scope-sql.ts,assertReadScopeComparandsRunnable, via the new walkfindRefusedIcontainsComparand). It runs after the two shared faces, in the same envelope.ObjectQLStrategy.withReadScopeandresolveFkAttralready call this function, soobjectql-strategy.tsis 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,$startsWithand$endsWithkeep 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 inlowerAnalyticsWherestill 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 insidefieldLeaves, 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-sqltakes the same stance: its constant is reached only after its validating walk. The predicate'sundefinedcarve-out is owned upstream: the type face andassertDefinedComparandsrefuse anundefinedbefore this line can see one.\n- Read scope, lowering. The same order inside the arm:assertRenderableTextfirst, so an object or array keeps its #5234 sentence. Then the table's rows. Then the non-text constant.undefinedis refused earlier, byassertDefinedComparandsincompileField.\n- Read scope, merges. AfterassertListComparandShapesandnormalizeFilterComparandTypes. A doubly-refused scope therefore carries the faces' sentence, as it would on the engine, and the type face has already refusedundefined, the carve-out.\n\n### Declared scope extension (in-place, one file)\n\nThe claim names the read scope'scase '$icontains'. Measured before the fix, the same cell on the ObjectQL execute face did not serve rows. It reachedengine.aggregate, anddriver-sql's #5702 refusal came back asINVALID_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 incompileOperatoralone 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 throughreadScopeCompileError, in the function #19995 and #20018 built for exactly this.\n3.read-scope-sql.tsis 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 includeassertReadScopeComparandsRunnable.\n\n## Measured first (recorded on this branch asfe47363cd9, before any source change)\n\nBaseorigin/main7ddf396109. A real sql.js engine (driver-sqlite-wasm) over rows a1 'Acme Corp', a2 'ACME ltd', a3 'beta', a4 NULL and a5 '' (fieldname), with a realObjectQLengine 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 andpackages/rest's dataset route, each over a realAnalyticsService.\n\nThewheredoor (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 | bareError, no code (see finding 1) | 400INVALID_FILTER, names$icontains("not evaluated") | a1 a2 a3 a5 |\n|$icontains: 'acme'(control) | a1 a2 | a1 a2 | bareError, no code | 400, not evaluated | a1 a2 |\n|$icontains: 42/true/null| no row (bound as text) | no row | bareError| 400, not evaluated | no row |\n|$icontains: {a:1}| 400, type face (every face) | = | = | = | = |\n|$notover$icontains: ''| a4 only | a4 only | bareError| 400, not evaluated | a4 only |\n|FilterArray['name','icontains','']| a1 a2 a3 a5 | a1 a2 a3 a5 | bareError| no row (the preview does not lower aFilterArray) | 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, hostgetReadScope):\n\n| cell |compileScopedFilterToSql(run) | nativeapplyReadScope| echo (run) | ObjectQL execute (withReadScope) |\n|:--|:--|:--|:--|:--|\n|$icontains: ''| a1 a2 a3 a5,instr(lower(\"acct\".\"name\"), lower(?)) > 0with''| a1 a2 a3 a5 | a1 a2 a3 a5 | 400INVALID_FILTERfromdriver-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}| 500READ_SCOPE_COMPILE_FAILED, withheld (every face) | = | = | = |\n|$notover$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/restandpackages/runtimeare not edited):\n\n-POST /api/v1/analytics/queryand/analytics/sqltypewhereasFilterConditionSchema.$icontains: ''and42get 400VALIDATION_FAILEDcarrying the spec's reason atwhere.name.$icontains; the service is never called.'acme'gets 200. AFilterArraywhereis refused whole, because the door is object-only.\n-POST /analytics/dataset/query:selection.runtimeFilteror an inlinedataset.filtercarrying''or42gets 400VALIDATION_FAILEDat the door parse. A hostgetReadScopecarrying$icontains: ''got 200, served, with the statement'sWHEREcarrying the''pattern. The read-scope cell reached HTTP callers; the caller-authored cell did not.\n\nRead-scope reach.compileCelToFilternever emits$icontains.name.icontains('x')andname.lowerAscii().contains('x')are refused asunsupported, andSTRING_METHODmaps onlycontains/startsWith/endsWith. A placeholder cannot resolve to'':resolveFilterTokensthrowsFILTER_TOKEN_UNRESOLVEDfor an emptyuserId/tenantId, and the date macros resolve to dates. Verdict: today, only a host-suppliedgetReadScope, or a direct caller of the exportedcompileScopedFilterToSql, carries$icontains: ''into a read scope.\n\n## After (headec20b23f97)\n\n- Everywhereface refuses'',42,true,nulland aDateunder$icontainsasINVALID_FILTER/ 400, in both spellings and at any depth. Nothing reaches the database or the engine: 0 statements, 0engine.aggregatecalls.queryDatasetrefuses the same comparand in a stored dataset filter or a measure filter.\n- Every read-scope face (native, echo, ObjectQL execute,AnalyticsService.queryeither way) refuses asREAD_SCOPE_COMPILE_FAILED/ 500. The provenance isdeclared,declaredRefusalMessageisundefined, and nothing reaches the database or the engine.\n- HTTP re-measure: the dataset route with a hostgetReadScopecarrying$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 thewhereand the read scope.\n- The draft preview is not edited. It already refuses every$icontainsin 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 filepackages/services/service-analytics/src/__tests__/icontains-text-comparand-refusal.test.ts, 44 cases:\n\n- thewheredoor'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 assertscode+status, and thewhereones also assert that the message containstextComparandRefusalReason(field, '$icontains', comparand).\n\nPins re-judged, none deleted. The census was overservice-analytics,packages/restandpackages/runtime, taken before the change;restandruntimehad 0 hits.\n\n-cross-field-reference-refusal.test.ts, "the fence is NARROW": it asserted$icontains: 5and$icontains: nullcompile 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\nHeadec20b23f97:\n\n-pnpm --filter @objectstack/service-analyticssuite: 124 files, 2886 tests passed.\n-typecheck: exit 0.--listFilesincludes all 3 touched test files.\n-packages/rest's five analytics route suites, run against the rebuiltdist: 68 passed. That is a consumer sanity run, not a pin.\n\n### Ablations (each throughscripts/ablation-replace.mjs, WRAP mode, anchor hit ×1)\n\nservice-analyticstests import the subject by relativesrcpaths, so nodistis in the path.\n\n| leg | mutation | red / green | restore |\n|:--|:--|:--|:--|\n| A |wheregate disabled (if (false && …)) | 26 red / 136 green, over the new file pluscross-field-reference-refusal.test.ts. The re-judged pin is red too; the preview case stays green, as predicted | blob164f4ad4fa40== HEAD,git diff HEADempty |\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 | blob403440964f36== 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 |wheregate widened to all four case-exact operators | 2 red / 42 green: both no-widening pins | blob == HEAD |\n| E | read-scope$containsarm given the gate | 2 red / 42 green: both no-widening pins | blob == HEAD |\n\n## Gates (headec20b23f97)\n\nnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 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 arecheck-adr-0087-registration --base origin/main(dispositionnot-required (already-registered filter-icontains-comparand-refused-at-parse)),check-changeset-no-major,check-empty-changeset,check:nul-bytesandcheck:issue-citations(22 citations, all resolve).\n-check:type-check-debt: exit 0, after building@objectstack/runtime(the first run wasPREREQUISITE NOT MET).\n-check:dual-build-cjs-loads: NOT MEASURED (exit 3,PREREQUISITE NOT MET). It reads every package'sdist, and 38 are unbuilt in this worktree. The diff changes no build config,exportsorpackage.json. As a narrow proxy,service-analytics/dist/index.cjsloads underrequire(17 exports). CI builds the whole closure.\n---ranreconciliation: 61 derived, 60 run, 1 NOT-MEASURED (claimed, with its reason), 0 UNRUN.\n- Lint, narrowed and proven:eslint --no-inline-config --format jsonover the 5 changed.tsfiles gave 5 files, 0 errors, 0 warnings.isPathIgnoredanswers false for them.eslint.config.mjsnever 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 emptydepartmentlowers to{ name: { $contains: '' } }, which admits every non-NULL row on every face.$containshas 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 aFilterArraywhere(not filed).evaluateAnalyticsQueryOverRowshands the array tomatchesWhereas an object keyed0/1/2, so everyFilterArrayanswers no row. The published door lowers it. Reachability throughqueryDataset(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 anull$inmember) 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.convertFilterhas noicontainsarm. Any$icontains, including a valid'acme', on a query the ObjectQL strategy serves throws a bareErrorwith nocodeorstatus: "ObjectQL strategy cannot express filter operator "icontains"". Measured in-process throughObjectQLStrategy.executeandAnalyticsService.querywithobjectqlAggregate, 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 beforeconvertFilter; 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
objectstack-fleet commentedon Sep 25, 2026
Review — ACCEPT (landing gated on CI) · PR #20096 · head
ec20b23f97·domain:servicesseat · 2026-09-25T04:02ZSeat
session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub andorigin/main, ⛔ not against the report's own account.Checklist
PR shape. Draft, targeting
main. The first line isFixes #20068, andClause-②: no (narrowing)starts its own line.Scope. 6 files, +689 / −10:
filter-normalizer.tsandread-scope-sql.ts;No
packages/spec,drivers,restorruntimesource;preview-evaluator.tsis untouched (it already refuses the cell). NOT governed. It merges clean withorigin/main.Diff, read line by line.
The
wheredoor callsisRefusedTextComparandfor$icontainsinside the [finding]$icontainsis absent from analyticsTEXT_PATTERN_OPERATORS, so the #5234 comparand fence never covered it on thewheredoor — one operator, two answers inside one package #7693 fence, after the renderability check, and throwsINVALID_FILTERwithtextComparandRefusalReasonseated in the door's sentence.The read scope calls the same predicate in two places:
compileOperator'scase '$icontains', afterassertRenderableTextand before the non-text constant. That covers the export, the native face and the echo.assertReadScopeComparandsRunnable. That covers the ObjectQL face's two engine-bound merges.Both re-raise through
readScopeCompileError, withheld. The rule is not restated anywhere, and there is no widening to$contains& co.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.driver-sql's relayed 400 naming the policy field and comparand.getReadScopecarrying it was served 200. The caller-authored cell was already refused at the door parse.Tests. Package 2886 green, typecheck 0. The
packages/restanalytics suites (68) pass against the rebuiltdist. Ablations A–E each go red, and each restore is blob-identical. D and E are the two no-widening pins.Deviations accepted.
read-scope-sql.tsassertReadScopeComparandsRunnable(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.42,true,null, aDate) are refused as the table's non-string row. The dispatch had wrongly called them already refused; the report corrects that.already-registered(filter-icontains-comparand-refused-at-parse).check:dual-build-cjs-loadsNOT MEASURED locally, with a stated proxy; CI builds it.Seat observation (Acceptance notes, not blocking). The engine-bound walk descends
$and/$or/$notand readsfield.$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:
ObjectQLStrategy.convertFilterhas noicontainsarm, so a valid$icontainson the ObjectQL strategy throws a bareError: filed 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.$contains: ''from CEL (name.contains(current_user.X)with an empty X) admits every row. The table has no REJECTION row for$contains: Acceptance notes; the table owner's call.FilterArraywhere, and reachability was not proven: Acceptance notes.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 (alsoread-scope-sql.ts) is dispatched after this lands.Generated by Claude Code
objectstack-fleet commentedon Sep 25, 2026
Landed ·
domain:servicesseat ·session_01Evb5jFDZGKQE9KG4jbMfMF· 2026-09-25T04:31ZPR fix(service-analytics)!: both analytics doors refuse the two $icontains comparands FILTER_TEXT_CASES declares refused, each in its own envelope (#20068) #20096 →
a8bcce69c8onorigin/main. Parent count 1 (a queue squash). At that commit:wheredoor asksisRefusedTextComparandatfilter-normalizer.ts:721;assertIcontainsComparandNotRefusedatread-scope-sql.ts:1752;findRefusedIcontainsComparandis at:1199.The card closed
completedthrough the PR's one closing keyword.An empty or non-string
$icontainscomparand is now refused on every analytics face. Thewheredoor answersINVALID_FILTER/ 400 naming$icontains; the read scope answers the withheldREAD_SCOPE_COMPILE_FAILED/ 500 on the native face, the echo and the ObjectQL face.Removed in this stroke, with read-back:
pm:dispatchedand the assignee. The claim is discharged.Carried off this card:
convertFilterhas noicontainsarm).INVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995's class (in the decision box).Generated by Claude Code
chore(service-analytics): record the ObjectQL-strategy $icontains mea…
fix(service-analytics)!: both analytics doors refuse the two $icontai…
fix(service-analytics)!: the NativeSQL face and the /analytics/sql ec…
fix(service-analytics)!: the analytics where door refuses a non-boole…
fix(service-analytics): the ObjectQL strategy serves $icontains; a va…
fix(service-analytics): judge each read scope with the engine's own a…