Repository navigation
fix(service-analytics)!: the analytics where door runs the shared comparand-TYPE face on the object spelling (#20035) - #20058
Conversation
…ement before the fix Measurement only; no source change. Taken on this branch at its base (origin/main 246314d) before any fix, with a throwaway harness that is not committed: a real sql.js engine (driver-sqlite-wasm) over rows d1 amt 1 'won', d2 amt 5 'lost', d3 amt 10 'open', d4 NULL NULL, d5 amt 3 ''. Faces: normalizeAnalyticsFilterTree (tree); NativeSQLStrategy.execute (native, the statement that runs); ObjectQLStrategy.generateSql then run (echo, /analytics/sql); ObjectQLStrategy.execute over a REAL ObjectQL engine.aggregate (engine path); engine.find on the same object where (engine seam); evaluateAnalyticsQueryOverRows (preview). The FilterArray spelling was run through tree, native and engine.find. Shared comparand-TYPE face (normalizeFilterComparandTypes, #7872) ----------------------------------------------------------------- cell | object spelling | FilterArray spelling | engine seam (object) 1. plain object $ne {stage:{$ne:{a:1}}} | native binds JSON text '{"a":1}': EVERY row d1..d5; echo DATABASE_ERROR/500 (raw object bound); engine path 400 (type face, on the laundered $and/$or/$ne); preview EVERY row | 400 type face at where.stage.$ne | 400 2. plain object $gt {amt:{$gt:{a:1}}} | native JSON text: no row; echo 500; engine path 400; preview no row | 400 | 400 3. plain object $eq {stage:{$eq:{a:1}}} | native JSON text: no row; echo 500; the engine path receives {stage:{a:1}} (implicit) and driver-sql refuses in its own words | no $eq spelling: '=' lowers to {stage:{a:1}}, a nested relation (stage.a = 1; native 500) | 400 4. plain-object $between endpoint [{a:1},5] | native `amt >= '{"a":1}' AND amt <= 5`: no row; echo 500; engine path 400 at $gte (an operator the author never wrote); preview no row | 400 at where.amt.$between[0] | 400 5. plain object $in member | refused 400, #5234 wording ("cannot be bound as a SQL parameter") | 400 type face | 400 6. plain object $contains | refused 400, #5234 wording ("TEXT of a pattern") | 400 type face | 400 7. non-string reference {$gt:{$field:5}} | native JSON text: no row; echo 500; engine path 400 | 400 type face (a plain object) | 400 8. binary (Uint8Array [1,2]) $eq | native binds JSON TEXT '{"0":1,"1":2}', not a blob: no row; echo binds the raw Uint8Array: no row; engine path 400; preview no row | (no $eq spelling) | 400 9. binary $ne | native, echo and preview: EVERY row; engine path 400 | 400 | 400 10. binary $in member | native JSON text: no row; echo raw: no row; engine path 400; preview no row | 400 | 400 11. binary implicit {stage: Uint8Array} | compiled as a NESTED RELATION `stage.0 = 1 AND stage.1 = 2`: native DATABASE_ERROR/500; echo and engine path INVALID_FIELD/400 (cross-object "stage.0"); preview refuses operator "0" | 400 type face at where.stage | 400 12. Map $eq | native binds '{}': no row; echo 500; engine path 400; preview no row | (no $eq spelling) | 400 13. Map implicit {stage: Map} | refused 400 as #5240's zero-operator wrapper on native/echo/engine path; preview EVERY row | 400 type face (a Map instance) | 400 14. bigint within 2^53 $gt 2n | native/echo/engine path d2,d3,d5 (bigint bound as-is); preview d2,d5 (d3 lost: the preview orders a bigint as TEXT, '10' < '2') | narrowed to 2: d2,d3,d5 | d2,d3,d5 15. bigint within 2^53 implicit 5n / $in [5n,10n] | same rows as narrowed on every face (d2 / d2,d3), kept as bigint | narrowed | same rows 16. bigint beyond 2^53 $gt 2n**60n | native/echo bind it: no row; engine path 400; preview d2,d5 | 400 | 400 17. bigint beyond 2^53 $in member | native/echo: no row; engine path 400; preview no row | 400 | 400 18. undefined implicit / $gt / $in member | refused 400 on native, echo and engine path, #6386 wording ('[analytics] comparand at "amt".$gt is undefined'); preview NO ROW, no refusal | 400, the type face's undefined sentence | 400 19. {$null: undefined} / {$null: {a:1}} | lowered to `set` (IS NOT NULL): d1,d2,d3,d5 on native, echo and engine path (which receives $ne: null); preview refuses $null as an unevaluable operator | no FilterArray spelling | 400 type face 20. nested relation {acct:{amt:{$gt:{a:1}}}} | tree `acct.amt gt [{a:1}]`; native no row; echo 500; engine path 400 | dotted ['acct.amt','>',{a:1}] 400 at where.acct.amt.$gt | 400 (driver-sql) 21. under $or {$or:[{id:'d3'},{stage:{$ne:{a:1}}}]} | native EVERY row; echo 500; preview EVERY row | 400 at where.$or[1].stage.$ne | 400 CONTROLS | {amt:{$gt:2}} d2,d3,d5 and {stage:null} d4 on every face and both spellings. Governing record: the #7872 ruling (2026-08-12), 「refuses everything else loudly at the compile face」. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…parand-TYPE face on the object spelling lowerAnalyticsWhere now hands every field entry of an object-form where, nested-relation entries included, to normalizeFilterComparandTypes after the comparand-shape face and before any node is built, the order parseFilterAST and the engine seam use, and lowers the face's RETURN value (a bigint within 2^53 narrowed to its number, copy-on-write). The draft preview calls the same gate and evaluates the narrowed condition. WIP: pins not yet re-judged. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…d-TYPE face moves Each keeps its verdict or flips with a note quoting the #7872 ruling; none is deleted. Predicted 39 red in 9 files before wiring the gate; measured 39 red in the same 9 files, the identical set. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
… the where door Byte identity (object = FilterArray = face) for every moved cell, the order against the shape face and the door's own gates, the positions the face leaves to the door, the copy-on-write bigint narrowing, the four faces over a real ObjectQL engine with zero statements and zero aggregates on refusal, and the stored-dataset service doors. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…door's docblocks The module header gains the #20035 section; the #6386, #5234 and toSqlBindValue notes say which positions the type face now answers first and which the door's own gates keep. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…and-TYPE face Clause-②: no (narrowing). ADR-0087: not-required (no-migration-prescription); the #7872 transition is not on the ledger. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check10 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 8de57ae03502aba21a7257a595aa3cd0120b006d && git checkout 8de57ae03502aba21a7257a595aa3cd0120b006d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 cf1563d6f80d26b97a5bb63040ceed9984877e2a && git checkout -B drift-repro b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 && git merge --no-ff cf1563d6f80d26b97a5bb63040ceed9984877e2a
node scripts/docs-audit/affected-docs.mjs --json b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 |
Resolves the two test conflicts with #20046 (#20018) so both sets of re-judgements hold: the comparand matrix keeps #20018's read-scope cells and this branch's where-door cells, each named with the change that moved it; the non-string $field case holds on both doors, each in its own envelope. Sentences either PR made false on the merged tree are scoped: the #5234 one-sentence note, the binary predicate note, the comparand-shape.ts binary-extra docblock (#20018's "on the where door, that is"), and this branch's changeset and normalizer lines on the read-scope binary admission. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
|
CI note ·
Generated by Claude Code |
…ces to the type face Docblocks only. The undefined table's where row now names the shared comparand-TYPE face as the first refusal (same verdict and envelope), with #6386's gate kept for the two positions the face steps around; the header's envelope clause, the non-string $field note, the two shared refusal sentences and the $between endpoint notes are scoped to the door each is true of. The "(or a binary value)" parenthetical in unbindableListMemberMessage's text is flagged in its docblock, not changed (no code change in this round). Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…offers a binary value as a repair Neither analytics door accepts a binary comparand any more (#20018 at the read scope, #20035 at the where door), so unbindableListMemberMessage names the accepted set and nothing else. isBindableComparand's binary arm (the driver-sql mirror) and driver-sql's own copy of the sentence are unchanged. The #8186 pin is re-judged; the changeset gains one line. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20035
Clause-②: no (narrowing)
What this changes
The analytics
wheredoor (lowerAnalyticsWhereinpackages/services/service-analytics/src/strategies/filter-normalizer.ts) now runs the shared comparand-TYPE face,normalizeFilterComparandTypes(@objectstack/spec/data), on the object spelling. It runs after the comparand-shape face and before any node is built, the orderparseFilterASTand the engine seam use. The condition the door lowers is the face's RETURN value, so a bigint within 2^53 is narrowed to its number, copy-on-write, as the engine does it.The governing record is the #7872 ruling (2026-08-12): the accepted comparand types are
string | number | bigint | boolean | null | Date, and the face 「refuses everything else loudly at the compile face」. TheFilterArrayspelling of this door (insideparseFilterAST) and the ObjectQL engine seam already ran it. The object spelling did not.How:
normalizeWhereComparands(new, exported) is the door's comparand gate: the shape passes (unchanged, now module-private asassertWhereComparandShapes), then the type pass over the whole condition.FilterArrayspelling's bytes.{ acct: { amt: … } }is judged atwhere.acct.amt, the path the dotted spelling gets.forEachWhereFieldEntry, now a copy-on-writemapWhereFieldEntries) descends a nested relation only when it is a PLAIN object (prototypeObject.prototypeornull). That is the type face's own structure test. Before, aUint8Array,Mapor class instance in the implicit slot was descended as a relation:{ stage: Uint8Array }compiled tostage.0 = 1 AND stage.1 = 2. Now it is visited as the comparand it is.preview-evaluator.ts) calls the same gate and evaluates the narrowed condition it returns. This closes the card's class-a leg: anundefinedcomparand is refused instead of answering no row.Measured first (recorded on this branch as
4e1cd13aac, before any source change)Base
origin/main246314dffe. The harness was a real sql.js engine (driver-sqlite-wasm) over rows d1 (amt 1, 'won'), d2 (5, 'lost'), d3 (10, 'open'), d4 (NULL, NULL) and d5 (3, ''). The faces were: the native SQL execute; the/analytics/sqlecho; the ObjectQL engine path over a realObjectQLengine.aggregate;engine.findon the same objectwhere(the engine seam); and the draft preview.FilterArrayspelling$ne{stage:{$ne:{a:1}}}'{"a":1}': EVERY row; echoDATABASE_ERROR/ 500; engine path 400; preview EVERY rowwhere.stage.$ne$gt$eq{stage:{a:1}}and driver-sql refused it in its own words$eqspelling (=lowers to a nested relation)$betweenendpointamtagainst the JSON text'{"a":1}': no row; echo 500; the engine path refused it as a$gtethe author never wrotewhere.amt.$between[0]$inmember /$contains{ $field: 5 }under$gt(not a reference)$eq/$inmember'{"0":1,"1":2}', not a blob: no row; echo bound the raw buffer: no row; engine path 400$ne{stage: Uint8Array}DATABASE_ERROR/ 500; echo and engine pathINVALID_FIELD/ 400; preview refused operator "0"where.stageMap$eq'{}': no row; echo 500; engine path 400$eqspellingMapimplicit{amt:{$gt:2n}}5n/$in [5n,10n]$gt/$inmemberundefined, implicit /$gt/$inmember[analytics] comparand at "amt".$gt is undefined); the preview answered NO rowundefinedsentence{$null: undefined}/{$null: {a:1}}set(IS NOT NULL): d1, d2, d3, d5 on native, echo and engine path; the preview refused$nullas unevaluable{acct:{amt:{$gt:{a:1}}}}['acct.amt','>',{a:1}]400$or{$or:[{id:'d3'},{stage:{$ne:{a:1}}}]}where.$or[1].stage.$ne{amt:{$gt:2}},{stage:null}After the change, every refusal row is refused on all four faces, before any statement or
engine.aggregatecall. The object message equals theFilterArraymessage and the face's own message, byte for byte. The bigint rows serve the same rows on all four faces, the preview included, and no face binds or receives a bigint.Binary: reconciled, not kept as a declared local extra
The type face's docblock lets a door keep "its recorded driver-local extras — binary bindables … declared at the use site", and #8186 asked for an explicit keep-or-reconcile call. The call is reconcile. The evidence:
isBindableComparand(comparand-shape.ts) admits binary, but the native path binds every object throughtoSqlBindValue, which JSON-stringifies it. Measured:{stage:{$in:[Uint8Array]}}bound'{"0":1,"1":2}', a value no blob column holds.$nethen served every row, and the implicit spelling compiled to a dotted member no object has. The engine path and theFilterArrayspelling refused binary outright.packages/**andexamples/**that mention analytics found no binary value built into awhere. The six files that mention a binary type at all are the client's stream and upload code,rest-server.ts's xlsx streaming, the two drivers' own extras, and this package's two filter files. Positive control: 21 of the 258 carry awhere:object literal, which the scan read.read-scope-sql.ts). Since service-analytics: the NativeSQL read-scope compiler and the/analytics/sqlecho compile two scope shapes the shared comparand faces refuse (plain-object comparand under$eq, null member in$in): one scope, two answers across faces #20018 landed (PR fix(service-analytics)!: the NativeSQL execute face and the /analytics/sql echo refuse a read scope the shared comparand faces refuse #20046), that door also refuses a binary comparand, with the same face after its own gates, so a binary is now refused at both analytics doors. TheisBindableComparandpredicate still admits it, and the#8186predicate-level pins stay green.Pins re-judged: predicted 39 red in 9 files, measured 39 red in the same 9 files
Before wiring the gate I read every candidate pin and predicted the red set. I then wired the gate and ran the package suite (
86907678c0):Tests 39 failed | 2617 passed (2656), the identical set. Each pin keeps its verdict, or flips with a note quoting the #7872 ruling. ⛔ None is deleted.filter-normalizer-undefined-comparand.test.ts(21). service-analytics 的where门把undefined值的键整个丢掉 —— 单键 where 退化成「无过滤器」,方向是加宽(#6125 五面表漏记的第六、七种读法) #6386's twelve positions, its five$notrewrite paths, "says ONE thing" and "names the repairs" keep the refusal and now read in the face's sentence and path (where.d.$gt). The face runs BEFORE the$not的语义在 driver-sql 与 driver-memory / formula 之间分叉:NULL 行的去留相反,$not: {}一个是 TRUE 一个是 FALSE #5146 rewrite, so the rewrite block now pins that the author's own$notis judged.{$null: undefined}/{$exists: undefined}flip fromsetto refused, because the face judges those comparands as literals. The boolean-domain question ($null的比较值不是布尔时,driver-sql 与 driver-memory 给出**完全相反**的答案(一个 IS NULL,一个 IS NOT NULL)—— 实测 #5347 /$exists的比较值不是布尔时,三个后端面给出三个答案,driver-memory 自己的两个面在'yes'上就已分叉 —— 实测,$null(#5347)的同族另一轴 #5369 / read-scope-sql 的$null/$exists按真值性读比较数 ——{$null: "false"}编成IS NULL,#5347 / #5369 的先例没推到 RLS 编译器 #6387) stays untouched for accepted values:{$null: 'false'}still lowers as before, and a pin now says so.comparand-shape-refusal.test.ts(8). driver-sql:两类无意义比较对象仍编译成「静默空谓词」——$in/$nin的非$field对象成员,与 LIKE 族的对象比较值(String 成[object Object]) #5234's$in/$ninobject-member and LIKE-family object sentences now read in the face's sentence. The{ $eq: {…} }account driver-sql:两类无意义比较对象仍编译成「静默空谓词」——$in/$nin的非$field对象成员,与 LIKE 族的对象比较值(String 成[object Object]) #5234 "left open" flips to refused,{ $eq: { $field: 5 } }included.cross-field-reference-refusal.test.ts(3). A non-string$fieldand a plain object under$eqflip to refused; the routing detector half is unchanged. [finding]$icontainsis absent from analyticsTEXT_PATTERN_OPERATORS, so the #5234 comparand fence never covered it on thewheredoor — one operator, two answers inside one package #7693's$icontains {foo: 1}keeps its refusal and its sibling-equality check, in the face's words.comparand-door-single-source.test.ts(2). The [finding] service-analytics carries its own copies of the comparand-type allow-list the #7872 door now single-sources — reconcile membership and message wording to the door #8186 matrix:binarywhereIn/whereEqandplain objectwhereEqmove from accept to refused. The predicate and read-scope cells are unchanged.filter-normalizer-mixed-wrapper.test.ts(1). service-analytics 的where门:字段约束里$算子与非$键混写时,非$兄弟键被静默丢掉(方向是加宽;与值无关,不是 #6386 的undefined) #6444's order:{d:{$eq: undefined, nested:'x'}}is still diagnosed as the comparand first, now by the face.filter-value-type-fidelity.test.ts,filter-refusal-envelope.test.ts,filter-normalizer-not-null-safe.test.ts(1 each). The service-analytics 的where门把undefined值的键整个丢掉 —— 单键 where 退化成「无过滤器」,方向是加宽(#6125 五面表漏记的第六、七种读法) #6386 wording only.where-face-arms-refusal.test.ts(1). The service-analytics: object-form analyticswhereskips the shared comparand-shape face's other arms ($innull member,$gt: null, null/blank$betweenbound, scalar$in) that the FilterArray spelling refuses 400 #20010 CONTROL that pinned "the TYPE face is not run here" now pins the type face's sentence.Census of consumer pins outside the package. I searched all 3904 tracked test files under
packages/**andexamples/**(outside this package) for #6386's sentence, #5234's two sentences, #6444's wording and the face's sentence. The hits were driver-sql's and driver-turso's own refusals and spec's own tests, none through this door. A second pass covered the 127 test files that reach analytics (@objectstack/rest15,runtime17,qa/dogfood11 and the rest). It looked for anundefined, plain-object, binary,Mapor bigint comparand in awhere, and found none. So there are zero hits and no edit outside the package.@objectstack/restran in full anyway (below).Compile surfaces
lowerAnalyticsWhere/normalizeAnalyticsFilterTree: callerwhere, dataset scopefilter, measurefilter; native execute,/analytics/sqlecho, ObjectQL engine pathengine.aggregate; a bigint within 2^53 is narrowed. Pinned per face over a real engine.evaluateAnalyticsQueryOverRows(draft preview)normalizeFilterComparandTypes(the shared face)parseFilterAST(this door'sFilterArrayspelling)lowerWhereFilterArray)engine.find.compileScopedFilterToSql(service-analytics read scope)read-scope-sql.ts. Not touched.parseFilterASTor the engine seam, which run the face. Not re-measured here.applyHaving/matchesHaving)Tests and evidence (head
f4ba18ba9a)src/__tests__/where-type-face-refusal.test.ts, 106 tests. Every refusal assertscode+status+ the face's opening sentence.FilterArray=normalizeFilterComparandTypes. Another 9 object-only cells ($eq, the$null/$existsflags,$not) are held to the face. The nested relation is held to the dotted spelling.parseFilterAST's order); the [finding] service-analytics' filter normalizer reads an implicit-equality ARRAY asIN, while ruling 乙 refuses the same shape at the shared face "for every driver at once" #19888 equality list before either; the type face before service-analytics 的where门:字段约束里$算子与非$键混写时,非$兄弟键被静默丢掉(方向是加宽;与值无关,不是 #6386 的undefined) #6444's mixed wrapper and the unsupported-operator refusal.undefinedinside an array comparand or under an unknown operator (service-analytics 的where门把undefined值的键整个丢掉 —— 单键 where 退化成「无过滤器」,方向是加宽(#6125 五面表漏记的第六、七种读法) #6386), and an array or{ $field }member / LIKE comparand (driver-sql:两类无意义比较对象仍编译成「静默空谓词」——$in/$nin的非$field对象成员,与 LIKE 族的对象比较值(String 成[object Object]) #5234 / [spec]service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces #7598).FilterArrayspelling compiles. The caller's condition is never edited, and the same reference comes back when nothing narrowed. CONTROL: every accepted comparand compiles as before.ObjectQLengine on sql.js, with 14 refused cells × 4 faces. Each asserts 0 statements and 0engine.aggregatecalls. CONTROLS: scalars andnullserve the same rows on every face. A bigint within 2^53 serves its number's rows on every face, and no face binds or receives a bigint.f4ba18ba9a(pnpm --filter @objectstack/service-analytics test && … typecheck, under the verify lock):Test Files 120 passed (120),Tests 2762 passed (2762),tsc --noEmitexit 0.tsc --listFilesincludes all 12 touched TS files. Base: 119 files / 2656 tests.@objectstack/restfull suite (its closure built, service-analyticsdist/carryingnormalizeWhereComparands):Test Files 194 passed (194),Tests 3265 passed | 1 skipped (3266).scripts/ablation-replace.mjs: anchor x1 to x0, markergrep -c1, blob changed. The restore was proven as blob == HEAD withgit diff HEADempty, and each wrapper carried its owntraprestore. The tests import the source relatively, so no build is on the path. Every count was predicted before the run:return normalizeWhereComparandTypes(node, path);becamereturn node;). Predicted 138; measuredTests 138 failed | 2624 passed (2762): 99 in the new file and the 39 re-judged pins. Samples:native execute: expected a refusal, got rows: expected [ 'd1', 'd2', 'd3', 'd4', 'd5' ] to be undefined,expected 'accept' to be 'INVALID_FILTER/400',native execute: a bigint was bound: expected true to be false.query.whereinstead of the gate's return. Predicted 1; measured1 failed | 2761 passed:draft preview: $gt 2n: expected [ 'd2', 'd5' ] to deeply equal [ 'd2', 'd3', 'd5' ].3 failed | 2759 passed: the tree-narrowing, copy-on-write and bound-bigint cases.12 failed | 2750 passed: the binary /Map/ class-instance implicit cells. Samples:expected 'DATABASE_ERROR' to be 'INVALID_FILTER',expected '[analytics] "stage" carries a field c…' to be 'Filter comparand at where.stage is a …'.dispatch-gates --repo objectstack-ai/objectstack --commandsatf4ba18ba9aderived 60 families. The union run was 61: those 60 pluscheck:dispatcher-error-vocabularyfrom the dispatch list, which exited 0. 59 exited 0. NOT MEASURED (2):check:dual-build-cjs-loadsandcheck:type-check-debtexited 3 (PREREQUISITE NOT MET: they need the whole workspace built, which CI does).--ran:60 derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3), 0 UNRUN. The passes includecheck-adr-0087-registration --base origin/main(it accepted the changeset's disposition),check-changeset-no-major,check:changeset-gate-self-tests,check:nul-bytes,check:where-matcher,check:test-source-alias,check:cross-package-test-inputsandcheck:issue-citations.check-issue-citationsalso ran in its board-probing mode withGITHUB_TOKEN: exit 0, 43 citations resolve.eslint --no-inline-config --format jsonover the 12 touched TS files: 12 files, 0 errors, 0 warnings.eslint --print-configresolves a config for each, which is the population read from eslint's own config.eslint.config.mjsnever enables type-aware linting (its note at line 327), so this diff cannot move the verdict on an untouched file.Changeset and the ADR-0087 disposition
.changeset/20035-analytics-where-type-face.md:@objectstack/service-analyticsminor, a!headline,Clause-②: no (narrowing), and a BREAKING paragraph with a before / now table for every accept-cell that now refuses.The marker is
not-required (no-migration-prescription), and it is the honest one. #7872's transition is not on the ledger. No entry inpackages/spec/src/migrations/registry.tsnames the type face or its accepted set, and #7872's own changeset declared no breaking change. Soalready-registeredwould name an id that does not cover this change, which ADR-0087's own addendum calls out.registeredneeds a ledger entry inpackages/spec, which this card excludes. And no ledger entry could carry this change: a stored plain-object comparand has no accepted comparand to be rewritten to, and the other refused values cannot be stored as JSON at all. The table records each cell's verdict before and now and prescribes no rewrite.Deviations from the dispatch, stated
FROM/TOlabel as a migration prescription, which would refuse the only honest disposition above. The table is the transition in substance (the verdict each accept-cell had and has), and its columns say so. The seat may prefer registering a ledger entry in apackages/specfollow-up instead; that is out of this card's surface.{ stage: Uint8Array }and{ stage: new Map() }never reach the type face. Ablation D pins it.assertWhereComparandShapesis now module-private. Its only other caller, the preview, now callsnormalizeWhereComparands. The package entry never re-exported it.origin/mainmerged in (round 2), as a merge commit (0164eba2b7), after fix(service-analytics)!: the NativeSQL execute face and the /analytics/sql echo refuse a read scope the shared comparand faces refuse #20046 (service-analytics: the NativeSQL read-scope compiler and the/analytics/sqlecho compile two scope shapes the shared comparand faces refuse (plain-object comparand under$eq, null member in$in): one scope, two answers across faces #20018) landed as980bc05e5b. Two test files conflicted; both sets of re-judgements hold on the merged tree (see "Round 2" below).origin/mainhas since moved one commit, to3557f85fa5(a driver-turso README). It touches none of these files, so it was not merged again.Round 2: merged with
origin/mainafter #20046 (head0164eba2b7)Conflict 1,
comparand-door-single-source.test.ts. The matrix keeps both sets of cells:/analytics/sqlecho compile two scope shapes the shared comparand faces refuse (plain-object comparand under$eq, null member in$in): one scope, two answers across faces #20018's scope cells:nullscopeIn, and binary and plain objectscopeIn/scopeEq, allREAD_SCOPE_COMPILE_FAILED/500;wherecells: binarywhereIn/whereEqand plain objectwhereEq, allINVALID_FILTER/400.Each moved cell's comment names the change that moved it. Every other cell stays
accept, and fix(service-analytics)!: the NativeSQL execute face and the /analytics/sql echo refuse a read scope the shared comparand faces refuse #20046's per-cellmovedmap for the six accepted types is kept as is. The header's binary bullet now says the extra is reachable at neither door. fix(service-analytics)!: the NativeSQL execute face and the /analytics/sql echo refuse a read scope the shared comparand faces refuse #20046's "thewheredoor keeps the extra" is gone, because this PR makes it false.Conflict 2,
cross-field-reference-refusal.test.ts. The non-string$fieldcase now holds on both doors, each in its own envelope. Thewheredoor refuses it as a plain object atwhere.amount.$gtwithINVALID_FILTER/ 400 (this PR). The read-scope lowering refuses it withREAD_SCOPE_COMPILE_FAILED/ 500, in the type face's words and not the field-reference gate's (service-analytics: the NativeSQL read-scope compiler and the/analytics/sqlecho compile two scope shapes the shared comparand faces refuse (plain-object comparand under$eq, null member in$in): one scope, two answers across faces #20018). The routing detector still returnsnullfor it.Sentences one PR made false, re-read on the merged tree. Each is now scoped:
comparand-shape-refusal.test.ts's "one sentence, two envelopes" note: for a plain object,Mapor binary, thewheredoor now answers in the type face's sentence, while the read scope keeps this package's sentence because its own gates run first;comparand-shape.tsbinary-extra docblock (above);The file list against
mainis 14 files;comparand-shape.ts(docblock only) is the one added.Evidence on the merged head
0164eba2b7.Test Files 121 passed (121),Tests 2795 passed (2795).tsc --noEmitexit 0.Tests 138 failed | 2657 passed (2795), with the same per-file split (99 new-file, 21 / 8 / 3 / 2 / 1×5 re-judged). Restore: blob == HEAD (6de15db882ed),git diff HEADempty.check:dual-build-cjs-loads,check:type-check-debt).--ran:60 derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED.check-adr-0087-registration --base origin/mainexit 0 (same disposition).check-issue-citationsboard-probing exit 0 (48 resolve).Round 3: stale docblocks carried (head
8c78550a75)comparand-shape.tsdocblocks only: 53 comment lines added, 6 removed, with no code or test line changed. So there is no ablation this round: it moves no behaviour and no assertion.8c78550a75:Test Files 121 passed (121),Tests 2795 passed (2795);tsc --noEmitexit 0; eslint on the file: 0 errors, 0 warnings;dispatch-gates --commands: the same 60 families; the union of 61 gave 59 exit 0 and 2 exit 3 (the same NOT MEASURED pair);--ran:60 derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED;check-issue-citationsboard-probing: exit 0, 72 resolve.origin/mainis atb76aad5f6fat the time of writing. None of its commits since the merge base980bc05e5btouchesservice-analyticsor the shared filter faces, so it was not merged.Round 4: the unbindable list-member refusal no longer offers "(or a binary value)" (head
cf1563d6f8)cf1563d6f8: 3 files, +28 / −10.comparand-shape.ts:unbindableListMemberMessage's runtime text no longer offers "(or a binary value)" as a repair. It now names only the accepted set. Its code, status and verdict do not change.isBindableComparand's binary arm stays as driver-sql's mirror. driver-sql's own copy of the sentence (sql-driver.ts) also stays, because that driver does bind a binary.comparand-door-single-source.test.ts: the [finding] service-analytics carries its own copies of the comparand-type allow-list the #7872 door now single-sources — reconcile membership and message wording to the door #8186 pin that assertedtoContain('(or a binary value)')is re-judged, flipped, with a note. The message must not containbinary, and the accepted-set sentence must be followed directly by "Refusing rather than binding it".git grep -Fover all 9482 tracked files atcf1563d6f8, so every package is covered,packages/restincluded. The tree has no snapshot files.sql-driver.ts:3007, driver-sql's own copy, which is expected and stays. The other 3 are prose that names the removed words: the changeset line, the re-judged pin's comment and thecomparand-shape.tsdocblock. None of them is an assertion.comparand-shape.ts:700) and driver-sql's copy (sql-driver.ts:3006).comparand-door-single-source.test.ts:325), driver-sql's copy, and two other messages (comparand-shape.ts:595, driver-tursoremote-transport.ts:4141).unbindableListMemberMessage: 9 hits. They are the definition, its two door callers (filter-normalizer.ts:680,read-scope-sql.ts:1009), their imports and the matrix test's three calls. The other two calls assert only the accepted-set sentence andbigint, and neither moved.comparand-shape.ts:698) and three door pins that still hold on it (where-type-face-refusal.test.ts:229-230,cross-field-reference-refusal.test.ts:288).scripts/ablation-replace.mjs, which put "(or a binary value)" back into the runtime string.4cc83d7bf44c→77c17ca505c8.Test Files 1 failed | 120 passed (121),Tests 1 failed | 2794 passed (2795).comparand-door-single-source.test.ts› "binary stays a package-local extra the door does not admit":AssertionError: expected '"$in" on "status" has a value at inde…' not to contain 'binary'.4cc83d7bf44c),git diff HEADempty,git status --porcelainempty. The wrapper carried its owntraprestore.cf1563d6f8:Test Files 121 passed (121),Tests 2795 passed (2795);tsc --noEmitexit 0;--no-inline-config --format jsonon the two touched TS files: 2 files, 0 errors, 0 warnings;dispatch-gates --commands: the same 60 families as rounds 1 to 3. 58 exited 0. 2 exited 3 and are NOT MEASURED (check:dual-build-cjs-loads,check:type-check-debt), because they need the whole workspace built.--ran:60 derived famil(ies) accounted for — 58 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3);check-issue-citationsin board-probing mode: exit 0, 74 citations resolve.origin/mainis at66960564d9, 5 commits past the merge base980bc05e5b. None of those commits touchesservice-analyticsor the shared comparand-type face. This round changes no code, soorigin/mainwas not merged.Acceptance notes
comparand-shape.tsdocblocks: fixed. Round 2 corrected fix(service-analytics)!: the NativeSQL execute face and the /analytics/sql echo refuse a read scope the shared comparand faces refuse #20046's binary-extra note so that it says NEITHER door serves the extra. Round 3 (8c78550a75, docblocks only) fixes the rest of the file's pre-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 #20035where-door account:undefinedtable'swhererow now names the shared comparand-TYPE face as the first refusal (same verdict and envelope), with service-analytics 的where门把undefined值的键整个丢掉 —— 单键 where 退化成「无过滤器」,方向是加宽(#6125 五面表漏记的第六、七种读法) #6386's gate kept for the two positions the face steps around;$fieldnote, the two shared refusal sentences (unrenderableTextComparandMessage,unbindableListMemberMessage) and the two$between-endpoint notes are each scoped to the door they are true of.unbindableListMemberMessage's "(or a binary value)": fixed in round 4 (cf1563d6f8). Round 3 left it because that round changed docblocks only. Neither door accepts a binary any more (service-analytics: the NativeSQL read-scope compiler and the/analytics/sqlecho compile two scope shapes the shared comparand faces refuse (plain-object comparand under$eq, null member in$in): one scope, two answers across faces #20018 at the read scope, this PR at thewheredoor), so the runtime text now names only the accepted set. The pin incomparand-door-single-source.test.tsis re-judged. driver-sql's own copy keeps the words, because that driver binds a binary.$newith a list is still the face's to judge ([finding]$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 stage 2). The type face steps around arrays, so this change neither moves nor pins it.undefinedinside an array comparand ({d:{$contains:['a', undefined]}}) or under an unknown operator still reads in service-analytics 的where门把undefined值的键整个丢掉 —— 单键 where 退化成「无过滤器」,方向是加宽(#6125 五面表漏记的第六、七种读法) #6386's sentence, and an array or{ $field }member / LIKE comparand in driver-sql:两类无意义比较对象仍编译成「静默空谓词」——$in/$nin的非$field对象成员,与 LIKE 族的对象比较值(String 成[object Object]) #5234's. Those are positions the face does not judge, and both spellings agree on them.whereskips the shared comparand-shape face's other arms ($innull member,$gt: null, null/blank$betweenbound, scalar$in) that the FilterArray spelling refuses 400 #20010 remains open for its$nearm, which is not this card's.Generated by Claude Code