Skip to content

fix(service-analytics)!: the analytics where door refuses a non-boolean $null / $exists flag, which it read as IS NOT NULL (#20040) - #20115

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20040-analytics-null-flag
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20040-analytics-null-flag

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20040

Clause-②: no (narrowing)

What this changes

The 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.

How:

  • normalizeWhereComparands gains a third pass, assertBooleanNullFlags, after the shared comparand-shape face and the shared comparand-type face.
    • 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.
    • It reports a flag at the path those faces give it (where.acct.stage.$null).
  • 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. …
    • Its main clause is driver-sql's, word for word through "(true or false)".
    • The rest says what this door used to do and how to repair it. It carries no tracker number.
  • Reuse. @objectstack/spec, core and types publish no predicate or sentence for this refusal (searched; none exists).
  • true / false lower exactly as before.
  • The FilterArray spelling needs no pass (measured, below):
    • parseFilterAST writes a hard-coded boolean for is_null / is_not_null, and refuses $null / exists as array operators;
    • isFilterAST refuses an embedded object.

The order chosen, and why

Measured first (recorded on this branch as 12e3081867, test-only, before any source change)

Base origin/main 8d76c2d38c.

  • 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.
  • HTTP legs. Read and measured only, through throwaway harnesses (not committed) over a real AnalyticsService:
    • packages/runtime's dispatcher route POST /api/v1/analytics/query, native and ObjectQL-only;
    • packages/rest's POST /analytics/dataset/query, with the flag in selection.runtimeFilter and in an inline dataset.filter.
{ stage: { FLAG: V } } native execute echo (run) ObjectQL engine path draft preview AnalyticsService.query (native / ObjectQL) HTTP, both routes
$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
the same under $not r2 r2 r2 400, "not evaluated" r2 200, r2
V = { a: 1 } or undefined 400, type face = = = = 400 (the plain object)
$null: true / $exists: false (control) r2 r2 r2, { stage: null } 400, "not evaluated" r2 200, r2
$null: false / $exists: true (control) r1 r3 r1 r3 r1 r3, { stage: { $ne: null } } 400, "not evaluated" r1 r3 200, r1 r3
  • 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.
  • $null: null / $exists: null are reachable, in process and over HTTP (JSON null). Both read as IS NOT NULL and answered 200:
  • 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.
  • The FilterArray spelling carries no flag value.
    • ['stage', 'is_null', 'x'] gives notSet and ['stage', 'isnotnull', 0] gives set; the third member is filler.
    • ['stage', '$null', 'x'], 'null', 'exists' and '$exists' are refused as not a filter.
    • isFilterAST answers false for every array embedding an object node (5 shapes probed).
  • Other analytics paths through the same lowering (the card's check):
    • dataset scope filters and measure filters reach it through the strategies' normalizeAnalyticsFilterTree calls;
    • the preview reaches it through normalizeWhereComparands;
    • the read-scope door does not use it.

After (head 66266c2a3b)

  • 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.

  • 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.

  • Controls, byte for byte. For $null / $exists × true / false, at the top level, under $not, and under $not beside $ne, I captured:

    • the compiled tree;
    • the native SQL and its params;
    • the echo SQL and its params;
    • the engine where.

    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.

  • The read scope keeps its own answer, untouched:

    • on native, the echo and AnalyticsService.query (native): READ_SCOPE_COMPILE_FAILED / 500, withheld, in read-scope-sql's sentence;
    • on the ObjectQL face, driver-sql answers it: INVALID_FILTER / 400, with the policy content withheld.

Tests

New file packages/services/service-analytics/src/__tests__/where-boolean-flag-refusal.test.ts, 63 cases. Every refusal asserts code + status.

  • the door rows: 2 flags × 10 non-boolean values;
  • every position, the nested-relation path, and the received value named in the message;
  • the FilterArray control;
  • existing refusals keeping their order and sentence;
  • the measured precedence against the lowering's own refusals, with boolean controls;
  • every face over a real engine, with statement and engine-call counters;
  • the preview, whose message is byte-equal to the door's;
  • the byte-for-byte controls;
  • the read-scope face on every sub-face, plus compileScopedFilterToSql;
  • stored dataset and measure filters through queryDataset.

Pins re-judged, none deleted.

  • The census was taken before the change, over service-analytics, packages/rest and packages/runtime, plus a repo-wide test scan.
  • 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.
  • 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".
    • It asserted { $null: 'false' } and { $exists: 'yes' } lower to set.
    • Flipped to refusals, with a note. The undefined half is unchanged, and the case title now says what it asserts.

Runs.

  • Test-only commit 12e3081867, before the fix: 53 failed | 10 passed (63).
    • Every door row was red.
    • Green: the four controls, the type-face and shape-face order pins, the FilterArray control and the three read-scope pins.
  • Head 66266c2a3b:
    • pnpm --filter @objectstack/service-analytics suite: 125 files, 2949 tests passed;
    • typecheck: exit 0, and tsc --listFiles includes both touched test files;
    • 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).

Ablations

Each 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.

leg mutation red / green restore
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
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
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
D the gate widened to refuse false 2 red / 61 green: the two false controls == HEAD
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
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

Gates (head 66266c2a3b)

  • 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.
  • 60 of 60 exit 0. Among them:
    • check-adr-0087-registration --base origin/main, with the disposition not-required (no-migration-prescription);
    • check-changeset-no-major, check-empty-changeset and check:nul-bytes;
    • 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.
  • 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/*/*').
  • Reconciliation. --ran: 60 derived, 60 run, 0 NOT MEASURED, 0 UNRUN. Every entry carries its exit code.
  • 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.
  • Lint, narrowed and proven.
    • eslint --no-inline-config --format json over the 3 changed .ts files: 3 files, 0 errors, 0 warnings.
    • isPathIgnored is false for all three. The changeset .md is ignored and has no config, so it is outside the population.
    • 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.

Changeset

.changeset/20040-analytics-null-flag.md: minor with ! and a Clause-②: no (narrowing) line, following the #20035 / #20068 precedents.

Acceptance notes

Out-of-scope findings (reported to the seat, not filed here)

  1. Class (c). The save door admits a non-boolean flag that every query face refuses.

Generated by Claude Code

…cells at the analytics where door, before the fix

Test-only. The new file's header carries the table measured on origin/main
8d76c2d: every lowering face (native execute, the /analytics/sql echo,
the ObjectQL engine path, AnalyticsService.query) read a non-boolean flag
as IS NOT NULL, the draft preview refused every flag as not evaluated, and
both HTTP routes answered 200. Against this commit's source the file runs
53 failed | 10 passed: every door row red, every control and every
unchanged refusal green.

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
…an $null / $exists flag before any lowering

FieldOperatorsSchema declares both flags z.boolean(), and the rulings that
driver-sql and the read-scope compiler enforce refuse a non-boolean one on
every backend, because the backends read it in opposite directions. The
where door read the flag by identity, so every other value lowered to IS
NOT NULL on every face and both HTTP routes served it.

normalizeWhereComparands gains a third pass, assertBooleanNullFlags, after
the shared comparand-shape and comparand-type faces and before any node is
built. It refuses in the door's INVALID_FILTER / 400 envelope, naming the
operator, the field and the path. The draft preview reaches it through the
same gate, so one filter gets one answer. true / false lower exactly as
before.

The one pin that asserted the old identity reading of an accepted
non-boolean flag is re-judged with a note, not deleted.

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Sep 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7b27bd00c7b6397aa248389dfe78e67529773c23 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 919645ed3fc1486b271a70067af4ecbfda829976 — the merge of head 66266c2a3b78f1831821e2feca3fa49194d5fe53 into base 7b27bd00c7b6397aa248389dfe78e67529773c23, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 919645ed3fc1486b271a70067af4ecbfda829976 && git checkout 919645ed3fc1486b271a70067af4ecbfda829976
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7b27bd00c7b6397aa248389dfe78e67529773c23 66266c2a3b78f1831821e2feca3fa49194d5fe53 && git checkout -B drift-repro 7b27bd00c7b6397aa248389dfe78e67529773c23 && git merge --no-ff 66266c2a3b78f1831821e2feca3fa49194d5fe53

node scripts/docs-audit/affected-docs.mjs --json 7b27bd00c7b6397aa248389dfe78e67529773c23

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

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

Labels

documentation Improvements or additions to documentation size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

2 participants