Skip to content

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

@objectstack-fleet

Filing gate: ① a defect with a named landing site: packages/services/service-analytics/src/strategies/filter-normalizer.ts (lowerAnalyticsWhere, compile face 4 in references/compile-surfaces.md), where a caller's analytics where lowers $null / $exists. Finding class (b): a declared, ruled contract not held at this face.

Filed by the domain:engine execution seat 1 (session_01Bvd69VPa6puiNzzPUroDBx) from the out-of-scope findings of its #20020 dev (os-dev-report on #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

What happens

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 / $exists at this lowering with the same INVALID_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

Dedupe words: analytics where non-boolean $null answered 200 · filter-normalizer $exists string coerced $ne null · analytics caller where $null flag not refused


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Blocked-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:blocked Blocked-by #20035; rationale: $null / $exists are declared z.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 real POST /api/v1/analytics/query route: HTTP 200, the driver received $ne: null) — a silent coercion of the caller's own filter, not a read-scope widening, hence p2 without security; 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/main 7f1de2eb66 上核对。

    本席核对

    定级说明

    p2:调用方自己写的筛选条件被悄悄换成了另一个意思,答案错误。但只影响调用方自己能看的行,不涉及读范围,所以不加 security。

    解锁后的执行要点

    1. 在这一层拒绝非布尔的 $null / $exists:返回 INVALID_FILTER / 400,与其他面的写法一致(参照 driver-sql 的 nonBooleanNullComparandError)。
    2. 顺带查一下:同一个降级有没有被其他分析路径复用。
    3. 钉子:经真实路由验证,{ $null: 'x' } 和 { $exists: 'x' } 都返回 400;true / false 照常。

    Generated by Claude Code

  2. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

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

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


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1
    Session: session_01Evb5jFDZGKQE9KG4jbMfMF
    Branch: claude/issue-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 / $exists lowering in lowerAnalyticsWhere, and any sibling reader of the flag in the same file); src/preview-evaluator.ts only if the draft preview answers the cell differently; pins asserting today's admission, re-judged with notes; new test file(s) under packages/services/service-analytics/src/; .changeset/20040-*.md. ⛔ Not read-scope-sql.ts or the strategies (#20075 in flight). ⛔ No packages/spec, drivers, rest or runtime source. (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 in filter-normalizer.ts, landed as 7ddf396109 and a8bcce69c8. The in-flight #20075 claims read-scope-sql.ts and the two strategies, which is disjoint from this file. #20098 (objectql-strategy.ts) is queued behind #20075.

  4. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    os-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| $null or $exists, V = 'x', 'false', 'true', 0, 1, null, [true], { $field: 'id' }, a Date, 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 } or undefined | 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 analytics where door (lowerAnalyticsWhere in packages/services/service-analytics/src/strategies/filter-normalizer.ts) now refuses a $null or $exists flag whose value is not a boolean. It refuses before any lowering, in the door's INVALID_FILTER / 400 envelope, with the message kept.\n\n- FieldOperatorsSchema declares both flags z.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-sql refuses it (nonBooleanNullComparandError / nonBooleanExistsComparandError), and so does the read-scope compiler (#6387, assertBooleanFlagComparands).\n- This door read the flag by identity (=== true for $null, === false for $exists), so every other value lowered to set, IS NOT NULL.\n\nHow:\n\n- normalizeWhereComparands gains 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 / $or members, $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 is driver-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, core and types publish 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 own shapePreview (comparand-shape.ts).\n- true / false lower exactly as before.\n- The FilterArray spelling needs no pass (measured, below):\n - parseFilterAST writes a hard-coded boolean for is_null / is_not_null, and refuses $null / exists as array operators;\n - isFilterAST refuses 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, a Map, a bigint beyond 2^53. That is the sentence the FilterArray spelling and the engine seam give. A shape or type defect elsewhere in the same where is still answered first. Ablation C pins this order.\n- Before the undefined gate (#6386) and everything else buildNode reaches. Two readers see the flag before the identity read in fieldLeaves:\n - the #5146 $not rewrite, which classifies every field spec through nullValueSatisfiesOperator / operatorIsNullTotal (the flag readers at the old filter-normalizer.ts:1411 / :1464);\n - the draft preview, which evaluates what normalizeWhereComparands returns and never reaches fieldLeaves.\n\n The undefined gate skips both flags by name, so the two gates never judge one value.\n- One precedence change, same envelope. A where carrying 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 stay INVALID_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 through normalizeWhereComparands and now refuses this cell in the door's words, byte for byte. preview-evaluator.ts is not edited. A boolean flag still gets the preview's own "not evaluated" refusal.\n\n## Measured first (recorded on this branch as 12e3081867, test-only, before any source change)\n\nBase origin/main 8d76c2d38c.\n\n- Data. A real sql.js engine (driver-sqlite-wasm) over rows r1 'won', r2 NULL and r3 'lost' (field stage), with a real ObjectQL behind 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 real AnalyticsService:\n - packages/runtime's dispatcher route POST /api/v1/analytics/query, native and ObjectQL-only;\n - packages/rest's POST /analytics/dataset/query, with the flag in selection.runtimeFilter and in an inline dataset.filter.\n\n| { stage: { FLAG: V } } | native execute | echo (run) | ObjectQL engine path | draft preview | AnalyticsService.query (native / ObjectQL) | HTTP, both routes |\n|:--|:--|:--|:--|:--|:--|:--|\n| $null or $exists, V = 'x', 'false', 'true', 0, 1, null, [true], { $field: 'id' }, a Date, 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 } or undefined | 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 $not beside $ne; every coerced cell answered the same in each. { $not: { stage: { $null: 'true' } } } served the NULL row the author excluded.\n- $null: null / $exists: null are reachable, in process and over HTTP (JSON null). Both read as IS NOT NULL and answered 200:\n - the undefined gate (#6386) skips both flags by name;\n - the type face accepts null, which is in its accepted set;\n - the shape face's null carve-outs cover $in / $nin members, $between endpoints and ordering comparands, not the flags.\n- The card's HTTP 200 is confirmed on main. FilterConditionSchema types a field entry as z.unknown(), so the body parse admitted every non-boolean flag on both routes.\n- The FilterArray spelling carries no flag value.\n - ['stage', 'is_null', 'x'] gives notSet and ['stage', 'isnotnull', 0] gives set; the third member is filler.\n - ['stage', '$null', 'x'], 'null', 'exists' and '$exists' are refused as not a filter.\n - isFilterAST answers 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' normalizeAnalyticsFilterTree calls;\n - the preview reaches it through normalizeWhereComparands;\n - the read-scope door does not use it.\n\n## After (head 66266c2a3b)\n\n- In process. Every non-boolean row above is refused INVALID_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 0 engine.aggregate calls.\n- HTTP re-measure. Both routes answer 400 INVALID_FILTER for 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 $not beside $ne, I captured:\n - the compiled tree;\n - the native SQL and its params;\n - the echo SQL and its params;\n - the engine where.\n\n The capture taken on 8d76c2d38c and 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 and AnalyticsService.query (native): READ_SCOPE_COMPILE_FAILED / 500, withheld, in read-scope-sql's sentence;\n - on the ObjectQL face, driver-sql answers it: INVALID_FILTER / 400, with the policy content withheld.\n\n## Tests\n\nNew file packages/services/service-analytics/src/__tests__/where-boolean-flag-refusal.test.ts, 63 cases. Every refusal asserts code + 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- the FilterArray control;\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, plus compileScopedFilterToSql;\n- stored dataset and measure filters through queryDataset.\n\nPins re-judged, none deleted.\n\n- The census was taken before the change, over service-analytics, packages/rest and packages/runtime, plus a repo-wide test scan.\n- rest and runtime had 0 hits asserting this door's admission. rest-4xx-message-truncation.test.ts copies a driver-sql message and is not this door.\n- One pin in filter-normalizer-undefined-comparand.test.ts asserted 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 to set.\n - Flipped to refusals, with a note. The undefined half is unchanged, and the case title now says what it asserts.\n\nRuns.\n\n- Test-only commit 12e3081867, 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, the FilterArray control and the three read-scope pins.\n- Head 66266c2a3b:\n - pnpm --filter @objectstack/service-analytics suite: 125 files, 2949 tests passed;\n - typecheck: exit 0, and tsc --listFiles includes both touched test files;\n - consumer sanity run against the rebuilt dist, not a pin: packages/rest's 10 analytics route suites (148 passed) and packages/runtime's 3 analytics suites (36 passed).\n\n### Ablations\n\nEach ran through scripts/ablation-replace.mjs from committed state, with the anchor hit ×1 and the restore proven (blob == HEAD, git diff HEAD empty). service-analytics tests import the subject by relative src paths, so no dist is 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 | blob a7bdec971782 == HEAD |\n| B | $exists dropped from the gate's set | 27 red / 70 green. Every red case carries an $exists assertion (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 and where-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 refuse false | 2 red / 61 green: the two false controls | == HEAD |\n| E | read-scope-sql.ts's assertBooleanFlagComparands call removed (a transient ablation; the file is not in this diff) | 2 red / 61 green: the read-scope pin and compileScopedFilterToSql. The ObjectQL-face pin stayed green, as predicted, because driver-sql answers there | blob 403440964f36 == HEAD |\n| F | driver-sql's $null gate removed and driver-sql rebuilt; ablation-dist-preflight --absent passed (the marker is absent from all 6 built files) | 1 red / 62 green: the ObjectQL-face read-scope pin | blob a89054ee3aab == HEAD; rebuilt, and preflight shows the marker present in 2 built files with the tree clean |\n\n## Gates (head 66266c2a3b)\n\n- Derivation. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 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-debt and check:where-matcher.\n- 60 of 60 exit 0. Among them:\n - check-adr-0087-registration --base origin/main, with the disposition not-required (no-migration-prescription);\n - check-changeset-no-major, check-empty-changeset and check: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-loads and check:type-check-debt read 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. main moved twice while this ran: aa04ea2964 (objectql) and fa00ebf447 (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 json over the 3 changed .ts files: 3 files, 0 errors, 0 warnings.\n - isPathIgnored is false for all three. The changeset .md is ignored and has no config, so it is outside the population.\n - eslint.config.mjs never enables type-aware linting (no parserOptions.project and no projectService, 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: minor with ! and a Clause-②: 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 declares not-required (no-migration-prescription), and the gate accepts it.\n - No ledger entry names the flags' boolean domain: the #5347 / #5369 refusals in driver-sql and 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 in driver-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 / $exists at 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 to true, 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 at 66266c2a3b:\n - FilterConditionSchema.safeParse({ stage: { $null: 'x' } }) succeeds;\n - DatasetSchema.safeParse with filter: { 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-sql since #5347, the analytics door since this PR.\n - The named producer is whoever authors a dataset filter: defineStack metadata 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"
    }

  5. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Review — ACCEPT (landing gated on CI) · PR #20115 · head 66266c2a3b · domain:services seat · 2026-09-25T06:14Z

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

    Checklist

    • PR shape. Draft, targeting main. The first line is Fixes #20040, and Clause-②: 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.ts is not edited (it reaches the gate through normalizeWhereComparands). No read-scope-sql.ts, strategies, spec or drivers. NOT governed. It merges clean with origin/main.

    • Diff, read line by line.

    • 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 where are 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) and runtime (36) analytics suites pass against the rebuilt dist. Ablations A–F each go red, and each restore is blob-identical. F rebuilt driver-sql and ran the dist preflight in both directions.

    Deviations accepted.

    Out-of-scope findings, one line each:

    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

  6. objectstack-fleet commented on Sep 25, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat · session_01Evb5jFDZGKQE9KG4jbMfMF · 2026-09-25T06:39Z


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions