Repository navigation
fix(driver-mongodb): a boolean aggregand answers the ruled values on this face too - #12819
Conversation
Two independent halves of one cell, in the same package: 1. `mongodb-aggregation.ts` lowered `sum` / `avg` as bare `$sum` / `$avg` over the field path. Those are arithmetic accumulators and ignore every non-numeric value, so a boolean column summed to `0` and averaged to `null`. They now wrap the aggregand in the #11065 boolean-only coercion, answering 3 / 0.5 on the 3-true/3-false fixture. 2. `mongodb-pipeline-evaluator.testkit.ts` applied its "arithmetic accumulators ignore non-numeric values" filter one arm too far: `$min` / `$max` consumed it too and answered `null` over a boolean column. They are order statistics over BSON canonical order, so they now ignore only null and missing, compare by type-then-value, and return a member of the input — `false` / `true`, the #11249 ruling. `bsonRank` is the single place that order is written down and refuses any type it does not model, so the arms raise rather than silently answering `null`, which is what the file's head note has always promised. `$type` is modelled for the same reason: the coercion above emits it. The coercion in (1) is deliberately NOT applied to `$min` / `$max` — that would answer 0 / 1 where #11249 ruled false / true. Part of #11151
…s controls Ungrouped and grouped, on the `FLAG_BY_ID` distribution already landed on `main` in driver-sql's #11635 suite (west [T,F,F,F], east [T,T]), so the two faces' grouped numbers are comparable. The load-bearing pin is the emitted-lowering block: it reads the stages rather than the values to assert the `sum`/`avg` coercion did NOT reach `$min`/`$max`. Once the evaluator ranks booleans both spellings produce an answer, so the values alone can no longer tell them apart. Controls kept beside them: `count` / `count_distinct` (which already agreed), all four functions over the numeric column, and sum/avg over a string column to pin that the coercion stays boolean-only. Part of #11151
Two independent halves, ablated separately, because a single ablation that
reds everything proves neither.
ABLATION 1 — revert ONLY `mongodb-aggregation.ts`'s sum/avg coercion
(`{ $sum: numericAggregandExpr(fieldRef) }` -> `{ $sum: fieldRef ?? 0 }`,
same for `$avg`).
PREDICTED DIRECTION: RED, and NARROWLY.
- RED: 'sum(flag) answers 3' (expect 0), 'avg(flag) answers 0.5'
(expect null), 'grouped sum/avg answer per group', and
'sum and avg wrap the aggregand in the boolean-only coercion'.
- GREEN, untouched: every min/max test, including
'min and max are left BARE', the empty-window nulls, the refusal block,
and all controls.
Expected red count: 4.
ABLATION 2 — revert ONLY `mongodb-pipeline-evaluator.testkit.ts`'s $min/$max
arms to the number-filtered form
(`numbers.length === 0 ? null : Math.min(...numbers)`, same for max).
PREDICTED DIRECTION: RED, and NARROWLY.
- RED: 'min(flag) answers false' (expect null), 'max(flag) answers true'
(expect null), 'grouped min/max answer per-group members',
'min over an unmodelled BSON type raises UnsupportedShape' and the max
twin (the arms would answer null instead of raising), and
'the types it DOES rank all answer' (min over the string column would
answer null rather than 'lost').
- GREEN, untouched: every sum/avg test, the emitted-lowering block in
full (it reads stages, not values, and the lowering is not mutated),
'min/max over a column that is null or absent everywhere answer null'
(both forms answer null there — this one CANNOT discriminate and is
predicted green in both legs), and the numeric-column control
(min(score)=10 / max(score)=60 survive a number-only filter).
Expected red count: 5.
No rebuild is involved in either leg: the pin suite imports
`./mongodb-aggregation.js` and `./mongodb-pipeline-evaluator.testkit.js` as
in-package relative specifiers, which vitest resolves to `src/*.ts`, not to
this package's `dist/`. The only built dependency in the closure is
`@objectstack/spec/data`, which neither leg mutates.
Part of #11151
…cion reds `builds $group with groupBy fields` and `builds multiple aggregations` spelled the `sum`/`avg` accumulator literally while their actual subject is alias routing and stage shape. Disposition: change the spelling — the accumulator's own internals are pinned, with reasons, in the new #11151 suite. The wrapper is written once as a local helper so the two pins stay readable as routing pins, and its note records that `min`/`max` deliberately do NOT take it (the `builds min/max aggregations` case below it is the pin that says so). Also: the new suite's `AGGREGATION_ROWS` cast goes through `unknown`, matching the spelling `mongodb-aggregation-translation.test.ts` already uses. Measured rather than assumed — this package's `typecheck` script EXCLUDES `**/*.test.ts` via its tsconfig, so `pnpm typecheck` was green over a program that had never read either test file. Checked with an ad-hoc program that includes them: my two files are clean; ten pre-existing errors in eight untouched test files are filed separately, not fixed here. Part of #11151
…ngodb-boolean-aggregand
📓 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 — 6 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 0ec3407f69f9d6fb9c28b43ad722133a45972eb6 && git checkout 0ec3407f69f9d6fb9c28b43ad722133a45972eb6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 632e862d14497e882885e0a75f5c31cc61c5186d dcead01a9ee88f9ebdf5cf8676288cc1caaa0cc3 && git checkout -B drift-repro 632e862d14497e882885e0a75f5c31cc61c5186d && git merge --no-ff dcead01a9ee88f9ebdf5cf8676288cc1caaa0cc3
node scripts/docs-audit/affected-docs.mjs --json 632e862d14497e882885e0a75f5c31cc61c5186d |
…rds instead of a tracker number (stage 6) (objectstack-ai#21593) Part of objectstack-ai#20749 Clause-②: no Stage 6 of the `domain:spec` lane's share of the runtime-string burn-down (ruling `5902360492`, form D): class (c), the case notes and names of the eight shared conformance modules in `packages/spec/src`, with the two test seams that bind their text. Every rewritten note or name now states in words what the cited decision was, or drops a citation its sentence already explained. Text only. ## What changed - **58 messages / 68 tracker ids / 40 distinct cards** in eight modules: `contracts/metadata-service-roundtrip-conformance.ts` and, in `data/`, `aggregation-conformance.ts`, `filter-comparand-type-conformance.ts`, `filter-logic-conformance.ts`, `filter-text-conformance.ts`, `filter-text-operator-declared-type.ts`, `temporal-conformance.ts`, `value-roundtrip-conformance.ts`. Fifty-two are `note` / `why` texts and six are case names. - **The selector seam** (`packages/lint/src/validate-empty-combinators.test.ts:275`): selects the same four empty-combinator cases by a phrase the rewritten notes carry instead of `objectstack-ai#5322` (A3 below). - **The name-pin seam** (`packages/services/service-analytics/src/__tests__/icontains-dialect-sql.test.ts:369`): pins the renamed infix `icontains` case verbatim, the same strength (A4 below). - One `@objectstack/spec` **patch** changeset, `Clause-②: no`: the conformance tables ship in the package's `dist` (measured: the new note text is in `dist/data/index.mjs`; the two test files ship nowhere, since `@objectstack/lint` and `@objectstack/service-analytics` publish `dist` only and neither dist carries the seam text, with the package's own symbols as the positive control). - No generated artifact moves (`check:generated` green; the tables are values behind annotated types, so the API surface is unchanged). ## Census at the base (A1) Stage 3's instrument (`census.cjs`, byte-identical copy, md5 `e5fe562290fcd1ac392bd560cb86d861`) at base `cc645f2385`: class (c) is **58 messages / 68 ids in 8 files, 40 distinct cards**, exactly stage 3's count (the `[c-SELECTOR]` 4 / 7 and `[c-NAMEPIN]` 1 / 1 rows included). Under the ~60-card bar, so one stage. Lines are the base's. | file (under `packages/spec/src`) | line:id | messages / ids | field | consumer | |:--|:--|--:|:--|:--| | `contracts/metadata-service-roundtrip-conformance.ts` | 194:objectstack-ai#6725 · 202:objectstack-ai#6725 · 280:objectstack-ai#7378 · 296:objectstack-ai#7378 · 304:objectstack-ai#7378 | 5 / 5 | `why` | none prints it: both runners title a row by its `id`; the text is a reader's note in the shared table | | `data/aggregation-conformance.ts` | 342:objectstack-ai#6409 · 398:objectstack-ai#11065,objectstack-ai#11151 · 407:objectstack-ai#11065 · 417:objectstack-ai#11152 419:objectstack-ai#11249 · 535/550/561:objectstack-ai#15546 · 577/596:objectstack-ai#6401 | 9 / 11 | `note` | the assertion message in the driver-memory, driver-sql, driver-turso, driver-sqlite-wasm, objectql and mongodb-translation suites | | `data/filter-comparand-type-conformance.ts` | 153:objectstack-ai#7956 · 160:objectstack-ai#7872 · 272/282/289:objectstack-ai#19757 (names) · 178:objectstack-ai#5234 · 191:objectstack-ai#6050 (notes) | 7 / 7 | 5 `name`, 2 `note` | names are test titles in five driver suites and in `filter-save-door-face-parity.test.ts`; notes are assertion messages | | `data/filter-logic-conformance.ts` | 339/362:objectstack-ai#3774 · 420:objectstack-ai#5322 · 426:objectstack-ai#5322,objectstack-ai#5134 · 432/438:objectstack-ai#5322,objectstack-ai#5297 · 470:objectstack-ai#5298 · 476:objectstack-ai#5146 · 503/509:objectstack-ai#5298,objectstack-ai#13356 · 539:objectstack-ai#5299,objectstack-ai#5962 · 561/567/573/579/595:objectstack-ai#20444 | 16 / 22 | `note` | the assertion message in the eleven filter-logic harnesses; **the one selector**: `validate-empty-combinators.test.ts:275` picks the four `objectstack-ai#5322` notes | | `data/filter-text-conformance.ts` | 300:objectstack-ai#8934 (name) · 309:objectstack-ai#8934 · 349:objectstack-ai#6518,objectstack-ai#6682 · 428:objectstack-ai#4706 · 436:objectstack-ai#5710,objectstack-ai#6993 · 452:objectstack-ai#5240 | 6 / 8 | 1 `name`, 5 `note` | names are test titles in the text-conformance suites; **the one name pin**: `icontains-dialect-sql.test.ts:369`; notes are assertion messages | | `data/filter-text-operator-declared-type.ts` | 392:objectstack-ai#8296 · 553:objectstack-ai#8371 · 554:objectstack-ai#8296 | 3 / 3 | `note` | the assertion message in `engine-text-operator-declared-type-door.test.ts` | | `data/temporal-conformance.ts` | 211:objectstack-ai#3773 · 215/216:objectstack-ai#3777 · 220:objectstack-ai#20600 (row `why`) · 270:objectstack-ai#3777 · 393:objectstack-ai#3773 · 448/457:objectstack-ai#1874 · 549:objectstack-ai#3994 (case `note`) | 9 / 9 | 4 `why`, 5 `note` | a row `why` is seeded as DATA into each harness's `why` string column (never filtered on); a case `note` is an assertion message | | `data/value-roundtrip-conformance.ts` | 213:objectstack-ai#11535 · 225/226:objectstack-ai#11782 | 3 / 3 | `note` | printed in the TEST TITLE `round-trips NAME (NOTE)` in five driver suites | No non-test code reads any of these texts (every `.note` / `.why` read outside a test is another table's: `RETIRED_FILTER_OPERATORS`, the driver-conformance ledger). ## Delivered (A2): each site, the decision as read, the new words Each cited card was read with its body and every comment; the decision column names the record (comment id, or the landing record where the card carries none). Line = the base line of the edit anchor. "Old → new" shows only the words that changed. | file:line | ids | decision as read | old → new | |:--|:--|:--|:--| | `contracts/metadata-service-roundtrip-conformance.ts:194` | objectstack-ai#6725 | objectstack-ai#6725: card answers 404 here; landing record: changesets 1507ba3 / bbee302 (objectql and spec CHANGELOGs): MetadataFacade.register('object') wrote into a map none of its own object reads consulted | objectstack-ai#6725 would have failed → that catches an object write landing in a store none of the object reads consult — the hole a shipped implementation once fell through | | `contracts/metadata-service-roundtrip-conformance.ts:202` | objectstack-ai#6725 | objectstack-ai#6725: card answers 404 here; landing record: changesets 1507ba3 / bbee302 (objectql and spec CHANGELOGs): MetadataFacade.register('object') wrote into a map none of its own object reads consulted | produced objectstack-ai#6725 → let an object write land in the generic store while every object read looked elsewhere | | `contracts/metadata-service-roundtrip-conformance.ts:280` | objectstack-ai#7378 | objectstack-ai#7378: maintainer ruling 5261734580 (2026-08-12): row 1 refuse a name/data.name disagreement, row 2 one answer converged with check:meta-type-normalized, row 3 refuse non-object data | objectstack-ai#7378, maintainer 2026-08-12, row 1 → maintainer 2026-08-12, row 1 of the round-trip ruling | | `contracts/metadata-service-roundtrip-conformance.ts:296` | objectstack-ai#7378 | objectstack-ai#7378: maintainer ruling 5261734580 (2026-08-12): row 1 refuse a name/data.name disagreement, row 2 one answer converged with check:meta-type-normalized, row 3 refuse non-object data | objectstack-ai#7378, maintainer 2026-08-12, row 2 → maintainer 2026-08-12, row 2 of the round-trip ruling | | `contracts/metadata-service-roundtrip-conformance.ts:304` | objectstack-ai#7378 | objectstack-ai#7378: maintainer ruling 5261734580 (2026-08-12): row 1 refuse a name/data.name disagreement, row 2 one answer converged with check:meta-type-normalized, row 3 refuse non-object data | objectstack-ai#7378, maintainer 2026-08-12, row 3 → maintainer 2026-08-12, row 3 of the round-trip ruling | | `data/aggregation-conformance.ts:342` | objectstack-ai#6409 | objectstack-ai#6409: card body, executing the objectstack-ai#6188 split ruling: count_distinct stays declared and is enforced as distinct non-null values on the SQL family | objectstack-ai#6409: → count_distinct was kept and enforced on every face, as distinct non-null values: | | `data/aggregation-conformance.ts:398` | objectstack-ai#11065 objectstack-ai#11151 | objectstack-ai#11065: card answers 404 here; landing record: driver-memory changeset 2095040: avg/sum over a boolean answer the same numbers as every SQL face (a boolean is worth 1 or 0) · objectstack-ai#11151: PR objectstack-ai#12819 (triage 5446817173): driver-mongodb's boolean aggregand answers the ruled values too | objectstack-ai#11065/objectstack-ai#11151: an arithmetic accumulator that drops booleans answers its identity 0 here — a plausible number, which → A boolean aggregand is worth 1 or 0 on every face — driver-memory and then driver-mongodb were both moved onto that answer: an arithmetic accumulator that drops booleans answers its identity 0 here — a plausible number, which | | `data/aggregation-conformance.ts:407` | objectstack-ai#11065 | objectstack-ai#11065: card answers 404 here; landing record: driver-memory changeset 2095040: avg/sum over a boolean answer the same numbers as every SQL face (a boolean is worth 1 or 0) | objectstack-ai#11065: the rate-over-a-flag-column shape (an SLA-violation rate, a win rate). → The rate-over-a-flag-column shape (an SLA-violation rate, a win rate) that driver-memory answered null for until it counted a boolean as 1 or 0. | | `data/aggregation-conformance.ts:417` | objectstack-ai#11152 objectstack-ai#11249 | objectstack-ai#11152: maintainer ruling 2026-08-28, option A (changeset f6fa22c in the driver CHANGELOGs; decision request 5446817173): booleans aggregate as numbers, min/max answer 0/1 · objectstack-ai#11249: ruling 5386670755 (false/true for min/max over a boolean), superseded by objectstack-ai#11152's 2026-08-28 ruling | objectstack-ai#11152 ruling (2026-08-28): booleans aggregate as numbers with no per-aggregate exception, so the order statistics answer 0/1 in the same domain sum/avg answer in — not false/true (objectstack-ai#11249, superseded) → Ruled 2026-08-28: booleans aggregate as numbers with no per-aggregate exception, so the order statistics answer 0/1 in the same domain sum/avg answer in — not the false/true an earlier ruling had chosen, which this one superseded | | `data/aggregation-conformance.ts:535` | objectstack-ai#15546 | objectstack-ai#15546: ruling 5572006116 (option A): a non-empty group whose aggregand is NULL in every row sums to 0 on every face | objectstack-ai#15546: → Ruled: a non-empty group whose aggregand is NULL in every row sums to 0. | | `data/aggregation-conformance.ts:550` | objectstack-ai#15546 | objectstack-ai#15546: ruling 5572006116 (option A): a non-empty group whose aggregand is NULL in every row sums to 0 on every face | objectstack-ai#15546: the reachability control for the → The reachability control for the all-NULL-sums-to-0 | | `data/aggregation-conformance.ts:561` | objectstack-ai#15546 | objectstack-ai#15546: ruling 5572006116 (option A): a non-empty group whose aggregand is NULL in every row sums to 0 on every face | objectstack-ai#15546: the partial-null control → The partial-null control for the same ruling | | `data/aggregation-conformance.ts:577` | objectstack-ai#6401 | objectstack-ai#6401: dev report 5229051388 (PR objectstack-ai#6849): GroupByNode.alias is enforced, not removed; every SQL face projects the group under alias ?? field | objectstack-ai#6401: t → A structured group node's `alias` names the projected group column on every face — the SQL faces that ignored it were made to honour it. T | | `data/aggregation-conformance.ts:596` | objectstack-ai#6401 | objectstack-ai#6401: dev report 5229051388 (PR objectstack-ai#6849): GroupByNode.alias is enforced, not removed; every SQL face projects the group under alias ?? field | objectstack-ai#6401: the degenerate alias. Its twin above → The degenerate alias, under the same every-face `alias` rule. Its twin above | | `data/filter-comparand-type-conformance.ts:153` | objectstack-ai#7956 | objectstack-ai#7956: ACCEPT 5264821447: a measurement only; its control cell ({qty: {$eq: 100}}) returned the row on every driver, so the zeros were real answers | objectstack-ai#7956 control cell → control cell of the cross-driver comparand-type measurement | | `data/filter-comparand-type-conformance.ts:160` | objectstack-ai#7872 | objectstack-ai#7872: maintainer ruling 5265944890 and ACCEPT 5273103599 (PR objectstack-ai#8234): a shared comparand-type door; a safe-range bigint narrows to its exact number, beyond 2^53 is refused | (objectstack-ai#7872) → at the shared comparand door | | `data/filter-comparand-type-conformance.ts:178` | objectstack-ai#5234 | objectstack-ai#5234: ACCEPT 5217619370 (PR objectstack-ai#6296): a non-$field object member of $in/$nin and an object LIKE comparand are refused; each member is judged on its own | right (objectstack-ai#5234) → right, each judged like a scalar comparand | | `data/filter-comparand-type-conformance.ts:191` | objectstack-ai#6050 | objectstack-ai#6050: ruling B 5211349926: an undefined comparand is refused loudly; null keeps its meaning as the null predicate | (objectstack-ai#6050's untouched half) — → — the half left untouched when an undefined comparand was ruled a loud refusal — so | | `data/filter-comparand-type-conformance.ts:272` | objectstack-ai#19757 | objectstack-ai#19757: ruling 5793368540 (letter 乙): an array in the implicit-equality slot is refused at the shared face, for every driver at once | (objectstack-ai#19757) → at the shared face | | `data/filter-comparand-type-conformance.ts:282` | objectstack-ai#19757 | objectstack-ai#19757: ruling 5793368540 (letter 乙): an array in the implicit-equality slot is refused at the shared face, for every driver at once | (objectstack-ai#19757) → at the shared face | | `data/filter-comparand-type-conformance.ts:289` | objectstack-ai#19757 | objectstack-ai#19757: ruling 5793368540 (letter 乙): an array in the implicit-equality slot is refused at the shared face, for every driver at once | too (objectstack-ai#19757) → too, at the shared face | | `data/filter-logic-conformance.ts:339` | objectstack-ai#3774 | objectstack-ai#3774: defect: driver-sql OR-ed a $or branch's own keys and operators; rule: everything inside one filter object ANDs at every depth (card body) | objectstack-ai#3774: compiled → A $or combines its branches, never the keys inside one: a driver that OR-ed a branch's own keys compiled this | | `data/filter-logic-conformance.ts:362` | objectstack-ai#3774 | objectstack-ai#3774: defect: driver-sql OR-ed a $or branch's own keys and operators; rule: everything inside one filter object ANDs at every depth (card body) | objectstack-ai#3774: a single-key branch is miscompilable too — the operator map is looped with the same → A single-key branch is miscompilable too — that driver looped the operator map with the same OR | | `data/filter-logic-conformance.ts:420` | objectstack-ai#5322 | objectstack-ai#5322: maintainer ruling 5185589944: every face reduces an empty combinator to its boolean identity ($and:[] all rows, $or:[] none, {} TRUE, $not:{} none), whole tree | objectstack-ai#5322: a → Ruled: every face reduces an empty combinator to its boolean identity. A | | `data/filter-logic-conformance.ts:426` | objectstack-ai#5322 objectstack-ai#5134 | objectstack-ai#5322: maintainer ruling 5185589944: every face reduces an empty combinator to its boolean identity ($and:[] all rows, $or:[] none, {} TRUE, $not:{} none), whole tree · objectstack-ai#5134: defect repaired by PR objectstack-ai#5243 (ACCEPT 5178806144): driver-sql dropped an empty $and/$or group instead of applying the boolean identity; empty $or and empty $not now compile to FALSE | objectstack-ai#5322/objectstack-ai#5134: a disjunction of zero conditions matches nothing. Fail-closed for an RLS scope — a disjunct list that loops to zero items hides every row instead of exposing the table → Ruled: every face reduces an empty combinator to its boolean identity. A disjunction of zero conditions matches nothing. Fail-closed for an RLS scope — a disjunct list that loops to zero items hides every row instead of exposing the table, as a SQL lowering that dropped the empty group once did | | `data/filter-logic-conformance.ts:432` | objectstack-ai#5322 objectstack-ai#5297 | objectstack-ai#5322: maintainer ruling 5185589944: every face reduces an empty combinator to its boolean identity ($and:[] all rows, $or:[] none, {} TRUE, $not:{} none), whole tree · objectstack-ai#5297: dispatch 5184116664: read-scope-sql's { $not: {} } compiled to nothing (an RLS scope with no WHERE) and dropped a {} disjunct; both aligned to the boolean identity | objectstack-ai#5322: collapsing to the surviving branches instead compiles `a = x` — a silently NARROWED scope (objectstack-ai#5297) → Ruled: every face reduces an empty combinator to its boolean identity, so `{}` is a TRUE disjunct. Collapsing to the surviving branches instead compiles `a = x` — a silently NARROWED scope, the answer the RLS read-scope compiler gave until it was aligned | | `data/filter-logic-conformance.ts:438` | objectstack-ai#5322 objectstack-ai#5297 | objectstack-ai#5322: maintainer ruling 5185589944: every face reduces an empty combinator to its boolean identity ($and:[] all rows, $or:[] none, {} TRUE, $not:{} none), whole tree · objectstack-ai#5297: dispatch 5184116664: read-scope-sql's { $not: {} } compiled to nothing (an RLS scope with no WHERE) and dropped a {} disjunct; both aligned to the boolean identity | objectstack-ai#5322: emitting nothing for it runs the query UNSCOPED — on an RLS lowering that is a permission bypass (objectstack-ai#5297) → Ruled: every face reduces an empty combinator to its boolean identity, so NOT of `{}` is FALSE. Emitting nothing for it runs the query UNSCOPED — on an RLS lowering that is a permission bypass, which the read-scope compiler was until it compiled this to an always-false clause | | `data/filter-logic-conformance.ts:470` | objectstack-ai#5298 | objectstack-ai#5298: ruling 5202271174 (option A, confirmed 5204511921 item 4): $ne / $nin / $notContains are NULL-safe on the non-negated path, a no-value row is included | objectstack-ai#5298 → Ruled NULL-safe on every face | | `data/filter-logic-conformance.ts:476` | objectstack-ai#5146 | objectstack-ai#5146: maintainer ruling 5181102507: $not is NULL-safe on every driver; a row with no value does not satisfy the negated condition, so the negation returns it | objectstack-ai#5146: the same ruling reached through the combinator → The same NULL-safe ruling reached through the combinator, where it was first made for `$not` itself | | `data/filter-logic-conformance.ts:503` | objectstack-ai#5298 | objectstack-ai#5298: ruling 5202271174 (option A, confirmed 5204511921 item 4): $ne / $nin / $notContains are NULL-safe on the non-negated path, a no-value row is included | objectstack-ai#5298 option A → Ruled NULL-safe | | `data/filter-logic-conformance.ts:503` | objectstack-ai#13356 | objectstack-ai#13356: PR objectstack-ai#13356: driver-memory's reference matcher realigned so a no-value row satisfies $nin / $notContains | PR objectstack-ai#13356 → it was realigned to the ruling | | `data/filter-logic-conformance.ts:509` | objectstack-ai#5298 | objectstack-ai#5298: ruling 5202271174 (option A, confirmed 5204511921 item 4): $ne / $nin / $notContains are NULL-safe on the non-negated path, a no-value row is included | objectstack-ai#5298 option A → Ruled NULL-safe | | `data/filter-logic-conformance.ts:509` | objectstack-ai#13356 | objectstack-ai#13356: PR objectstack-ai#13356: driver-memory's reference matcher realigned so a no-value row satisfies $nin / $notContains | PR objectstack-ai#13356 → it was realigned to the ruling | | `data/filter-logic-conformance.ts:539` | objectstack-ai#5299 objectstack-ai#5962 | objectstack-ai#5299: ruling 5219858433 item 2, kept by 5238865560: $exists means has a value (non-null), never key-presence, since SQL cannot tell a missing key from null · objectstack-ai#5962: PR objectstack-ai#5962 (ACCEPT 5205011148 on objectstack-ai#5298): the NULL-safe operators and the $exists has-a-value reading shipped | objectstack-ai#5299 cell 2 / objectstack-ai#5962: `$exists` means HAS A VALUE, never key-presence → Ruled: `$exists` means HAS A VALUE, never key-presence, because SQL cannot tell a missing key from a stored null | | `data/filter-logic-conformance.ts:561` | objectstack-ai#20444 | objectstack-ai#20444: card body executing ruling A on objectstack-ai#20399 (5865693155): every compile surface answers $empty by the field's declared type; $empty: false is the exact complement | objectstack-ai#20444: null is empty on every row of the ruled table → Every face answers `$empty` by the field's declared type, and null is empty under every type's arm | | `data/filter-logic-conformance.ts:567` | objectstack-ai#20444 | objectstack-ai#20444: card body executing ruling A on objectstack-ai#20399 (5865693155): every compile surface answers $empty by the field's declared type; $empty: false is the exact complement | objectstack-ai#20444: the exact complement → `$empty: false` is the exact complement by ruling | | `data/filter-logic-conformance.ts:573` | objectstack-ai#20444 | objectstack-ai#20444: card body executing ruling A on objectstack-ai#20399 (5865693155): every compile surface answers $empty by the field's declared type; $empty: false is the exact complement | objectstack-ai#20444: `$empty` spells its NULL case out, so it → The ruled `$empty` arms spell their NULL case out, so `$empty` | | `data/filter-logic-conformance.ts:579` | objectstack-ai#20444 | objectstack-ai#20444: card body executing ruling A on objectstack-ai#20399 (5865693155): every compile surface answers $empty by the field's declared type; $empty: false is the exact complement | objectstack-ai#20444: → Under the same declared-type arms, | | `data/filter-logic-conformance.ts:595` | objectstack-ai#20444 | objectstack-ai#20444: card body executing ruling A on objectstack-ai#20399 (5865693155): every compile surface answers $empty by the field's declared type; $empty: false is the exact complement | objectstack-ai#20444: a face that lowers `$empty` → A face that lowers `$empty` to the field's declared-type arm | | `data/filter-text-conformance.ts:300` | objectstack-ai#8934 | objectstack-ai#8934: ruling A 5305708745: icontains joins the view and infix vocabularies; ilike/$ilike and icontains/$icontains are never aliased onto each other | objectstack-ai#8934 → ruled never an alias of ilike | | `data/filter-text-conformance.ts:309` | objectstack-ai#8934 | objectstack-ai#8934: ruling A 5305708745: icontains joins the view and infix vocabularies; ilike/$ilike and icontains/$icontains are never aliased onto each other | capability (objectstack-ai#8934) → capability, ruled when `icontains` joined the view and infix vocabularies | | `data/filter-text-conformance.ts:349` | objectstack-ai#6518 objectstack-ai#6682 | objectstack-ai#6518: ACCEPT 5226386725 (PR objectstack-ai#6706): the SQL family's $contains family is case-exact (GLOB on the SQLite dialects), per objectstack-ai#4706 Q2 = A · objectstack-ai#6682: ACCEPT 5251849449 and the memory half: the hardcoded case-folding flag came off driver-mongodb and driver-memory, the last two folding faces | match. SQLite's LIKE folds ASCII — the defect objectstack-ai#6518 replaced with GLOB on the SQLite dialects; a JS backend's equivalent is a RegExp carrying the `i` flag, which objectstack-ai#6682 took off → match: the `$contains` family is case-sensitive on every backend, by ruling. SQLite's LIKE folds ASCII — the defect the SQL family replaced with GLOB on the SQLite dialects; a JS backend's equivalent is a RegExp carrying the `i` flag, since taken off driver-memory and driver-mongodb, | | `data/filter-text-conformance.ts:428` | objectstack-ai#4706 | objectstack-ai#4706: ruling B 5199214776: $regex retired under ADR-0049 with a loud refusal naming the replacement, $icontains added | objectstack-ai#4706 retired the operator over → `$regex` was retired over, by ruling, with a loud refusal naming `$icontains` | | `data/filter-text-conformance.ts:436` | objectstack-ai#5710 objectstack-ai#6993 | objectstack-ai#5710: ACCEPT 5201171068 (PR objectstack-ai#5812): plugin-auth's adapter stopped emitting bare $regex for better-auth contains, unblocking the $regex retirement · objectstack-ai#6993: ACCEPT 5231388316 (PR objectstack-ai#7054): the expired status claims were re-measured by executing each face (2026-08) and rewritten | objectstack-ai#5710 flipped that producer before any backend enrolled this case (re-verified 2026-08, objectstack-ai#6993 → That producer was moved off `$regex` before any backend enrolled this case (re-verified 2026-08 by executing each face | | `data/filter-text-conformance.ts:452` | objectstack-ai#5240 | objectstack-ai#5240: maintainer ruling 5181107825: { field: {} } is refused loudly (INVALID_FILTER) on every backend, not read as TRUE or FALSE | objectstack-ai#5240 refused `{ field: {} }` over → for which a field with zero operators, `{ field: {} }`, is refused by ruling | | `data/filter-text-operator-declared-type.ts:392` | objectstack-ai#8296 | objectstack-ai#8296: standing ruling recorded in 5277439872: a where on a formula field is refused (INVALID_FIELD 400) because no driver materialises the column | objectstack-ai#8296 door refuses EVERY formula filter with INVALID_FIELD 400 whatever the `returnType` → unmaterializable-field door refuses EVERY formula filter with INVALID_FIELD 400 whatever the `returnType` (no driver stores a formula column) | | `data/filter-text-operator-declared-type.ts:553` | objectstack-ai#8371 | objectstack-ai#8371: ruling 5300373151 (option 2): a type-directed verdict on the dotted head segment; the structured/JSON head stays deliberately unjudged | unjudged there, objectstack-ai#8371 → left unjudged there, by ruling | | `data/filter-text-operator-declared-type.ts:554` | objectstack-ai#8296 | objectstack-ai#8296: standing ruling recorded in 5277439872: a where on a formula field is refused (INVALID_FIELD 400) because no driver materialises the column | objectstack-ai#8296 → the unmaterializable-field door | | `data/temporal-conformance.ts:211` | objectstack-ai#3773 | objectstack-ai#3773: defect: SQLite read an epoch-ms Field.datetime as a Julian day so date buckets were NULL; repaired by storage-aware bucketing (card body; cross-update 5099832227 on objectstack-ai#3777) | (objectstack-ai#3773) → as SQLite bucketing once did | | `data/temporal-conformance.ts:215` | objectstack-ai#3777 | objectstack-ai#3777: defect: a bare-day $lte on a datetime column stopped at midnight and dropped the rest of the final day; the bound keeps the whole day (card body; 5099832227) | by the objectstack-ai#3777 bug → when a bare-day upper bound stopped at midnight | | `data/temporal-conformance.ts:216` | objectstack-ai#3777 | objectstack-ai#3777: defect: a bare-day $lte on a datetime column stopped at midnight and dropped the rest of the final day; the bound keeps the whole day (card body; 5099832227) | by the objectstack-ai#3777 bug → when a bare-day upper bound stopped at midnight | | `data/temporal-conformance.ts:220` | objectstack-ai#20600 | objectstack-ai#20600: triage direction 5886142901, landed PR objectstack-ai#20643: the whole-day bound past 9999-12-31 is unbounded above and the drivers compile none | (objectstack-ai#20600) → and none is compiled | | `data/temporal-conformance.ts:270` | objectstack-ai#3777 | objectstack-ai#3777: defect: a bare-day $lte on a datetime column stopped at midnight and dropped the rest of the final day; the bound keeps the whole day (card body; 5099832227) | objectstack-ai#3777: the default dashboard window → The default dashboard window, whose bare-day $lte keeps the whole final day | | `data/temporal-conformance.ts:393` | objectstack-ai#3773 | objectstack-ai#3773: defect: SQLite read an epoch-ms Field.datetime as a Julian day so date buckets were NULL; repaired by storage-aware bucketing (card body; cross-update 5099832227 on objectstack-ai#3777) | objectstack-ai#3773 famil → family of the SQLite bucketing defect that read an epoch-ms datetime as a Julian da | | `data/temporal-conformance.ts:448` | objectstack-ai#1874 | objectstack-ai#1874: the templates' date-equality family that surfaced ADR-0053, whose Phase 1 stores Field.date as its calendar day (ADR-0053 Surfaced-by line; card body, no comments) | objectstack-ai#1874: `date == today` silently matched nothing while dates were stored as instants. E → `date == today` silently matched nothing while dates were stored as instants; ADR-0053 stores a date as its calendar day, so e | | `data/temporal-conformance.ts:457` | objectstack-ai#1874 | objectstack-ai#1874: the templates' date-equality family that surfaced ADR-0053, whose Phase 1 stores Field.date as its calendar day (ADR-0053 Surfaced-by line; card body, no comments) | from the objectstack-ai#1874 family → that surfaced ADR-0053 | | `data/temporal-conformance.ts:549` | objectstack-ai#3994 | objectstack-ai#3994: Field.time takes one canonical HH:MM:SS[.fff] text form on write, filter and read (ADR-0053 addendum D-C1..D-C3; driver-sql changeset 9774b78) | objectstack-ai#3994, measured → Measured before `Field.time` took one canonical `HH:MM:SS[.fff]` text form | | `data/value-roundtrip-conformance.ts:213` | objectstack-ai#11535 | objectstack-ai#11535: defect: a single-to-multi-value change kept the old text column on Postgres, so arrays came back as stringified literals; the drift detector now reports the base-type mismatch (claim 5395496220) | objectstack-ai#11535's exact shape → the exact shape a single-value column kept for a multi-value field corrupted | | `data/value-roundtrip-conformance.ts:225` | objectstack-ai#11782 | objectstack-ai#11782: landing 5406878917 (PR objectstack-ai#12019): a declared boolean answers JSON booleans on every read door and dialect; MySQL's tinyint 1/0 leaked before | objectstack-ai#11782 read this back as 1 on MySQL → MySQL read this back as 1 until every read door presented a declared boolean as true/false | | `data/value-roundtrip-conformance.ts:226` | objectstack-ai#11782 | objectstack-ai#11782: landing 5406878917 (PR objectstack-ai#12019): a declared boolean answers JSON booleans on every read door and dialect; MySQL's tinyint 1/0 leaked before | objectstack-ai#11782 → that MySQL read-back | | `lint: validate-empty-combinators.test.ts:275` | objectstack-ai#5322 | objectstack-ai#5322: maintainer ruling 5185589944: every face reduces an empty combinator to its boolean identity ($and:[] all rows, $or:[] none, {} TRUE, $not:{} none), whole tree | objectstack-ai#5322 → every face reduces an empty combinator to its boolean identity | | `analytics: icontains-dialect-sql.test.ts:369` | objectstack-ai#8934 | objectstack-ai#8934: ruling A 5305708745: icontains joins the view and infix vocabularies; ilike/$ilike and icontains/$icontains are never aliased onto each other | objectstack-ai#8934 → ruled never an alias of ilike | ## The selector seam (A3) The filter over `FILTER_LOGIC_CASES` now tests `(c.note ?? '').includes('every face reduces an empty combinator to its boolean identity')` where it tested `.includes('objectstack-ai#5322')`. The phrase is the objectstack-ai#5322 ruling in words, and the four rewritten notes open with it. Measured with an AST extractor over the table (`name` and `note` of all 36 cases, then the selector applied exactly as the test applies it): - base `cc645f2385` with `objectstack-ai#5322` → **4 of 36**: `$not of {} is FALSE — NOT TRUE`, `a {} branch is a TRUE disjunct and absorbs its $or`, `empty $and is TRUE — the AND identity`, `empty $or is FALSE — the OR identity`. - head with the phrase → **the same 4 of 36**, the same names. - cross: the old selector on the head selects 0 (so the move is required), the new phrase on the base selects 0. - control outside the set: the broader word `Ruled` selects 8, the four plus `$exists true selects exactly the valued rows`, `$ne …`, `$nin …` and `$notContains returns the rows with no value`; none of those four is picked by the phrase. - mutation control on a scratch copy (anchor hit 1, replacement present 1, anchor left 0): dropping the phrase from the empty-`$or` note leaves 3, which the test's own guard (`toEqual` on the four sorted names, `toBe(4)`) turns red. The test asserts exactly what it asserted before (the names pin, the count, and the row-set wording per case); only the selector moved. `validate-empty-combinators.test.ts` ran green: 21 of 21, the three identity cases included. ## The name pin (A4) `'icontains (the infix/view spelling, objectstack-ai#8934) lowers to $icontains — % stays a LITERAL through that door too'` becomes `'icontains (the infix/view spelling, ruled never an alias of ilike) lowers to $icontains — % stays a LITERAL through that door too'` in `FILTER_TEXT_CASES`, and the `toEqual` pin in `icontains-dialect-sql.test.ts:369` moves to the new string verbatim. No other consumer keys on that name (searched the whole tree for the old name and its fragments: the source and the pin only); the other consumers use it as a test title. The file ran green: 16 of 16. The five renamed `FILTER_COMPARAND_TYPE_CASES` names have no pin anywhere; they are test titles only. ## Text only (A5) Stage 3's `skeleton.cjs` (one line changed: the TypeScript load path to this worktree), TypeScript 6.0.3: leg 1 an AST skeleton with every string's text masked (a `+` chain's adjacent string operands read as one string), leg 2 the text of every string group, each changed group required to carry a tracker id before and none after, every other group byte-identical. Base copies vs the committed files, **10 of 10 SAME on both legs, exit 0**: | file | tokens | string groups | changed | |:--|--:|--:|--:| | `metadata-service-roundtrip-conformance.ts` | 1352 | 157 | 5 | | `aggregation-conformance.ts` | 1268 | 133 | 9 | | `filter-comparand-type-conformance.ts` | 1309 | 107 | 7 | | `filter-logic-conformance.ts` | 1682 | 223 | 16 | | `filter-text-conformance.ts` | 1093 | 137 | 6 | | `filter-text-operator-declared-type.ts` | 1821 | 114 | 3 | | `temporal-conformance.ts` | 2041 | 374 | 9 | | `value-roundtrip-conformance.ts` | 1197 | 175 | 3 | | `validate-empty-combinators.test.ts` | 2684 | 129 | 1 | | `icontains-dialect-sql.test.ts` | 4345 | 217 | 1 | Parse diagnostics 0 / 0 throughout. 58 changed groups in the eight modules, one per seam. No case's filter, input, expected rows, verdict, code, operator, dialect, order or count moves. Edits were applied by a script whose 62 anchors each had to hit exactly once before any write, and were read back from disk after it (each anchor gone, each replacement present once). Census after the edit: class (c) **0 / 0**; non-test 118 → 60 messages, 297 → 229 ids; test strings unchanged (1804 / 1920). ## Pins, titles and quotes (A6) - Pins moved: the two seams above, nothing else. Every old note and name was searched across the tree in 32-character windows: no test asserts a changed phrase; the remaining hits are code comments and other modules' own prose that share a phrase with an unchanged part of a note. - Titles that follow the text: the comparand-type and text-case names (test titles in the driver suites), and the `VALUE_ROUNDTRIP_CASES` notes (`round-trips NAME (NOTE)`). No skip list, ledger or snapshot names any of those titles. - Quotes: no `content/docs/**` page and no `skills/**` file quotes a changed note or name. - `TEMPORAL_ROWS[].why` is seeded into a `string` column (a `varchar` on Postgres and MySQL), so the four rewritten `why` texts stay short (164 characters at most). ## Gates Head `1427993cc3` (the merge of `origin/main` `901e7cf13a`; that merge touched no file under `packages/spec`, `packages/lint` or `packages/services/service-analytics`, 0 diff lines there). - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 11 paths vs merge base `901e7cf13`, 153 changed lines; **88 derived commands, 88 exit 0** on `1427993cc3`, each exit code written from `$?` before any pipe; `--ran`: "✓ dispatch-gates --ran: 88 derived famil(ies) accounted for — 88 run, 0 NOT-MEASURED". - `pnpm --filter @objectstack/spec build` exit 0 (the dependency closure is empty: no workspace dependencies); `check:generated`: "✓ All 15 generated artifacts are up to date". - `pnpm --filter @objectstack/spec test`: "Test Files 606 passed (606) / Tests 17952 passed | 1 todo (17953)"; `typecheck` exit 0 ("check:test-typecheck: OK — … 52 file(s) / 246 error(s) / 135 pinned signature(s) held"). - `pnpm --filter @objectstack/lint test`: "Test Files 119 passed (119) / Tests 5620 passed (5620)"; `typecheck` exit 0. - `pnpm --filter @objectstack/service-analytics test`: "Test Files 175 passed (175) / Tests 4152 passed | 253 skipped (4405)"; `typecheck` exit 0; `icontains-dialect-sql.test.ts` alone 16 of 16. - `pnpm check:doc-authoring` (self-test, then the run): "✓ doc authoring guard: 17337 customer-facing string(s) across 1251 spec sources clean" and "sibling-package prose ids hold the baseline — 0 pinned site(s) … no growth, no burn-down unrecorded". `pnpm check:nul-bytes`: "check-nul-bytes: OK (scanned 9947 text file(s) …; no raw ASCII control bytes)". - Changeset gates: `check-changeset-no-major.mjs`, `check-empty-changeset.mjs`, `check-adr-0087-registration.mjs` (each `--base origin/main`) exit 0; with this body as the `--event` payload, see the report. - ESLint, a proven narrowing: `eslint --no-inline-config --format json` over the 10 changed TS files reads 10 files, 0 errors, 0 warnings; the population is ESLint's own (`calculateConfigForFile` returns a config for each, `isPathIgnored` false); invariance: `eslint.config.mjs` enables no type-aware linting (`parserOptions.project` / `projectService` null for all 10), so a string-text edit cannot move an untouched file's verdict. Repo-wide `pnpm lint` is CI's. - Spec `test:repo` was not run locally: none of its 51 files names a changed module or table. ## Acceptance notes - **Two cited cards answer 404** in this repository (objectstack-ai#6725 and objectstack-ai#11065; the same numbers in objectui are unrelated PRs). Their decisions were read from the shipped landing records instead: the objectql and spec CHANGELOG entries `1507ba3` / `bbee302` (`MetadataFacade.register('object')` wrote into a map none of its own object reads consulted) and the driver-memory entry `2095040` (a boolean aggregand counts as 1 or 0, as on every SQL face). Neither decision read as unclear. - Two decisions live in shipped records rather than card comments: objectstack-ai#11152's 2026-08-28 ruling (option A, booleans aggregate as numbers, superseding objectstack-ai#11249) is recorded in changeset `f6fa22c` and matches the source comment above those cases; objectstack-ai#3994's in ADR-0053's D-C addendum. - Left for later stages, untouched here: the seam files' own test titles (`(objectstack-ai#5322/objectstack-ai#5659)`, `[objectstack-ai#16028]`) and every other test string; code comments (including the section comments beside these cases); class (f) in `api/error-code-ledger.zod.ts` and `kernel/public-auth-features.ts`; `migrations/registry.ts`. - Not governed: no `.claude/**`, `skills/**`, ADR, NORTH-STAR or AGENTS.md path. ## Line budget 153 changed lines (+90 / -63) over 11 files vs merge base `901e7cf13` (dispatch-gates), under the 5000 human-merge threshold: the eight modules, the two test files (one line each) and the changeset. No generated file, no governed surface. --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #11151
driver-mongodbanswered all four ruled boolean-aggregand cells wrong. This brings the face onto a contract that was already ruled and that every other face already implements. Two independent halves, in the same package, that are not the same fix — applying either to the other half is wrong in the opposite direction.1. The premise, re-measured on this branch's own base
The readings on the card were taken on older bases (
5a916c4d4d,811a3c2b). Re-measured onorigin/main@23843d3f4before any edit, throughbuildAggregationPipeline+postProcessAggregationdriven by the in-processrunPipelineevaluator, onAGGREGATION_ROWSplus a booleanflagcolumn.Fixture distribution used:
FLAG_BY_ID— rows 1..6 =true,false,false,false,true,true, sowestis[T,F,F,F]andeastis[T,T]. This is the one already landed onmainindriver-sql'ssql-driver-11635-boolean-aggregand-answers.test.ts, chosen over the other distribution in this card's record (true,false,true,true,false,false) so the two faces' grouped numbers are comparable and not merely both 3-true/3-false.23843d3f4sum(flag)sum(flag)BY regionavg(flag)avg(flag)BY regionmin(flag)falsefalsemin(flag)BY regionfalse/ easttruefalse/ easttruemax(flag)truetruemax(flag)BY regiontrue/ easttruetrue/ easttruecount(flag)count(flag)BY regioncount_distinct(flag)count_distinct(flag)BY regionPositive controls on the same run, proving the harness measured a real tree rather than answering
nullstructurally:sum(score)= 210,avg(score)= 35,min(score)= 10,max(score)= 60 — all correct on the base, all unchanged after.No cell already answered correctly. All four were wrong; both controls already agreed.
2. Half 1 —
mongodb-aggregation.ts,$sum/$avgONLYThese lowered straight to the arithmetic accumulators, which ignore every non-numeric value. With no numeric value
$sumfolds to its identity0and$avganswersnull. Emitted lowering on the base, for the record:Both arms now wrap the aggregand in the boolean-only coercion #11065 landed on
driver-memory:The coercion is deliberately narrow: null, missing and a non-numeric string reach the accumulator exactly as before and stay excluded. A control pins that (
sum(stage)over the string column is still0,avg(stage)stillnull).3. Half 2 —
mongodb-pipeline-evaluator.testkit.ts,$min/$maxA defect in the instrument, not in the lowering.
{ $min: '$flag' }is correct for a real mongod:$min/$maxare order statistics over BSON canonical comparison order, which ranks booleans and returns one. Thenullcame fromaccumulate, which computed its "arithmetic accumulators ignore non-numeric values" filter once for the whole switch and let$min/$maxconsume it — one arm too far; the comment was accurate the whole time.Those arms now ignore only null and missing, compare what is left by BSON canonical order, and return a member of the input.
bsonRankis the single place that order is written down (null, number, string, boolean, in the manual's order);bsonLtewas refactored onto it, and the three pre-existing ranks keep their relative order exactly, so$lte— the date-bucket label path, its only other caller — is unchanged.$typeis modelled in the evaluator for the first time, because half 1's coercion emits it. Without it the strict evaluator would have thrown on the new lowering, which is the evaluator working as designed.4. The coercion was NOT applied to
$min/$max— confirmed, and pinnedApplying half 1's
$condto the order statistics would answer0/1where #11249 ruledfalse/true. It was not applied. The emitted stages after this change:byte-identical to the base. A test block reads the emitted stages rather than the values to keep it that way, and this is deliberate: once the evaluator ranks booleans, the values alone can no longer tell the two spellings apart — a
$minover a coerced aggregand would answer0, which a loose assertion accepts. Only the stage distinguishes them, so the pin asserts the stage and additionally that no$condappears in either arm.5. The testkit's "refuses every shape it does not model" promise
The head note promises the evaluator refuses rather than answers, and
bsonLtealready honoured it by throwingUnsupportedShapeon an unranked type. The$min/$maxarms did not — they answerednullsilently, which is the worse failure, because the resulting red reads as a defect in the driver under test. That is exactly how this card'smin/maxhalf was first misattributed to the lowering.What was done for types beyond booleans:
bsonRankrefuses everything it does not rank, and both arms route through it, so an unmodelled aggregand now raises instead of collapsing tonull. The rank check runs over every candidate before the fold, so the refusal is a property of the aggregand's type and not of the group's cardinality — a bare fold never compares a one-element group and would have handed back an unmodelled value unexamined, recreating the same silence one layer down. Two tests pin the refusal on aDatecolumn, and a third pins that it is not blanket (number, string and boolean all still answer), so "it refuses" cannot degrade into "it refuses everything".Deliberately not done: dates, ObjectIds and embedded documents are not ranked. Ranking them is modelling work with no card behind it, and the honest state is a loud refusal that names itself.
6. Two independent ablations
Predictions were committed before either mutation (
b87754eb1, empty commit). Each leg mutated one file only, proved the mutation on disk with anchored greps in both directions, ran, restored viagit checkout HEAD --with an absolute path from atrap ... EXIT INT TERM, and proved the restore bygit hash-objectagainst the HEAD blob plus an emptygit diff HEAD.No rebuild is involved in either leg, and that is a property of the wiring rather than an omission: the pin suite imports
./mongodb-aggregation.jsand./mongodb-pipeline-evaluator.testkit.jsas in-package relative specifiers, which vitest resolves tosrc/*.ts, never to this package'sdist/. The only built dependency in the closure is@objectstack/spec/data, which neither leg mutates.Ablation 1 — revert only the
sum/avgcoercion. Anchors:numericAggregandExpr(fieldRef)2 to 0;$sum: fieldRef ?? 01 to 2;$avg: fieldRef ?? 00 to 1; blobde6a296eto7fa018a7.Result: 4 failed | 13 passed, and the 4 are exactly the predicted 4 —
sum(flag) answers 3,avg(flag) answers 0.5,grouped sum/avg answer per group,sum and avg wrap the aggregand in the boolean-only coercion. Every min/max test stayed green, including the bare-lowering pin and the refusal block.Ablation 2 — revert only the
$min/$maxarms to the number-filtered form. Anchors:for (const v of present) bsonRank(v);1 to 0;Math.min(...numbers)0 to 1;Math.max(...numbers)0 to 1; bloba268c296to505c4b4a.Result: 6 failed | 11 passed, and the 6 are exactly the 6 tests the prediction itemised —
min(flag) answers false,max(flag) answers true,grouped min/max, bothunmodelled BSON type raises UnsupportedShapecases, andthe types it DOES rank all answer. Every sum/avg test stayed green, as did the entire emitted-lowering block (it reads stages, and the lowering is not mutated in this leg).One correction, recorded rather than tidied away: the prediction commit itemised 6 tests for ablation 2 but wrote "Expected red count: 5" — an arithmetic slip in the summary line, not a wrong prediction. The named set matched exactly in both directions; no unnamed test went red, and every test predicted green stayed green. The prediction commit is left unamended so the slip stays visible.
Also predicted green in both legs and observed green in both:
min/max over a column that is null or absent everywhere answer null. Both the old and new forms answernullthere, so that case cannot discriminate — recorded so it is not read as evidence.7. A first ablation attempt that did NOT land, recorded
The first ablation-1 script used
perl -0pi -e 's/\Q...\E/.../'and exited 0 having changed nothing — the anchored counts came back identical, the on-disk blob equalled the HEAD blob. The script's own refuse-on-no-op guard caught it and voided the readings rather than running the suite against an unmutated tree, which would have reported a fully green ablation and read as "the assertions cannot fail". Replaced with a substitution helper that refuses unless the anchor occurs exactly once. Recorded because a zero-hit edit at exit 0 is invisible in every artifact except the count.8. Fixture triage on the two pins the coercion reds
mongodb-aggregation.test.tshad two pins spelling thesum/avgaccumulator literally:builds $group with groupBy fieldsandbuilds multiple aggregations. Their actual subject is alias routing and stage shape — which accumulator lands under which key — so the disposition is change the spelling, not replace the pin. The wrapper is written once as a local helper with a note recording thatmin/maxdeliberately do not take it; thebuilds min/max aggregationscase immediately below reads their bare field path and is the pin that says so from that file.Scanned for other consumers by the rule's radius rather than by package:
buildAggregationPipeline/postProcessAggregationhave no consumers outsidepackages/drivers/driver-mongodb/src(the only other repo hits are prose inpackages/spec/src/data/aggregation-conformance.tsand its built.d.ts).9. Standing caveat, carried not solved
runPipelineholds the lowering to the table. It does not answer whether a real mongod agrees, and nothing here claims it does — not in the PR body, not in a test name. This fleet cannot fetch a mongod binary at all (#5517), so every operator it models is read from the MongoDB manual rather than observed,$typeand the BSON-order$min/$maxadded here included. The suite's head note says so.10. Verification
Union re-run on the final commit
dcead01a9, after mergingorigin/main(which moved by one commit touching onlyscripts/pm/dispatch-gates.mjs— no overlap with this diff, no generated artifact):pnpm --filter @objectstack/driver-mongodb test— 20 passed | 5 skipped (25) files; 455 passed | 143 skipped (598) testspnpm --filter @objectstack/driver-mongodb typecheck— cleanpnpm check:type-check-debt— its own verdict lines:check-type-check-coverage: OK — 65/78 workspace packages type-checked ...andcheck-type-check-coverage --re-measure: OK — 31 ledger entr(ies) re-measured in 209.3s, 1570 raw tsc error(s) total, none above its recorded number./surplus: nonepnpm lint(eslint . --no-inline-config, whole repo, not narrowed) — exit 0Gate family derived from the real changeset with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackrather than from a hand-built diff: 22 path-matched families plus 5 convention-triggered ones. All ran green. One line in that list, a barenode scripts/pm/check-half-states.mjs, exited 3 = PREREQUISITE NOT MET (no GitHub credential in that tool's environment) — its own text says "no reading at all", and the gate CI actually runs ischeck:pm-half-states(the--self-testform), which passed.One measurement that would otherwise have been silently false: this package's
typecheckscript excludes**/*.test.tsvia its tsconfig, so a greenpnpm typechecksays nothing about either test file in this diff. Verified withtsc --listFiles: 10 source files in the program, neither test file among them. Measured instead with an ad-hoc program that includes them — it found one real error in the new suite (anAGGREGATION_ROWScast), now fixed by routing throughunknown, the spellingmongodb-aggregation-translation.test.tsalready uses. The 10 remaining errors are pre-existing, in 8 test files this diff does not touch, and match theTEST_DEBTledger entry for@objectstack/driver-mongodbexactly (recorded 10). The ratchet above confirms it mechanically:surplus: none. That exclusion is already documented in the ledger entry's own note, so it is not filed as a new finding.11. Bump:
patch, arguedThe changeset is
patch, and the tension is real: this changes what an existing operation returns, which ordinarily argues forminor.It is graded
patchbecause the returned values were already ruled before this change — #11065 for the arithmetic pair, #11249 for the order statistics — and are stated as shared values in@objectstack/spec/data. Every other face already produced them;driver-memory's sibling repair of this exact cell shipped as a patch (17.2.0, Patch Changes). There is no new API, no option, and no opt-out to describe: nothing here is a feature. The only behaviour a consumer could have depended on is a value this project has ruled wrong and that no other driver produces. Grading itminorwould advertise a capability that does not exist and imply the old answer had standing.The instrument half is a
.testkit.tsfile with no published surface, so it contributes nothing to the bump either way.12. Scope
packages/specuntouched. Read-only for this seat; it is not in the diff.AGGREGATION_ROWSuntouched — no boolean column added. The booleanflagused here lives in this suite's own fixture, exactly asdriver-sql's driver-sql: boolean aggregands need a lowering cast on PG (+ a MySQL min/max presentation check) — the ruledfalse/true+ arithmetic answers are unproducible on the PG face #11635 suite does it.AGGREGATION_ROWShas no boolean column, so the cross-driver aggregation conformance family cannot see a boolean aggregand on any face #11152 untouched. No label, no comment, no file of its. It carriesBlocked-by:to this card and unlocks when this closes; adding the shared boolean column is that seat's work and would land a spec change from the wrong lane.content/docs/releases/,docs/adr/,.claude/,skills/,AGENTS.md,CLAUDE.md— none touched.driver-mongodbanswers an unrecognised aggregate function as a silent SUM instead of refusing it (buildAccumulator'sdefault:arm, reachable becauseAggregationInput.functionis declared as a bare string). Filed unassigned, labelledfindingonly, no domain label and nopm:queue. Deduped by search first; the nearest existing card, finding:StrategyContext.executeAggregatedeclaresaggregations[].methodasstringwhile the engine contract declares the six-valueAggregationFunction#12776, is the upstream declaration half, not this one.Draft, and it stays draft: this seat neither merges nor flips ready.
Generated by Claude Code