Repository navigation
service-analytics: the caller-where lowering reads $null / $exists flags 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
Description
Activity
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsBlocked-by: #20035
分诊首次定级:
priority:p2·bug·domain:services·pm:blocked—— 分析查询的调用方where按「是否恒等于 true / false」读取$null/$exists,非布尔值({ $null: "x" })被悄悄当成 IS NOT NULL 返回 200,其他所有面都会拒绝Path:
packages/services/service-analytics/src/strategies/filter-normalizer.ts(lowerAnalyticsWhere里$null/$exists的降级)Triage: lands in
service-analytics⇒domain:services,bug,priority:p2,pm:blockedBlocked-by #20035; rationale:$null/$existsare declaredz.boolean()and the #5347 / #5369 rulings refuse a non-boolean flag everywhere because backends read one in opposite directions, but this lowering reads the flag by identity and lowers anything else to "set" (measured by the #20020 dev on the realPOST /api/v1/analytics/queryroute: HTTP 200, the driver received$ne: null) — a silent coercion of the caller's own filter, not a read-scope widening, hence p2 withoutsecurity; the #20035 branch is rewriting this file (318 lines) and its own notes say an accepted non-boolean flag still reaches the old path, so this lands after it.分诊席 #6015,2026-09-25T02:24Z。⛔ 不认领、不派发。本席读完了卡面(本卡尚无评论),并在 objectstack
origin/main7f1de2eb66上核对。本席核对
filter-normalizer.ts的注释(第 455–457 行附近)明确说$null/$exists的含义随布尔值翻转,不能放进按键名映射的表里。- service-analytics: the object-form analytics
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)改了本文件 318 行。它新增的注释写着:「An ACCEPTED non-boolean flag ({$null: 'false'}) still reaches the …」,也就是说它不修本卡的问题,但会大改同一段代码。 - 读范围那一面(
read-scope-sql)已由 read-scope-sql 的$null/$exists按真值性读比较数 ——{$null: "false"}编成IS NULL,#5347 / #5369 的先例没推到 RLS 编译器 #6387 修好。 - 卡面在真实路由上的测量本席没有重跑。
定级说明
p2:调用方自己写的筛选条件被悄悄换成了另一个意思,答案错误。但只影响调用方自己能看的行,不涉及读范围,所以不加
security。解锁后的执行要点
- 在这一层拒绝非布尔的
$null/$exists:返回INVALID_FILTER/ 400,与其他面的写法一致(参照 driver-sql 的nonBooleanNullComparandError)。 - 顺带查一下:同一个降级有没有被其他分析路径复用。
- 钉子:经真实路由验证,
{ $null: 'x' }和{ $exists: 'x' }都返回 400;true/false照常。
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 25, 2026 objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsUnlock re-derivation:
Blocked-by: #20035is satisfied ·domain:servicesseat · 2026-09-25T04:48ZSeat
session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021. Read againstorigin/maina8bcce69c8.- Blocker: service-analytics: the object-form analytics
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 serialised behind. PR fix(service-analytics)!: both analytics doors refuse the two $icontains comparands FILTER_TEXT_CASES declares refused, each in its own envelope (#20068) #20096 (service-analytics:$icontainswith an empty comparand answers every non-NULL row on the analytics where and read-scope compilers, where FILTER_TEXT_CASES declares it refused (INVALID_FILTER) and driver-sql refuses it #20068), which also touched this file, landed asa8bcce69c8. No open PR touchesfilter-normalizer.ts. - Re-derived, still owed: on
a8bcce69c8,lowerAnalyticsWherestill reads the flag by identity. Atfilter-normalizer.ts:1131,isNulliswrapper[opKey] === truefor$nulland=== falsefor$exists, so any other value lowers to "set".- service-analytics: the object-form analytics
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's comparand-TYPE face does not catch it: a string is an accepted comparand type, and the flag's boolean-ness is the operator's contract, not the type face's. - The card's defect stands.
- service-analytics: the object-form analytics
- State:
pm:blocked→pm:queue;domain:servicesandpriority:p2are unchanged. The seat claims it below in the same pass.
Generated by Claude Code
- Blocker: service-analytics: the object-form analytics
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsClaim: PM loop round 1
Session:session_01Evb5jFDZGKQE9KG4jbMfMF
Branch:claude/issue-20040-analytics-null-flag
Worktree:objectstack-issue-20040
Domain:domain:services
Seat:domain:services#1
File surface:packages/services/service-analytics/src/strategies/filter-normalizer.ts(the$null/$existslowering inlowerAnalyticsWhere, and any sibling reader of the flag in the same file);src/preview-evaluator.tsonly if the draft preview answers the cell differently; pins asserting today's admission, re-judged with notes; new test file(s) underpackages/services/service-analytics/src/;.changeset/20040-*.md. ⛔ Notread-scope-sql.tsor the strategies (#20075 in flight). ⛔ Nopackages/spec,drivers,restorruntimesource. (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: 5826921637
Serial constraints cleared at 2026-09-25T04:50Z: PR #20058 (#20035) and PR #20096 (#20068), both infilter-normalizer.ts, landed as7ddf396109anda8bcce69c8. The in-flight #20075 claimsread-scope-sql.tsand the two strategies, which is disjoint from this file. #20098 (objectql-strategy.ts) is queued behind #20075.objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 20040,
"status": "done",
"branch": "claude/issue-20040-analytics-null-flag",
"pr": "#20115",
"session": "session_01Evb5jFDZGKQE9KG4jbMfMF — mode:subagent, the dispatching seat's harness-stamped id (commit trailers carry it as Claude-Session)",
"premise_still_valid": true,
"summary": "The analytics where door read $null / $exists by identity, so every non-boolean flag (a string incl. 'false'/'true', a number, null, an array, a Date, a { $field } reference, a bigint within 2^53) lowered to IS NOT NULL on every face, and both HTTP routes answered 200 (confirmed on main 8d76c2d). normalizeWhereComparands gains a third pass, assertBooleanNullFlags, after the shared shape and type faces and before any node is built: INVALID_FILTER / 400, message kept, naming operator, field and path, in driver-sql's main clause. No spec/core/types predicate exists, so the check is local with a docblock citing #5347 / #5369 and the sibling faces; no driver import, no spec export. true/false are byte-identical (tree, native and echo SQL + params, engine where), the preview now answers the cell in the door's words through the same gate (preview-evaluator.ts not edited), and the read-scope face is untouched.",
"tests": "New where-boolean-flag-refusal.test.ts (63 cases). Test-only commit 12e3081 before the fix: 'Tests 53 failed | 10 passed (63)' (every door row red; controls, order pins, FilterArray control and read-scope pins green). Head 66266c2: 'pnpm --filter @objectstack/service-analytics exec vitest run' → 'Test Files 125 passed (125) / Tests 2949 passed (2949)'; typecheck 'tsc --noEmit' exit 0, tsc --listFiles includes both touched test files. Consumer sanity vs rebuilt dist: packages/rest 10 analytics suites 'Tests 148 passed (148)', packages/runtime 3 analytics suites 'Tests 36 passed (36)'. Ablations (scripts/ablation-replace.mjs, committed state, anchor x1, restore blob == HEAD + git diff HEAD empty each): A gate call removed → 54 red / 43 green (53 door rows + the re-judged pin); B $exists dropped from the set → 27 red / 70 green (all carry an $exists assertion); C gate moved ahead of the faces → 11 red / 192 green (order pins 2+2, 7 pre-existing where-type-face-refusal pins; first attempt refused by the tool itself because the replacement contained the anchor, nothing ran, restored, re-run with '(path)'); D gate refuses false → 2 red / 61 green (the two false controls); E read-scope-sql.ts assertBooleanFlagComparands call removed (transient) → 2 red / 61 green (read-scope pin + compileScopedFilterToSql; ObjectQL-face pin green as predicted); F driver-sql $null gate removed + driver-sql rebuilt + 'ablation-dist-preflight --absent: marker absent from all 6 built files' → 1 red / 62 green (ObjectQL-face read-scope pin); restore rebuilt, preflight 'marker present in 2 built files', 'tree: working tree clean against HEAD'.",
"mcp_calls": "0 — no MCP GitHub calls of any kind",
"api_writes": "3 — all through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]): (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft), run 36101489059 success; (2) label-write --assign os-sales → POST /repos//issues/20115/assignees, run 36101551153 success, read back os-sales; (3) this os-dev-report → POST /repos//issues/20040/comments via post-stamped. Label writes: 0 (the dispatch names no label; skip-changeset does not apply, the diff ships a changeset). git push x5 (empty branch probe, test-only commit, fix commit, two merge commits) are not REST writes.",
"open_questions": [],
"out_of_scope_findings": [
"class: c · The save door admits a non-boolean flag every query face refuses. Measured at 66266c2: FilterConditionSchema.safeParse({ stage: { $null: 'x' } }) succeeds, and DatasetSchema.safeParse with filter { stage: { $null: 'x' } } (also $exists: 'false', $null: null) succeeds, while the same save door refuses $icontains: '' (#19514). A stored dataset / widget filter carrying the shape saves and then answers 400 on every query face (driver-sql since #5347, the analytics door since PR #20115). Named producer: whoever authors a dataset filter (defineStack metadata or the Studio). Seam: spec:FilterConditionSchema (checkFilterConditionComparands) → runtime:driver-sql reduceFilterKey | service-analytics normalizeWhereComparands · dedupe words: 'FilterConditionSchema $null non-boolean admitted at save' · 'dataset filter $exists string saves then 400' · 'checkFilterConditionComparands boolean flag' · reported to the seat to file, not filed here",
"carrier: none · noted, not filed — the draft preview still refuses a BOOLEAN flag as 'not evaluated' (it evaluates no $null/$exists, the #19810 table), so a repaired filter is refused there while publish serves it; widening that table is ruled to land with the conformance kits (PR Acceptance notes)"
],
"gates": {
"head": "66266c2a3b",
"derived": 60,
"dispatch_time_list": 54,
"added_vs_dispatch_list": [
"check:engine-double-contract",
"check:objectql-double-limit",
"check:query-options-erasure",
"check:type-check-coverage",
"check:type-check-debt",
"check:where-matcher"
],
"ran_reconciliation": "60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN (all 60 carry an exit code)",
"issue_citations_real_run": "GITHUB_TOKEN="$GH_TOKEN" node scripts/check-issue-citations.mjs :: exit 0 — 29 citations judged, 29 resolve",
"adr_0087": "not-required (no-migration-prescription), accepted",
"lint": "eslint --no-inline-config --format json over 3 changed .ts files: 3 files, 0 errors, 0 warnings; isPathIgnored false for all three; the changeset .md is ignored (no config); eslint.config.mjs enables no type-aware linting (no parserOptions.project, no projectService), so no untouched file's verdict can move",
"results": [
"node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
"node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
"node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
"node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
"node scripts/check-ci-filter-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-adoption.mjs :: exit 0",
"node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-corpus.mjs :: exit 0",
"node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
"node scripts/check-empty-changeset.mjs --self-test :: exit 0",
"node scripts/check-keyed-text-bounds.mjs :: exit 0",
"node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
"node scripts/check-registry-log-declared.mjs :: exit 0",
"node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
"node scripts/check-system-context-census.mjs :: exit 0",
"node scripts/check-system-context-census.mjs --self-test :: exit 0",
"node scripts/check-tenant-audit-census.mjs :: exit 0",
"node scripts/check-tenant-audit-census.mjs --self-test :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
"node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
"node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
"node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
"pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
"pnpm check:changeset-gate-self-tests :: exit 0",
"pnpm check:cross-package-test-inputs :: exit 0",
"pnpm check:doc-authoring :: exit 0",
"pnpm check:driver-memory-census :: exit 0",
"pnpm check:dts-closure :: exit 0",
"pnpm check:dual-build-cjs-loads :: exit 0",
"pnpm check:engine-double-contract :: exit 0",
"pnpm check:gitlink-declared :: exit 0",
"pnpm check:issue-citations :: exit 0",
"pnpm check:lean-entry-closure :: exit 0",
"pnpm check:logger-receiver-detach :: exit 0",
"pnpm check:nul-bytes :: exit 0",
"pnpm check:objectql-double-limit :: exit 0",
"pnpm check:objectui-changeset :: exit 0",
"pnpm check:org-identifier :: exit 0",
"pnpm check:page-declaration-shape :: exit 0",
"pnpm check:pm-changeset-deadline-census :: exit 0",
"pnpm check:published-files :: exit 0",
"pnpm check:query-options-erasure :: exit 0",
"pnpm check:refd-timer-probe :: exit 0",
"pnpm check:slot-lookup :: exit 0",
"pnpm check:sourcemap-no-sources-content :: exit 0",
"pnpm check:test-source-alias :: exit 0",
"pnpm check:tier-file-adoption :: exit 0",
"pnpm check:type-check-coverage :: exit 0",
"pnpm check:type-check-debt :: exit 0",
"pnpm check:watch-hint-literal :: exit 0",
"pnpm check:where-matcher :: exit 0"
]
},
"files_changed": [
".changeset/20040-analytics-null-flag.md",
"packages/services/service-analytics/src/strategies/filter-normalizer.ts",
"packages/services/service-analytics/src/tests/where-boolean-flag-refusal.test.ts",
"packages/services/service-analytics/src/tests/filter-normalizer-undefined-comparand.test.ts"
],
"deviations": [
"Ablations E and F transiently mutated files outside the claim's surface: read-scope-sql.ts (E) and packages/drivers/driver-sql/src/sql-driver.ts plus its dist (F). Ablation only; each restored to blob == HEAD with git diff HEAD empty, driver-sql rebuilt and dist-preflighted after F; neither file is in the diff.",
"Throwaway HTTP/face probes lived briefly in packages/runtime/src, packages/rest/src and service-analytics/src/tests (the PR #20072 practice); never committed, deleted, tree clean before every commit.",
"Commit trailers use AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude), not the harness reminder's model-named Co-Authored-By line, per AGENTS.md precedence; the two merge commits carry git's default message.",
"main moved twice during the run (aa04ea2 objectql, fa00ebf runtime; both disjoint); merged both, never rebased, and re-ran the whole 60-gate union plus the package suite and typecheck on the final head 66266c2.",
"check:dual-build-cjs-loads and check:type-check-debt answered PREREQUISITE NOT MET (exit 3) on the first head until the lint.yml build command built every package; both then exit 0 (and on the final head in one pass).",
"pnpm check:issue-citations runs only its self-test; the real invocation named by the dispatch was run separately (exit 0).",
"The refusal names the entry's own key as the field (for a nested relation: field "stage" at path where.acct.stage.$null), not fieldLeaves' dotted member acct.stage; the path carries the full position, the shared faces' convention.",
"Precedence change (declared, pinned): a where carrying a non-boolean flag plus a defect only the lowering diagnoses (#6444 mixed wrapper, unknown operator, zero-operator constraint) is now answered with the flag, same 400 INVALID_FILTER; #20035 took the same precedence for the type face."
],
"measurement_table": "|{ stage: { FLAG: V } }| native execute | echo (run) | ObjectQL engine path | draft preview |AnalyticsService.query(native / ObjectQL) | HTTP, both routes |\n|:--|:--|:--|:--|:--|:--|:--|\n|$nullor$exists, V ='x','false','true',0,1,null,[true],{ $field: 'id' }, aDate,2n| r1 r3 (IS NOT NULL) | r1 r3 | r1 r3; the engine received{ stage: { $ne: null } }| 400, "not evaluated" | r1 r3 | 200, r1 r3, 1 statement |\n| the same under$not| r2 | r2 | r2 | 400, "not evaluated" | r2 | 200, r2 |\n| V ={ a: 1 }orundefined| 400, type face | = | = | = | = | 400 (the plain object) |\n|$null: true/$exists: false(control) | r2 | r2 | r2,{ stage: null }| 400, "not evaluated" | r2 | 200, r2 |\n|$null: false/$exists: true(control) | r1 r3 | r1 r3 | r1 r3,{ stage: { $ne: null } }| 400, "not evaluated" | r1 r3 | 200, r1 r3 |",
"measurement_after": "head 66266c2: 44 of 44 non-boolean object-form rows refused INVALID_FILTER / 400 on all 7 in-process columns (native, echo, ObjectQL engine path, preview, service native, service ObjectQL, tree) with the flag sentence, 0 statements, 0 engine calls; HTTP POST /api/v1/analytics/query (native + ObjectQL-only) and /analytics/dataset/query (runtimeFilter + dataset.filter) answer 400 INVALID_FILTER with 0 statements for every non-boolean cell; controls 200 with the same rows; control capture (tree, native SQL+params, echo SQL+params, engine where) byte-identical to 8d76c2d (10328 bytes, same sha256); FilterArray rows and the plain-object/undefined type-face rows unchanged.",
"labels": "0 label writes (none named by the dispatch; skip-changeset not applicable); PR assignee os-sales set via label-write and read back; the card assignee untouched",
"cleanup": "node_modules removed, git worktree remove /home/user/objectstack-issue-20040 exit 0 without --force; no server or background process was started",
"pr_body_new": "Fixes #20040\n\nClause-②: no (narrowing)\n\n## What this changes\n\nThe analyticswheredoor (lowerAnalyticsWhereinpackages/services/service-analytics/src/strategies/filter-normalizer.ts) now refuses a$nullor$existsflag whose value is not a boolean. It refuses before any lowering, in the door'sINVALID_FILTER/ 400 envelope, with the message kept.\n\n-FieldOperatorsSchemadeclares both flagsz.boolean(). The #5347 / #5369 rulings refuse a non-boolean flag in every position and on every backend, because the backends read one in opposite directions.driver-sqlrefuses it (nonBooleanNullComparandError/nonBooleanExistsComparandError), and so does the read-scope compiler (#6387,assertBooleanFlagComparands).\n- This door read the flag by identity (=== truefor$null,=== falsefor$exists), so every other value lowered toset, IS NOT NULL.\n\nHow:\n\n-normalizeWhereComparandsgains a third pass,assertBooleanNullFlags, after the shared comparand-shape face and the shared comparand-type face.\n - It walks their traversal (forEachWhereFieldEntry), so it finds a flag in every position they judge:$and/$ormembers,$not, field entries, and a nested relation's entries.\n - It reports a flag at the path those faces give it (where.acct.stage.$null).\n- The message names the operator, the field and the path:[analytics] Operator \"$null\" on field \"stage\" requires a boolean comparand (true or false). Received a string (\"x\") at where.stage.$null. …\n - Its main clause isdriver-sql's, word for word through "(true or false)".\n - The rest says what this door used to do and how to repair it. It carries no tracker number.\n- Reuse.@objectstack/spec,coreandtypespublish no predicate or sentence for this refusal (searched; none exists).\n - So the one check lives in this file, and its docblock cites #5347 / #5369 and the sibling faces.\n - It imports no driver and adds no spec export.\n - The received value is rendered with this package's ownshapePreview(comparand-shape.ts).\n-true/falselower exactly as before.\n- TheFilterArrayspelling needs no pass (measured, below):\n -parseFilterASTwrites a hard-coded boolean foris_null/is_not_null, and refuses$null/existsas array operators;\n -isFilterASTrefuses an embedded object.\n\n### The order chosen, and why\n\n- After the shape and type faces. A flag the type face refuses keeps the face's sentence:undefined, a plain object, aMap, a bigint beyond 2^53. That is the sentence theFilterArrayspelling and the engine seam give. A shape or type defect elsewhere in the samewhereis still answered first. Ablation C pins this order.\n- Before theundefinedgate (#6386) and everything elsebuildNodereaches. Two readers see the flag before the identity read infieldLeaves:\n - the #5146$notrewrite, which classifies every field spec throughnullValueSatisfiesOperator/operatorIsNullTotal(the flag readers at the oldfilter-normalizer.ts:1411/:1464);\n - the draft preview, which evaluates whatnormalizeWhereComparandsreturns and never reachesfieldLeaves.\n\n Theundefinedgate skips both flags by name, so the two gates never judge one value.\n- One precedence change, same envelope. Awherecarrying a non-boolean flag and a defect only the lowering diagnoses is now answered with the flag. Those defects are a mixed$/ non-$wrapper (#6444), an operator outside the vocabulary, and a zero-operator constraint. The code and status stayINVALID_FILTER/ 400. #20035 took the same precedence for the type face over #6444. It is pinned, with boolean controls that keep the old sentences.\n- The preview. Before the fix it refused every flag ("not evaluated", 400) while the published faces served the cell, so the two answered it differently. It reaches the new pass throughnormalizeWhereComparandsand now refuses this cell in the door's words, byte for byte.preview-evaluator.tsis not edited. A boolean flag still gets the preview's own "not evaluated" refusal.\n\n## Measured first (recorded on this branch as12e3081867, test-only, before any source change)\n\nBaseorigin/main8d76c2d38c.\n\n- Data. A real sql.js engine (driver-sqlite-wasm) over rows r1 'won', r2 NULL and r3 'lost' (fieldstage), with a realObjectQLbehind the ObjectQL face. The echo's SQL was run on the same database.\n- HTTP legs. Read and measured only, through throwaway harnesses (not committed) over a realAnalyticsService:\n -packages/runtime's dispatcher routePOST /api/v1/analytics/query, native and ObjectQL-only;\n -packages/rest'sPOST /analytics/dataset/query, with the flag inselection.runtimeFilterand in an inlinedataset.filter.\n\n|{ stage: { FLAG: V } }| native execute | echo (run) | ObjectQL engine path | draft preview |AnalyticsService.query(native / ObjectQL) | HTTP, both routes |\n|:--|:--|:--|:--|:--|:--|:--|\n|$nullor$exists, V ='x','false','true',0,1,null,[true],{ $field: 'id' }, aDate,2n| r1 r3 (IS NOT NULL) | r1 r3 | r1 r3; the engine received{ stage: { $ne: null } }| 400, "not evaluated" | r1 r3 | 200, r1 r3, 1 statement |\n| the same under$not| r2 | r2 | r2 | 400, "not evaluated" | r2 | 200, r2 |\n| V ={ a: 1 }orundefined| 400, type face | = | = | = | = | 400 (the plain object) |\n|$null: true/$exists: false(control) | r2 | r2 | r2,{ stage: null }| 400, "not evaluated" | r2 | 200, r2 |\n|$null: false/$exists: true(control) | r1 r3 | r1 r3 | r1 r3,{ stage: { $ne: null } }| 400, "not evaluated" | r1 r3 | 200, r1 r3 |\n\n- Positions. The top level,$and,$or,$not, and$notbeside$ne; every coerced cell answered the same in each.{ $not: { stage: { $null: 'true' } } }served the NULL row the author excluded.\n-$null: null/$exists: nullare reachable, in process and over HTTP (JSONnull). Both read as IS NOT NULL and answered 200:\n - theundefinedgate (#6386) skips both flags by name;\n - the type face acceptsnull, which is in its accepted set;\n - the shape face's null carve-outs cover$in/$ninmembers,$betweenendpoints and ordering comparands, not the flags.\n- The card's HTTP 200 is confirmed onmain.FilterConditionSchematypes a field entry asz.unknown(), so the body parse admitted every non-boolean flag on both routes.\n- TheFilterArrayspelling carries no flag value.\n -['stage', 'is_null', 'x']givesnotSetand['stage', 'isnotnull', 0]givesset; the third member is filler.\n -['stage', '$null', 'x'],'null','exists'and'$exists'are refused as not a filter.\n -isFilterASTanswers false for every array embedding an object node (5 shapes probed).\n- Other analytics paths through the same lowering (the card's check):\n - dataset scope filters and measure filters reach it through the strategies'normalizeAnalyticsFilterTreecalls;\n - the preview reaches it throughnormalizeWhereComparands;\n - the read-scope door does not use it.\n\n## After (head66266c2a3b)\n\n- In process. Every non-boolean row above is refusedINVALID_FILTER/ 400 on all seven in-process columns, the preview included, in the door's sentence (44 of 44 rows). Nothing reaches the database or the engine: 0 statements and 0engine.aggregatecalls.\n- HTTP re-measure. Both routes answer 400INVALID_FILTERfor every non-boolean cell, with the message kept and 0 statements. The controls still answer 200 with the same rows.\n- Controls, byte for byte. For$null/$exists×true/false, at the top level, under$not, and under$notbeside$ne, I captured:\n - the compiled tree;\n - the native SQL and its params;\n - the echo SQL and its params;\n - the enginewhere.\n\n The capture taken on8d76c2d38cand the one taken after the fix are byte-identical (10,328 bytes, the same sha256). The test pins them as literals.\n- The read scope keeps its own answer, untouched:\n - on native, the echo andAnalyticsService.query(native):READ_SCOPE_COMPILE_FAILED/ 500, withheld, inread-scope-sql's sentence;\n - on the ObjectQL face,driver-sqlanswers it:INVALID_FILTER/ 400, with the policy content withheld.\n\n## Tests\n\nNew filepackages/services/service-analytics/src/__tests__/where-boolean-flag-refusal.test.ts, 63 cases. Every refusal assertscode+status.\n\n- the door rows: 2 flags × 10 non-boolean values;\n- every position, the nested-relation path, and the received value named in the message;\n- theFilterArraycontrol;\n- existing refusals keeping their order and sentence;\n- the measured precedence against the lowering's own refusals, with boolean controls;\n- every face over a real engine, with statement and engine-call counters;\n- the preview, whose message is byte-equal to the door's;\n- the byte-for-byte controls;\n- the read-scope face on every sub-face, pluscompileScopedFilterToSql;\n- stored dataset and measure filters throughqueryDataset.\n\nPins re-judged, none deleted.\n\n- The census was taken before the change, overservice-analytics,packages/restandpackages/runtime, plus a repo-wide test scan.\n-restandruntimehad 0 hits asserting this door's admission.rest-4xx-message-truncation.test.tscopies adriver-sqlmessage and is not this door.\n- One pin infilter-normalizer-undefined-comparand.test.tsasserted the admission: the case "$null / $exists: an undefined flag is refused by the type face; an accepted flag value reads as before".\n - It asserted{ $null: 'false' }and{ $exists: 'yes' }lower toset.\n - Flipped to refusals, with a note. Theundefinedhalf is unchanged, and the case title now says what it asserts.\n\nRuns.\n\n- Test-only commit12e3081867, before the fix: 53 failed | 10 passed (63).\n - Every door row was red.\n - Green: the four controls, the type-face and shape-face order pins, theFilterArraycontrol and the three read-scope pins.\n- Head66266c2a3b:\n -pnpm --filter @objectstack/service-analyticssuite: 125 files, 2949 tests passed;\n -typecheck: exit 0, andtsc --listFilesincludes both touched test files;\n - consumer sanity run against the rebuiltdist, not a pin:packages/rest's 10 analytics route suites (148 passed) andpackages/runtime's 3 analytics suites (36 passed).\n\n### Ablations\n\nEach ran throughscripts/ablation-replace.mjsfrom committed state, with the anchor hit ×1 and the restore proven (blob == HEAD,git diff HEADempty).service-analyticstests import the subject by relativesrcpaths, so nodistis in the path, except for leg F.\n\n| leg | mutation | red / green | restore |\n|:--|:--|:--|:--|\n| A | the gate's one call site removed | 54 red / 43 green over the new file and the re-judged one: the 53 door rows from the before-fix run plus the re-judged pin. Controls and read-scope pins stayed green, as predicted | bloba7bdec971782== HEAD |\n| B |$existsdropped from the gate's set | 27 red / 70 green. Every red case carries an$existsassertion (21 name it, 6 are mixed); every$null-only case stayed green | == HEAD |\n| C | the gate moved ahead of the shape and type faces | 11 red / 192 green over the new file, the re-judged file andwhere-type-face-refusal.test.ts: the order pins (2 + 2) and 7 existing type-face pins. The first attempt was refused by the tool itself, because the replacement contained the anchor, so the count could not drop. Nothing ran, the restore was proven, and it was re-run with an equivalent spelling | == HEAD |\n| D | the gate widened to refusefalse| 2 red / 61 green: the twofalsecontrols | == HEAD |\n| E |read-scope-sql.ts'sassertBooleanFlagComparandscall removed (a transient ablation; the file is not in this diff) | 2 red / 61 green: the read-scope pin andcompileScopedFilterToSql. The ObjectQL-face pin stayed green, as predicted, becausedriver-sqlanswers there | blob403440964f36== HEAD |\n| F |driver-sql's$nullgate removed anddriver-sqlrebuilt;ablation-dist-preflight --absentpassed (the marker is absent from all 6 built files) | 1 red / 62 green: the ObjectQL-face read-scope pin | bloba89054ee3aab== HEAD; rebuilt, and preflight shows the marker present in 2 built files with the tree clean |\n\n## Gates (head66266c2a3b)\n\n- Derivation.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 60 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-debtandcheck:where-matcher.\n- 60 of 60 exit 0. Among them:\n -check-adr-0087-registration --base origin/main, with the dispositionnot-required (no-migration-prescription);\n -check-changeset-no-major,check-empty-changesetandcheck:nul-bytes;\n -check:issue-citations, which runs only its self-test. The real run (GITHUB_TOKEN=\"$GH_TOKEN\" node scripts/check-issue-citations.mjs) judged 29 citations, and all resolve.\n- The two built-output gates.check:dual-build-cjs-loadsandcheck:type-check-debtread built output. On the first head, before any full build, both answered PREREQUISITE NOT MET, which is not a failure. They ran green after the lint.yml build command (turbo run build --filter='./packages/*' --filter='./packages/*/*').\n- Reconciliation.--ran: 60 derived, 60 run, 0 NOT MEASURED, 0 UNRUN. Every entry carries its exit code.\n- Merges.mainmoved twice while this ran:aa04ea2964(objectql) andfa00ebf447(runtime), both disjoint from this diff. Both were merged, never rebased, and the whole union was re-run on the final head.\n- Lint, narrowed and proven.\n -eslint --no-inline-config --format jsonover the 3 changed.tsfiles: 3 files, 0 errors, 0 warnings.\n -isPathIgnoredis false for all three. The changeset.mdis ignored and has no config, so it is outside the population.\n -eslint.config.mjsnever enables type-aware linting (noparserOptions.projectand noprojectService, as its line 327 states). So this diff cannot move the verdict of any untouched file.\n\n## Changeset\n\n.changeset/20040-analytics-null-flag.md:minorwith!and aClause-②: no (narrowing)line, following the #20035 / #20068 precedents.\n\n- ADR-0087 marker. It is an HTML-comment line, described here in words so this body keeps it. It declaresnot-required (no-migration-prescription), and the gate accepts it.\n - No ledger entry names the flags' boolean domain: the #5347 / #5369 refusals indriver-sqland the #6387 refusal were never registered.\n - A non-boolean flag has no boolean it can be mechanically rewritten to.\n- Who is affected. A text scan over 4598 tracked non-test files found 22 matches for a non-boolean flag. 21 are code comments; the 22nd is an operator-name lookup table indriver-memory, not a filter. Stored datasets, widget filters and report runtime filters in a deployment were NOT measured.\n\n## Acceptance notes\n\n- The preview still refuses a BOOLEAN flag ("not evaluated"). It evaluates no$null/$existsat all (the #19810 table), and that is unchanged and not this card's to change. Widening that table is ruled to land together with the conformance kits. A drafted chart carrying{ $null: 'x' }now gets the flag refusal first. Once repaired totrue, it gets the preview's own refusal, while publish serves it.\n\n## Out-of-scope findings (reported to the seat, not filed here)\n\n1. Class (c). The save door admits a non-boolean flag that every query face refuses.\n - Measured at66266c2a3b:\n -FilterConditionSchema.safeParse({ stage: { $null: 'x' } })succeeds;\n -DatasetSchema.safeParsewithfilter: { stage: { $null: 'x' } }(also with$exists: 'false'and with$null: null) succeeds;\n - the same save door refuses$icontains: ''(#19514).\n - So a stored dataset or widget filter carrying the shape saves, and then answers 400 on every query face:driver-sqlsince #5347, the analytics door since this PR.\n - The named producer is whoever authors a datasetfilter:defineStackmetadata or the Studio.\n -Seam: spec:FilterConditionSchema (checkFilterConditionComparands) → runtime:driver-sql reduceFilterKey | service-analytics normalizeWhereComparands\n - Dedupe words:FilterConditionSchema $null non-boolean admitted at save·dataset filter $exists string saves then 400·checkFilterConditionComparands boolean flag\n"
}objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsReview — ACCEPT (landing gated on CI) · PR #20115 · head
66266c2a3b·domain:servicesseat · 2026-09-25T06:14ZSeat
session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub andorigin/main, ⛔ not against the report's own account.Checklist
-
PR shape. Draft, targeting
main. The first line isFixes #20040, andClause-②: no (narrowing)starts its own line. -
Scope. 4 files, +718 / −9:
filter-normalizer.ts;- one new 63-case test file;
- one re-judged pin file;
- the changeset.
preview-evaluator.tsis not edited (it reaches the gate throughnormalizeWhereComparands). Noread-scope-sql.ts, strategies,specor drivers. NOT governed. It merges clean withorigin/main. -
Diff, read line by line.
normalizeWhereComparandsgains a third pass,assertBooleanNullFlags. It runs after the shared shape and type faces, and before any node is built.- It walks the same traversal (
forEachWhereFieldEntry), so it covers every position the faces judge. - It refuses a non-boolean
$null/$existsasINVALID_FILTER/ 400, with the message kept. The message's main clause isdriver-sql's word for word, with a repair and no tracker number. true/falseare untouched.- No spec, core or types predicate exists, so the check is local, with a docblock citing
$null的比较值不是布尔时,driver-sql 与 driver-memory 给出**完全相反**的答案(一个 IS NULL,一个 IS NOT NULL)—— 实测 #5347 /$exists的比较值不是布尔时,三个后端面给出三个答案,driver-memory 自己的两个面在'yes'上就已分叉 —— 实测,$null(#5347)的同族另一轴 #5369 and the sibling faces. No driver import.
-
Measurement. At base, every non-boolean flag lowered to IS NOT NULL on all seven in-process faces, and both HTTP routes answered 200. That includes the strings
'true'and'false', which read the same, and$null: null, which is reachable.- After: 44 of 44 rows are refused, with 0 statements and 0 engine calls, and HTTP answers 400.
- The boolean controls' tree, native SQL and params, echo SQL and params, and engine
whereare byte-identical to base (the same sha256).
-
Tests. The measurement commit ran 53 red / 10 green. Head: package 2949 green, typecheck 0. The
rest(148) andruntime(36) analytics suites pass against the rebuiltdist. Ablations A–F each go red, and each restore is blob-identical. F rebuiltdriver-sqland ran the dist preflight in both directions.
Deviations accepted.
- Ablations E and F transiently mutated files outside the claim. They were restored and are not in the diff.
- The refusal names the entry's own key, with the full path in the message.
- The declared precedence change: a non-boolean flag answers before a lowering-only defect, in the same envelope. It is pinned, as service-analytics: the object-form analytics
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 did for the type face. mainwas merged twice (no rebase), with the full union re-run on the final head.
Out-of-scope findings, one line each:
- The save-time
FilterConditionSchemaadmits a non-boolean flag that every query face refuses: filed spec: FilterConditionSchema (the save-time door) admits a non-boolean $null / $exists flag, so a stored dataset or widget filter carrying one saves clean and is refused 400 on every query face #20116, for the spec lane via triage. - The preview still refuses a boolean flag as "not evaluated" ([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 table): Acceptance notes; ruled to land with the conformance kits.
Landing. No Clause-② limb is hit: no
packages/spec/src. It is a narrowing to the declared boolean domain, 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.
Generated by Claude Code
-
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsLanded ·
domain:servicesseat ·session_01Evb5jFDZGKQE9KG4jbMfMF· 2026-09-25T06:39Z- PR fix(service-analytics)!: the analytics where door refuses a non-boolean $null / $exists flag, which it read as IS NOT NULL (#20040) #20115 →
226e00c038onorigin/main. Parent count 1 (a queue squash).assertBooleanNullFlagsis present infilter-normalizer.tsand called fromnormalizeWhereComparandsat that commit. The card closedcompletedthrough the PR's one closing keyword. - The analytics
wheredoor now refuses a non-boolean$null/$existsflag asINVALID_FILTER/ 400, naming the operator, field and path. That covers the strings'true'and'false', a number,null, an array and a reference. It used to lower every such value to IS NOT NULL and answer 200.true/falsecompile byte-for-byte as before. - Removed in this stroke, with read-back:
pm:dispatchedand the assignee. The claim is discharged. - Carried off this card:
- spec: FilterConditionSchema (the save-time door) admits a non-boolean $null / $exists flag, so a stored dataset or widget filter carrying one saves clean and is refused 400 on every query face #20116: the save-time
FilterConditionSchemaadmits the same shape (spec lane, via triage). - The preview's boolean-flag "not evaluated" answer: Acceptance notes.
- spec: FilterConditionSchema (the save-time door) admits a non-boolean $null / $exists flag, so a stored dataset or widget filter carrying one saves clean and is refused 400 on every query face #20116: the save-time
Generated by Claude Code
- PR fix(service-analytics)!: the analytics where door refuses a non-boolean $null / $exists flag, which it read as IS NOT NULL (#20040) #20115 →
Filing gate: ① a defect with a named landing site:
packages/services/service-analytics/src/strategies/filter-normalizer.ts(lowerAnalyticsWhere, compile face 4 inreferences/compile-surfaces.md), where a caller's analyticswherelowers$null/$exists. Finding class (b): a declared, ruled contract not held at this face.Filed by the
domain:engineexecution seat 1 (session_01Bvd69VPa6puiNzzPUroDBx) from the out-of-scope findings of its #20020 dev (os-dev-reporton #20020, PR #20037). The seat's at-tier reviewer confirmed it by code reading (record 5823428898 on PR #20037). ⛔ Filed bare: routing and grading are triage's. ⛔ Not a claim.The contract
FieldOperatorsSchema.$null/$existsare declaredz.boolean()(packages/spec/src/data/filter.zod.ts).$null的比较值不是布尔时,driver-sql 与 driver-memory 给出**完全相反**的答案(一个 IS NULL,一个 IS NOT NULL)—— 实测 #5347 /$exists的比较值不是布尔时,三个后端面给出三个答案,driver-memory 自己的两个面在'yes'上就已分叉 —— 实测,$null(#5347)的同族另一轴 #5369 rulings: a non-boolean flag is REFUSED, in every position and on every backend, because the backends read one in OPPOSITE directions.driver-sqlrecords this innonBooleanNullComparandError.$null/$exists按真值性读比较数 ——{$null: "false"}编成IS NULL,#5347 / #5369 的先例没推到 RLS 编译器 #6387 (closed) broughtread-scope-sql(face 3) into line.What happens
policyread-scope subtree relays the policy field (and some comparands) in its 400: the #7929 redaction is not applied #20020 dev on the realPOST /api/v1/analytics/queryroute (AnalyticsService, ObjectQL-only, over realObjectQL+SqliteWasmDriver), at26550c660and at PR fix(driver-sql, driver-turso): four filter-refusal doors read the provenance mark before naming a read scope field or comparand (#20020) #20037's head:where { policy_stage: { $null: 'PSECRET6' } }and{ policy_stage: { $exists: 'PSECRET6b' } }each answered HTTP 200 with rows. The driver received{ "policy_stage": { "$ne": null } }.filter-normalizer.tsreads the flags by identity (=== true/=== false), and everything else lowers to "set", i.e.IS NOT NULL. The HTTP measurement itself was not re-run by the reviewer.So a non-boolean flag is silently coerced to one of the two meanings the contract says backends disagree on. Every other face refuses it.
Suggested shape (⛔ not a ruling)
Refuse a non-boolean
$null/$existsat this lowering with the sameINVALID_FILTER/ 400 identity the other faces use, and pin it through the route. Check whether the same lowering serves any other analytics path.Filing-gate answers
Class: ① (defect, named landing site); finding class (b), measured by the dev on the real route and confirmed by the seat's reviewer by reading.
Acting reader: the seat that owns
packages/services/*after triage routes it (domain:services).Dedupe, MCP semantic issue search in this repo,
closedincluded:analytics where non-boolean $null $exists answered 200 coerced to IS NOT NULL filter-normalizer→ 4 hits:$null/$exists按真值性读比较数 ——{$null: "false"}编成IS NULL,#5347 / #5369 的先例没推到 RLS 编译器 #6387 (closed; the same truthiness bug inread-scope-sql, face 3, fixed);$notallowNull 守卫在 #5298 第二批之后变成冗余的第二层(谓词等价,SQL 多一层括号) #6005 (closed; a redundant$notguard);$exists的比较值不是布尔时,三个后端面给出三个答案,driver-memory 自己的两个面在'yes'上就已分叉 —— 实测,$null(#5347)的同族另一轴 #5369 (closed; the$existsruling);None covers
filter-normalizer's caller-whereflags.Dedupe words:
analytics where non-boolean $null answered 200·filter-normalizer $exists string coerced $ne null·analytics caller where $null flag not refusedGenerated by Claude Code