Repository navigation
service-analytics (SQLite): the shared text-match arm emits GLOB, so a $contains / $endsWith / $startsWith comparand holding U+0000 is cut at the NUL; on the read scope a leading U+0000 widens $contains / $endsWith to every row #20025
Description
Filing gate: ① a defect with a named landing site: packages/services/service-analytics/src/text-match-sql.ts textMatchPredicateSql, its sqlite arm (GLOB). Two compile faces from references/compile-surfaces.md share it:
- face 3,
read-scope-sql.tscompileScopedFilterToSql(the RLS read side); - face 4,
strategies/filter-normalizer.tslowerAnalyticsWhere.
Finding class (a).
Filed by the domain:engine execution seat 1 (session_01Bvd69VPa6puiNzzPUroDBx) from the out-of-scope findings of its #19999 dev (os-dev-report on #19999, PR #20019). The seat's at-tier reviewer confirmed it by reading (record 5821310673 on PR #20019). ⛔ Filed bare: routing and grading are triage's. ⛔ Not a claim.
What happens
SQLite's glob() reads its pattern and the stored value as C strings, so each is cut at its first U+0000. This is the same mechanism #19999 fixes in driver-sql and the turso remote transport. The analytics compilers have their own copy of the arm, and it is not touched there.
Measured by the #19999 dev at PR #20019's head. Face 3 was executed: compileScopedFilterToSql(…, { dialect: 'sqlite' }) ran on better-sqlite3 over the values 'a'+U+0000+'b', 'ab'+U+0000, U+0000+'z', 'plain' and '':
| scope filter | rows returned | JS answer |
|---|---|---|
$contains: U+0000 |
all 5 | 3 |
$endsWith: U+0000 |
all 5 | 1 |
$contains: U+0000+'b' |
all 5 | 1 |
$startsWith: U+0000 |
'' and U+0000+'z' |
1 |
Face 4 was not executed; it shares the arm by reading.
Why it matters
On face 3, a widening is not a loose filter. It is a read scope admitting rows its policy excludes. Whether a scope comparand can come from a value a caller controls (for example a user attribute interpolated into the scope) is NOT MEASURED. If it can, triage should weigh the security label.
Suggested shape (⛔ not a ruling)
Reuse the constructs PR #20019 measured NUL-safe on better-sqlite3, sql.js and libSQL:
instr(col, ?) > 0forcontains;instr(col, ?) = 1forstarts;- a BLOB byte suffix for
ends. Take the zero-length-value fix PR fix(driver-sql, driver-turso): compare a SQLite text comparand holding U+0000 whole instead of cutting it at GLOB #20019 is landing:coalesce(substr(…), X''), so the predicate is false, not NULL, on''.
Keep GLOB for every NUL-free comparand, byte for byte. Declare faces 3 and 4 per references/compile-surfaces.md.
Filing-gate answers
- Class: ① (defect, named landing site); finding class (a). Face 3 was measured by execution by the dev, and the seat's reviewer confirmed the arm by reading.
- Acting reader: the seat that owns
packages/services/*after triage routes it (domain:services). It lands after PR fix(driver-sql, driver-turso): compare a SQLite text comparand holding U+0000 whole instead of cutting it at GLOB #20019, whose constructs and zero-length fix it reuses. - Dedupe, MCP semantic issue search in this repo,
closedincluded:service-analytics read scope SQL sqlite GLOB text match comparand NUL over-reach→ 10 hits, including 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 (open; plain-object$eqand null-in-$inscope shapes), driver-sql (SQLite faces): a$contains/$startsWith/$endsWithcomparand holding U+0000 is cut at the NUL byglob(), so the filter answers wrongly; one that starts with U+0000 makes$contains/$endsWithmatch every row #19999 (the driver half), and the closed 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, 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, analytics: read-scope-sql's ruled $not-over-$in-empty residue has no open card — and #13640 turned it into an echo-vs-execution disagreement on the ObjectQL strategy #13926,/analytics/sql回显的 SQL 丢掉$startsWith/$endsWith谓词:回显比实际执行的查询更宽,无法复现结果 #5333, drivers(sql family): 文本算子的大小写折叠是「方言的」而非「契约的」——$contains在 SQLite 过折叠、$icontains在 PG/MySQL 过折叠 #6518 and analytics 侧三个 SQL 编译器都不转义 LIKE 比较值:$contains: '_admin'命中xyadmin、$contains: '50%'命中off 5012 now—— driver-sql 自己把这条旁路标为 P0 —— 实测 #5567 (the LIKE case fold,translate(),$notover empty$in, the echo, the fold contract, and LIKE escaping). None covers a U+0000 comparand.- Control: the query reaches the analytics text-match cards.
Dedupe words: read-scope-sql GLOB U+0000 · textMatchPredicateSql NUL comparand · analytics sqlite glob C string
Generated by Claude Code
Activity
objectstack-fleet commentedon Sep 24, 2026
Blocked-by: #19999
分诊首次定级:
priority:p2·security·bug·domain:services·pm:blocked—— 分析服务在 SQLite 上自己有一份文本匹配编译,同样会在 NUL 处截断;落在读范围上时,以 NUL 开头的「包含 / 结尾是」会让读范围放行所有行;复用 #19999 的写法,排在它的 PR 后面Path:
packages/services/service-analytics/src/text-match-sql.ts(textMatchPredicateSql的sqlite分支,GLOB)→ 面 3read-scope-sql.tscompileScopedFilterToSql(RLS 读侧)· 面 4strategies/filter-normalizer.tslowerAnalyticsWhereTriage: lands in
service-analytics⇒domain:services,security,bug,priority:p2,pm:blockedBlocked-by #19999; rationale: the analytics compilers carry their own copy of the SQLiteGLOBtext-match arm, which #19999 does not touch — executed on face 3 (the RLS read side), a scope comparand starting with U+0000 widens$contains/$endsWithto EVERY row, i.e. a read scope admitting rows its policy excludes (hencesecurity); whether a scope comparand can come from a caller-controlled value is NOT measured, which keeps it at p2; the repair reuses PR #20019's NUL-safe constructs and its zero-length fix, so it waits for that landing.分诊席 #6015,2026-09-24T20:24Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectstack
origin/main44639665ee上确认text-match-sql.ts的 SQLite 分支用GLOB。面 3 的执行结果(5 行数据里$contains: U+0000返回全部 5 行,JS 答案是 3 行)是 #19999 的开发测的,本席没有重跑;面 4 只按读代码推断共用这一分支。定级说明
security:面 3 是 RLS 读侧,这里的「放宽」不是一个宽松的过滤,而是读范围放进了策略排除的行。为什么挂在 #19999 后面
卡面写明它复用 PR #20019 在 better-sqlite3、sql.js、libSQL 上都测过的 NUL 安全写法(
instr(col, ?) > 0、instr(col, ?) = 1、BLOB 字节后缀),以及 PR #20019 正在落地的零长度修复(coalesce(substr(…), X''))。#19999 关闭时本卡解锁。解锁后的执行要点
GLOB。按references/compile-surfaces.md声明面 3 和面 4。/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(同一个read-scope-sql.ts,另两种形状)同车道,派发时注意串行。Generated by Claude Code
objectstack-fleet commentedon Sep 24, 2026
Cross-lane note from
domain:engine:Blocked-by: #19999is satisfieddomain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-24T21:18Z. ⛔ Not a claim, and not an unlock: this card is the services lane's.#19999 closed
completed. PR #20019 merged as9d81af714f(landing record 5822388701 on #19999). The NUL-safe constructs this card's suggested shape reuses are onmaininsql-driver.ts(sqliteLengthAwareTextMatch), measured on better-sqlite3, sql.js and libSQL:instr(col, ?) > 0,instr(col, ?) = 1, andcoalesce(substr(CAST(col AS BLOB), -length(CAST(? AS BLOB))), CAST(col AS BLOB)) = CAST(? AS BLOB). The last one answers false, not NULL, on'', and NULL on NULL, likeGLOB.Generated by Claude Code
objectstack-fleet commentedon Sep 25, 2026
Unblock re-derivation:
pm:blocked→pm:queue· 2026-09-25T00:00ZActing on the maintainer's instruction. Provenance: who — the maintainer; verbatim — 「帮我排查一下 blocked 卡片,哪些需要更新状态帮我更新。」; where — the maintainer's chat with session
session_013RWUA7bNq5bRhehLPqXwMg, 2026-09-24. ⛔ Not a claim and not a dispatch: the card returns to its lane's take order. Candidate surfaced bycheck-half-states.mjsH19 on this sweep.$contains/$startsWith/$endsWithcomparand holding U+0000 is cut at the NUL byglob(), so the filter answers wrongly; one that starts with U+0000 makes$contains/$endsWithmatch every row #19999 closedcompletedon 2026-09-24 via PR fix(driver-sql, driver-turso): compare a SQLite text comparand holding U+0000 whole instead of cutting it at GLOB #20019. Thedomain:enginenote5822412218already records it satisfied and leaves the unlock to this lane.Blocked-by:condition on this card is driver-sql (SQLite faces): a$contains/$startsWith/$endsWithcomparand holding U+0000 is cut at the NUL byglob(), so the filter answers wrongly; one that starts with U+0000 makes$contains/$endsWithmatch every row #19999.compileScopedFilterToSqlis not touched: "its SQLite text arm is 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's". It does not carry this card's work.main, per the engine note.State:
pm:blocked→pm:queue;domain:services,priority:p2,securityunchanged; no assignee.Generated by Claude Code
objectstack-fleet commentedon Sep 25, 2026
Claim: PM loop round 1
Session:
session_01Evb5jFDZGKQE9KG4jbMfMFBranch:
claude/issue-20025-analytics-sqlite-text-nulWorktree:
objectstack-issue-20025Domain:
domain:servicesSeat:
domain:services#1File surface:
packages/services/service-analytics/src/text-match-sql.ts(thesqlitearm oftextMatchPredicateSql);src/__tests__/text-operator-case-exactness.test.tsand new test file(s) underpackages/services/service-analytics/src/;.changeset/20025-*.md. The call sites inread-scope-sql.ts,native-sql-strategy.tsandobjectql-strategy.tsare touched only if the bind contract changes (it binds more than one value per arm), and that is stated in the report. ⛔ Not the comparand refusal doors (filter-normalizer.ts, #20035 in flight;assertReadScopeComparandsRunnable, #20018 in the queue). ⛔ Nopackages/spec, nopackages/drivers/*. The scope includes the stored-value half that #20024 fixed indriver-sqland left to this card. (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: 5824306940
Serial constraints cleared at 2026-09-25T00:25Z: the two open PRs in
service-analyticsare PR #20046 (#20018;read-scope-sql.ts,comparand-shape.ts, tests) and PR #20058 (#20035;filter-normalizer.ts,preview-evaluator.ts, tests). Neither touchestext-match-sql.tsortext-operator-case-exactness.test.ts, so the surfaces are disjoint. The upstream constructs are onmain: PR #20019 (#19999,9d81af714f) and the #20024 stored-value arm insql-driver.tssqliteLengthAwareTextMatch.test(service-analytics): pin the SQLite text arm against U+0000 on ev…
objectstack-fleet commentedon Sep 25, 2026
os-dev-report
{
"issue": 20025,
"status": "done",
"branch": "claude/issue-20025-analytics-sqlite-text-nul",
"pr": "#20067",
"session": "session_01Evb5jFDZGKQE9KG4jbMfMF (mode:subagent, the parent's harness-stamped id; every commit carries Claude-Session with it)",
"premise_still_valid": true,
"summary": "The sqlite arm of service-analytics text-match-sql.ts textMatchPredicateSql now mirrors driver-sql's construct table on main cell for cell: instr(col,?) > 0 for contains ($contains/$notContains/$icontains), the coalesce BLOB byte suffix for a non-empty ends comparand, instr > 0 for an empty one, instr = 1 for a starts comparand holding U+0000, and GLOB byte-for-byte only for starts without U+0000 (the wider split: glob() also cuts the stored value). Measured first on the branch: all five sqlite faces (read scope direct, via native, via echo; native where; echo where) on better-sqlite3 and sql.js answered 88 of the pin grid's 128 cells differently from JS at base (37 of 60 in the scratch grid, formula as baseline), 0 at head; the driver-sql control matched JS throughout. No call site changes (the suffix binds twice through the existing TextMatchBind). The assignee field was os-sales on arrival and was not touched; the newest Claim 5824553351 names this branch.",
"tests": "At d000746: pnpm --filter @objectstack/service-analytics test → 'Test Files 121 passed (121) / Tests 2718 passed (2718)'; typecheck (tsc --noEmit, --listFiles counts 3/3 of text-match-sql.ts, text-match-sqlite-nul.test.ts, text-operator-case-exactness.test.ts) exit 0. New text-match-sqlite-nul.test.ts committed first at ced01cb and run at base: 'Tests 14 failed | 15 passed (29)', each face 'N: 88 of 128 cells differ', driver reference green; at head 'Tests 29 passed (29)'. Respelled 4 files (per-operator construct, tighter): 'Test Files 5 passed (5) / Tests 87 passed (87)'. Ablations via scripts/ablation-replace.mjs on src/text-match-sql.ts (relative import, src, no dist in the path), each leg anchor 1→0 and blob changed, restored blob e913134711cb == HEAD and git diff HEAD empty: A1 contains→GLOB 21 failed (60/128 cells); A2 ends→GLOB 15 failed (22/128); A3 starts-with-NUL→GLOB 11 failed (6/128); A4 starts NUL-free off GLOB 4 failed (compiled-text byte-for-byte pins; rows unchanged as expected); A5 empty ends→suffix 11 failed (2/128); A6 first attempt a no-op (replacement text occurred inside its own anchor, ablation-replace refused, no command ran), A6b with backtick-bounded anchor: 14 failed (12/128, '' under $not). Observed direction: turn-red on every leg.",
"mcp_calls": "0 — no MCP GitHub tool called",
"api_writes": "2 — POST /repos/objectstack-ai/objectstack/dispatches (fleet relay) x2: pr_create → POST /repos/objectstack-ai/objectstack/pulls (PR #20067, draft, run 36081336677 success); comment → POST /repos//issues/20025/comments (this os-dev-report). Plus git push (not REST). 0 label writes.",
"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: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",
"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",
"pnpm check:watch-hint-literal :: exit 0",
"pnpm check:where-matcher :: exit 0"
],
"gates_summary": "dispatch-gates --commands from the real diff (7 paths vs merge base 980bc05) = 60 families, identical to the dispatch-time list; 58 exit 0; 2 NOT MEASURED (exit 3 PREREQUISITE NOT MET: check:dual-build-cjs-loads and check:type-check-debt read the full-repo built closure; this worktree built only service-analytics and its dependency closure); --ran reconciliation "60 derived, 58 run, 2 NOT-MEASURED, 0 UNRUN". GITHUB_TOKEN=$GH_TOKEN node scripts/check-issue-citations.mjs exit 0 (14 citations resolve). After building service-analytics, check:dts-closure (15 packages, 69/69) and check:sourcemap-no-sources-content (15 packages, 99 maps) exit 0. Lint narrowed: eslint --no-inline-config --format json on the 6 changed TS files → files 6, errors 0, warnings 0; population = eslint.config.mjs packages/**/*.{ts,...} block, none ignored; type-aware linting not enabled (--print-config parserOptions {ecmaVersion, sourceType}, no project) so untouched files cannot move; HEAD d000746.",
"files_changed": [
"packages/services/service-analytics/src/text-match-sql.ts",
"packages/services/service-analytics/src/tests/text-match-sqlite-nul.test.ts (new)",
"packages/services/service-analytics/src/tests/text-operator-case-exactness.test.ts",
"packages/services/service-analytics/src/tests/icontains-dialect-sql.test.ts",
"packages/services/service-analytics/src/tests/sql-dialect-vocabulary.test.ts",
"packages/services/service-analytics/src/tests/text-operator-non-text-column.test.ts",
".changeset/20025-analytics-sqlite-text-nul.md (@objectstack/service-analytics: patch, Clause-②: no)"
],
"deviations": [
"Base: the worktree was cut from origin/main 980bc05 (PR #20046 landed between dispatch and worktree creation), not 2274894; text-match-sql.ts is identical between them and every measurement is anchored at 980bc05. Later main moved only by 3557f85 (driver-turso README), no merge needed.",
"Surface: the claim named text-operator-case-exactness.test.ts plus new files; three more existing test files (icontains-dialect-sql, sql-dialect-vocabulary, text-operator-non-text-column) asserted GLOB for moved shapes and are respelled (each tighter). Nothing on the dispatch ⛔ list touched; no call site changed.",
"The U+0000 parity rows went into a sibling file (text-match-sqlite-nul.test.ts), which the dispatch allowed, rather than into text-operator-case-exactness.test.ts.",
"The committed test states the JS answer with an inline mirror of formula evalOp five operator lines plus the spec asciiCaseInsensitiveContains, because service-analytics has no @objectstack/formula dependency (adding one would touch package.json and the lockfile); every cell is also held to driver-sql rows on the same engine, and the scratch measurement used formula matchesFilterCondition itself.",
"The issue comment JSON and this final JSON are not byte-identical: per the dispatch security discipline the comment carries the controllability verdicts only; controllability_evidence (exact commands) and pr_body_new are in the final report only.",
"Labels: zero writes. The dispatch named no label, and skip-changeset does not apply (the changeset publishes a patch).",
"Commit trailers use the AGENTS.md model-free pair (Claude-Session + Co-authored-by: Claude) rather than the harness reminder model-named Co-Authored-By line; AGENTS.md takes precedence and the pre-push hook refuses a model identifier.",
"Ablation A6 first attempt was a no-op (declared above) and was re-run with a different anchor.",
"Two derived gates are NOT MEASURED locally (full-repo build prerequisite); CI owns them."
],
"face_table": {
"anchor": "objectstack 980bc05 (origin/main after PR #20046; text-match-sql.ts identical to dispatch base 2274894); engines better-sqlite3 (SQLite 3.53.4) via SqlDriver.execute and sql.js (3.49.1) via SqliteWasmDriver.execute; JS baseline @objectstack/formula matchesFilterCondition (packages/formula/dist)",
"stored_values": "short ASCII strings with U+0000 at the start, middle and end, the same without it, '' and NULL (exact fixture in the committed test and the PM report)",
"comparands": "without U+0000 (including '') and holding U+0000 at the start, middle and end",
"base_dialect_sqlite_cells_differing_from_JS": {
"F3 read-scope (compileScopedFilterToSql)": "37 of 60",
"F3 via NativeSQLStrategy applyReadScope": "37 of 60",
"F3 via ObjectQL echo read scope": "37 of 60",
"F4 NativeSQLStrategy where": "37 of 60",
"F5 ObjectQL echo where": "37 of 60",
"CONTROL driver.find (ObjectQL execute face)": "1 of 60 ($icontains '' refused INVALID_FILTER vs JS false; by design)",
"same_on_both_engines": true,
"same_under_$not": "yes (NOT=1 re-run: identical 37/60 per face, $startsWith NUL-free 0/7)"
},
"base_per_operator_every_analytics_face": {
"$contains noNUL": "5/7 (narrows: match after a stored NUL missed)",
"$contains NUL": "3/5 (widens: NUL-leading comparand → every non-NULL row)",
"$notContains noNUL": "5/7 (widens: admits values containing the comparand after a stored NUL)",
"$notContains NUL": "3/5 (narrows: only NULL rows)",
"$startsWith noNUL": "0/7 (matches JS)",
"$startsWith NUL": "3/5 (widens: '' and NUL-led rows)",
"$endsWith noNUL": "5/7 (widens and narrows)",
"$endsWith NUL": "4/5 (widens)",
"$icontains noNUL": "6/7 (narrows; plus $icontains '' answers every non-NULL row vs JS false, pre-existing, not a NUL cell)",
"$icontains NUL": "3/5 (widens)"
},
"head_dialect_sqlite": "every analytics face on both engines: 1 of 60 differs, the $icontains '' cell only (unchanged by this PR); committed pin grid: 0 of 128 on every face and engine",
"unknown_dialect_arm_base": "no sqlDialect hook (unknown → LIKE), all five faces both engines: 46 of 60 differ (includes the LIKE ASCII-fold residue); NUL cells: $contains NUL 5/5, $notContains NUL 5/5, $startsWith NUL 5/5, $endsWith NUL 5/5; primitive: (a+NUL+b) LIKE %b% = 0 and plain LIKE %+NUL+% = 1 on both engines",
"like_ilike": "refused on every analytics face at base and head: read scope READ_SCOPE_COMPILE_FAILED/500, native and echo where INVALID_FILTER/400 (pattern with and without NUL) → no raw-pattern operator compiled on the sqlite arm, item 5 not applicable",
"explain_query_plan": "indexed TEXT column, both engines: base GLOB contains/ends SCAN, starts SEARCH USING INDEX; head contains/ends/notContains/icontains/starts-with-NUL SCAN, starts NUL-free SEARCH USING INDEX"
},
"controllability": {
"comparand_side": "no caller-controlled producer measured. RLS string-method arguments resolve from admin-authored policy literals or current_user string attributes (id, organization id, email); ids are system-generated, email is refused with U+0000 at the auth entries measured and read-only on the user object; membership sets are arrays, which the string methods refuse; plugin-sharing emits id/owner membership scopes, not text operators, and sharing-rule criteria go through engine.find (the fixed driver face).",
"stored_value_side": "a caller-controlled producer exists: any persona holding create/edit on an object with a text field; the engine write path stores U+0000 unchanged under a non-system context (executed). Reaching a read scope also needs a deployment-authored using predicate with a string method over such a field; no shipped example authors one.",
"recommendation_input_for_p1_question": "the widening needs a deployment-authored text-operator RLS predicate plus a writer of that field; no comparand-side producer measured"
},
"open_questions": [],
"out_of_scope_findings": [
"class: a · the unknown-dialect arm (LIKE) on a SQLite engine is cut at U+0000 on pattern and stored value: with no sqlDialect hook, all five faces on better-sqlite3 3.53.4 and sql.js 3.49.1 at 980bc05 answer 46 of 60 formula cells differently (NUL-comparand cells 5/5 for $contains/$notContains/$startsWith/$endsWith; primitive (a+NUL+b) LIKE %b% = 0, plain LIKE %+NUL+% = 1); reachable through the four embedder compositions text-match-sql.ts names (SqlDriver with a class client or unrecognised spelling, a host hook answering 'sqlite3', AnalyticsService without sqlDialect, a data service without getDriverForObject); not fixable in the arm without a dialect guess (PostgreSQL has no instr) · carrier: the domain:services seat · dedupe words: analytics unknown dialect LIKE U+0000 sqlite · text-match-sql unknown arm NUL cut · normalizeSqlDialect unknown sqlite LIKE NUL",
"class: b · $icontains with an empty comparand answers every non-NULL row on the service-analytics faces (compileScopedFilterToSql, NativeSQLStrategy where, echo; and AnalyticsService.query in-process executed 'instr(lower(v), lower($1)) > 0' with ['']), before and after this PR; contract: spec FILTER_TEXT_CASES 'declares two REJECTION rows for this operator (an EMPTY comparand and a NON-STRING one, code: INVALID_FILTER, mustMention: [$icontains])' (filter.zod.ts checkFilterConditionComparands docblock) and driver-sql refuses it (#5702); Seam: spec:FILTER_TEXT_CASES / isRefusedTextComparand → runtime:service-analytics AnalyticsService.query where + read-scope-sql compileScopedFilterToSql; REST-door reachability NOT MEASURED · carrier: PR #20058 (#20035) touches the analytics where door, coverage of this cell not verified · dedupe words: analytics icontains empty comparand · isRefusedTextComparand service-analytics · FILTER_TEXT_CASES rejection analytics",
"noted, not filed (PR Acceptance notes): stale GLOB prose in like-pattern.ts, read-scope-sql.ts docblocks, native-sql-strategy.ts, objectql-strategy.ts, strategies/types.ts · carrier: none"
]
}
objectstack-fleet commentedon Sep 25, 2026
Review — ACCEPT (landing gated on CI) · PR #20067 · head
d000746b1d·domain:servicesseat · 2026-09-25T01:24ZSeat
session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub andorigin/main, ⛔ not against the report's own account.Checklist
PR shape. Draft, targeting
main. The first line isFixes #20025, andClause-②: nostarts its own line. No other closing keyword.Scope. 7 files, +568 / −52:
text-match-sql.ts, one new 29-case test file, four respelled pin files and the changeset. No call site, no refusal door, no driver, spec or formula file. NOT governed. It merges clean withorigin/main.Diff, read line by line. The
sqlitearm oftextMatchPredicateSqlnow matchesdriver-sql'stextMatchPredicate/sqliteLengthAwareTextMatchonmaincell for cell:instr(col, ?) > 0forcontains;coalesce(substr(CAST … AS BLOB), -length(…)), CAST … AS BLOB) = CAST(? AS BLOB)suffix for a non-emptyends, andinstr > 0for an empty one;instr = 1for astartscomparand holding U+0000;GLOBbyte for byte only forstartswithout U+0000.This is the wider split driver-sql / driver-turso (SQLite faces):
GLOBstill cuts a STORED value at its first U+0000, so a NUL-free$contains/$notContains/$icontains/$endsWithanswers wrongly on it; a$like/$ilikepattern holding U+0000 is cut the same way #20024 set, not the card's narrower suggestion. The suffix binds its comparand twice through the existingTextMatchBind, in placeholder order. The fold wrapslower()around both sides. The negation isNOT (…), so 非否定路径上的$ne/$nin/$notContains:driver-sql 排除 NULL 行,driver-memory / formula 返回它们(#5146 只裁定了$not) #5298's wrapper composes unchanged.Measurement. Executed at base on better-sqlite3 and sql.js, through each driver's
execute(). Every analytics face answered 88 of the 128 pin cells differently from JS; thedriver-sqlcontrol answered 0. At head, 0 on every face and engine.$startsWithwithout U+0000 keeps its indexSEARCH.Tests. The new file was committed first and ran red at base (14 red / 15 green). At head: package 2718 green, typecheck 0. The four respelled files are each tighter, naming the construct per operator where they had one
/GLOB/. Ablations A1–A6 each turn red; A6's void first attempt is declared, and every restore is blob-identical.Controllability (triage's point 1) → the grade stays
priority:p2.current_userattributes.usingpredicate, and no shipped one exists.Triage's p1 condition was a caller-controlled comparand, and it is not met.
Deviations accepted.
980bc05e5b;text-match-sql.tsis identical to the dispatch base.formuladependency), and every cell is also held todriver-sql's rows.icontains-dialect-sql.test.ts,sql-dialect-vocabulary.test.tsandtext-operator-non-text-column.test.ts(this line is the revision).Out-of-scope findings, one line each:
$icontains: ''answers every non-NULL row on the analytics compilers, where the spec declares it refused: filed service-analytics:$icontainswith an empty comparand answers every non-NULL row on the analytics where and read-scope compilers, where FILTER_TEXT_CASES declares it refused (INVALID_FILTER) and driver-sql refuses it #20068.unknownarm (LIKE) on a SQLite engine is also cut at U+0000: dropped. No construct fixes it without a dialect guess (PostgreSQL has noinstr()). This PR declares the residue in the arm's header and in the changeset.GLOBprose in five docblocks: Acceptance notes.Landing. No Clause-② limb is hit: no
packages/spec/src, a patch, and the fix makes an existing operator answer its declared meaning. 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.Generated by Claude Code
objectstack-fleet commentedon Sep 25, 2026
Landed ·
domain:servicesseat ·session_01Evb5jFDZGKQE9KG4jbMfMF· 2026-09-25T02:14Zbf37b99a67onorigin/main. Parent count 1 (a queue squash). At that commit,sqliteLengthAwareTextMatchSqlis present intext-match-sql.ts:474, and thestarts-without-U+0000GLOBsplit is at:542. The card closedcompletedthrough the PR's one closing keyword.$contains,$notContains,$startsWith,$endsWith,$icontains) now compare the whole stored value and the whole comparand. That holds on the read scope, the nativewhereand the echo, and matchesdriver-sqland JS cell for cell.priority:p2held. No caller-controlled comparand producer was measured (see the ACCEPT).pm:dispatchedand the assignee. The claim is discharged.$icontainswith an empty comparand answers every non-NULL row on the analytics where and read-scope compilers, where FILTER_TEXT_CASES declares it refused (INVALID_FILTER) and driver-sql refuses it #20068 ($icontains: ''answers every row on the analytics compilers, awaiting triage). Theunknown-dialectLIKEresidue is declared intext-match-sql.ts's header.Generated by Claude Code
fix(driver-sql, driver-turso): SQLite $contains / $notContains / $ico…
fix(service-analytics): on SQLite, the text operators compare the who…