Repository navigation
fix(service-analytics)!: the analytics where door refuses a non-boolean $null / $exists flag, which it read as IS NOT NULL (#20040) - #20115
Conversation
…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>
…alytics-null-flag
…alytics-null-flag
📓 Docs Drift Check6 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
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
Fixes #20040
Clause-②: no (narrowing)
What this changes
The analytics
wheredoor (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.FieldOperatorsSchemadeclares both flagsz.boolean(). The$null的比较值不是布尔时,driver-sql 与 driver-memory 给出**完全相反**的答案(一个 IS NULL,一个 IS NOT NULL)—— 实测 #5347 /$exists的比较值不是布尔时,三个后端面给出三个答案,driver-memory 自己的两个面在'yes'上就已分叉 —— 实测,$null(#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 (read-scope-sql 的$null/$exists按真值性读比较数 ——{$null: "false"}编成IS NULL,#5347 / #5369 的先例没推到 RLS 编译器 #6387,assertBooleanFlagComparands).=== truefor$null,=== falsefor$exists), so every other value lowered toset, IS NOT NULL.How:
normalizeWhereComparandsgains a third pass,assertBooleanNullFlags, after the shared comparand-shape face and the shared comparand-type face.forEachWhereFieldEntry), so it finds a flag in every position they judge:$and/$ormembers,$not, field entries, and a nested relation's entries.where.acct.stage.$null).[analytics] Operator "$null" on field "stage" requires a boolean comparand (true or false). Received a string ("x") at where.stage.$null. …driver-sql's, word for word through "(true or false)".@objectstack/spec,coreandtypespublish no predicate or sentence for this refusal (searched; none exists).$null的比较值不是布尔时,driver-sql 与 driver-memory 给出**完全相反**的答案(一个 IS NULL,一个 IS NOT NULL)—— 实测 #5347 /$exists的比较值不是布尔时,三个后端面给出三个答案,driver-memory 自己的两个面在'yes'上就已分叉 —— 实测,$null(#5347)的同族另一轴 #5369 and the sibling faces.shapePreview(comparand-shape.ts).true/falselower exactly as before.FilterArrayspelling needs no pass (measured, below):parseFilterASTwrites a hard-coded boolean foris_null/is_not_null, and refuses$null/existsas array operators;isFilterASTrefuses an embedded object.The order chosen, and why
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.Before the
undefinedgate (service-analytics 的where门把undefined值的键整个丢掉 —— 单键 where 退化成「无过滤器」,方向是加宽(#6125 五面表漏记的第六、七种读法) #6386) and everything elsebuildNodereaches. Two readers see the flag before the identity read infieldLeaves:$not的语义在 driver-sql 与 driver-memory / formula 之间分叉:NULL 行的去留相反,$not: {}一个是 TRUE 一个是 FALSE #5146$notrewrite, which classifies every field spec throughnullValueSatisfiesOperator/operatorIsNullTotal(the flag readers at the oldfilter-normalizer.ts:1411/:1464);normalizeWhereComparandsreturns and never reachesfieldLeaves.The
undefinedgate skips both flags by name, so the two gates never judge one value.One precedence change, same envelope. A
wherecarrying a non-boolean flag and a defect only the lowering diagnoses is now answered with the flag. Those defects are a mixed$/ non-$wrapper (service-analytics 的where门:字段约束里$算子与非$键混写时,非$兄弟键被静默丢掉(方向是加宽;与值无关,不是 #6386 的undefined) #6444), an operator outside the vocabulary, and a zero-operator constraint. The code and status stayINVALID_FILTER/ 400. service-analytics: the object-form analyticswhereskips the shared comparand-TYPE face, so a plain-object / Map / oversized-bigint comparand is bound as JSON text on the native path while the FilterArray spelling and the engine refuse 400 #20035 took the same precedence for the type face over service-analytics 的where门:字段约束里$算子与非$键混写时,非$兄弟键被静默丢掉(方向是加宽;与值无关,不是 #6386 的undefined) #6444. It is pinned, with boolean controls that keep the old sentences.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
normalizeWhereComparandsand 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.Measured first (recorded on this branch as
12e3081867, test-only, before any source change)Base
origin/main8d76c2d38c.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.AnalyticsService:packages/runtime's dispatcher routePOST /api/v1/analytics/query, native and ObjectQL-only;packages/rest'sPOST /analytics/dataset/query, with the flag inselection.runtimeFilterand in an inlinedataset.filter.{ stage: { FLAG: V } }AnalyticsService.query(native / ObjectQL)$nullor$exists, V ='x','false','true',0,1,null,[true],{ $field: 'id' }, aDate,2n{ stage: { $ne: null } }$not{ a: 1 }orundefined$null: true/$exists: false(control){ stage: null }$null: false/$exists: true(control){ stage: { $ne: null } }$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.$null: null/$exists: nullare reachable, in process and over HTTP (JSONnull). Both read as IS NOT NULL and answered 200:undefinedgate (service-analytics 的where门把undefined值的键整个丢掉 —— 单键 where 退化成「无过滤器」,方向是加宽(#6125 五面表漏记的第六、七种读法) #6386) skips both flags by name;null, which is in its accepted set;$in/$ninmembers,$betweenendpoints and ordering comparands, not the flags.main.FilterConditionSchematypes a field entry asz.unknown(), so the body parse admitted every non-boolean flag on both routes.FilterArrayspelling carries no flag value.['stage', 'is_null', 'x']givesnotSetand['stage', 'isnotnull', 0]givesset; the third member is filler.['stage', '$null', 'x'],'null','exists'and'$exists'are refused as not a filter.isFilterASTanswers false for every array embedding an object node (5 shapes probed).normalizeAnalyticsFilterTreecalls;normalizeWhereComparands;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 0engine.aggregatecalls.HTTP re-measure. Both routes answer 400
INVALID_FILTERfor 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$notbeside$ne, I captured:where.The capture taken on
8d76c2d38cand 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:
AnalyticsService.query(native):READ_SCOPE_COMPILE_FAILED/ 500, withheld, inread-scope-sql's sentence;driver-sqlanswers 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 assertscode+status.FilterArraycontrol;compileScopedFilterToSql;queryDataset.Pins re-judged, none deleted.
service-analytics,packages/restandpackages/runtime, plus a repo-wide test scan.restandruntimehad 0 hits asserting this door's admission.rest-4xx-message-truncation.test.tscopies adriver-sqlmessage and is not this door.filter-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".{ $null: 'false' }and{ $exists: 'yes' }lower toset.undefinedhalf is unchanged, and the case title now says what it asserts.Runs.
12e3081867, before the fix: 53 failed | 10 passed (63).FilterArraycontrol and the three read-scope pins.66266c2a3b:pnpm --filter @objectstack/service-analyticssuite: 125 files, 2949 tests passed;typecheck: exit 0, andtsc --listFilesincludes both touched test files;dist, not a pin:packages/rest's 10 analytics route suites (148 passed) andpackages/runtime's 3 analytics suites (36 passed).Ablations
Each ran through
scripts/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.a7bdec971782== HEAD$existsdropped from the gate's set$existsassertion (21 name it, 6 are mixed); every$null-only case stayed greenwhere-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 spellingfalsefalsecontrolsread-scope-sql.ts'sassertBooleanFlagComparandscall removed (a transient ablation; the file is not in this diff)compileScopedFilterToSql. The ObjectQL-face pin stayed green, as predicted, becausedriver-sqlanswers there403440964f36== HEADdriver-sql's$nullgate removed anddriver-sqlrebuilt;ablation-dist-preflight --absentpassed (the marker is absent from all 6 built files)a89054ee3aab== HEAD; rebuilt, and preflight shows the marker present in 2 built files with the tree cleanGates (head
66266c2a3b)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.check-adr-0087-registration --base origin/main, with the dispositionnot-required (no-migration-prescription);check-changeset-no-major,check-empty-changesetandcheck: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.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/*/*').--ran: 60 derived, 60 run, 0 NOT MEASURED, 0 UNRUN. Every entry carries its exit code.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.eslint --no-inline-config --format jsonover the 3 changed.tsfiles: 3 files, 0 errors, 0 warnings.isPathIgnoredis false for all three. The changeset.mdis ignored and has no config, so it is outside the population.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.Changeset
.changeset/20040-analytics-null-flag.md:minorwith!and aClause-②: no (narrowing)line, following the #20035 / #20068 precedents.not-required (no-migration-prescription), and the gate accepts it.$null的比较值不是布尔时,driver-sql 与 driver-memory 给出**完全相反**的答案(一个 IS NULL,一个 IS NOT NULL)—— 实测 #5347 /$exists的比较值不是布尔时,三个后端面给出三个答案,driver-memory 自己的两个面在'yes'上就已分叉 —— 实测,$null(#5347)的同族另一轴 #5369 refusals indriver-sqland the read-scope-sql 的$null/$exists按真值性读比较数 ——{$null: "false"}编成IS NULL,#5347 / #5369 的先例没推到 RLS 编译器 #6387 refusal were never registered.driver-memory, not a filter. Stored datasets, widget filters and report runtime filters in a deployment were NOT measured.Acceptance notes
$null/$existsat all (the [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), 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.Out-of-scope findings (reported to the seat, not filed here)
66266c2a3b:FilterConditionSchema.safeParse({ stage: { $null: 'x' } })succeeds;DatasetSchema.safeParsewithfilter: { stage: { $null: 'x' } }(also with$exists: 'false'and with$null: null) succeeds;$icontains: ''(finding(spec):ViewFilterRuleSchemaaccepts two shapes every consumer refuses, andObjectGridProps.defaultFiltersisz.unknown()so nothing gates it at all — the protocol half of objectui#9050's ruling C′ #19514).driver-sqlsince$null的比较值不是布尔时,driver-sql 与 driver-memory 给出**完全相反**的答案(一个 IS NULL,一个 IS NOT NULL)—— 实测 #5347, the analytics door since this PR.filter:defineStackmetadata or the Studio.Seam: spec:FilterConditionSchema (checkFilterConditionComparands) → runtime:driver-sql reduceFilterKey | service-analytics normalizeWhereComparandsFilterConditionSchema $null non-boolean admitted at save·dataset filter $exists string saves then 400·checkFilterConditionComparands boolean flagGenerated by Claude Code