Skip to content

fix(driver-sql): an all-NULL sum answers 0 on every face — fold at the aggregate door, conformance cell on every enrolled face (#15546) - #16720

Merged
os-zhuang merged 5 commits into
mainfrom
claude/issue-15546-all-null-sum-zero
Sep 8, 2026
Merged

fix(driver-sql): an all-NULL sum answers 0 on every face — fold at the aggregate door, conformance cell on every enrolled face (#15546)#16720
os-zhuang merged 5 commits into
mainfrom
claude/issue-15546-all-null-sum-zero

Conversation

@os-musk

@os-musk os-musk commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Fixes #15546

Clause-②: yes — a published aggregate output changes on the SQL driver (and on the turso REMOTE transport): a sum over a group whose aggregand is NULL in every row now answers 0 where it answered null.

The ruling this PR executes

Recorded on the card (comment 5572006116, director seat, decision batch #1, 2026-09-07, maintainer reply 「同意」): option A — three rows whose aggregand is absent and zero rows are the SAME case for sum; the answer is 0, the value emptyGroupValueFor already gives the in-memory tier; the driver-sql side moves; the spec reducer is not touched. Execution scope, per the ruling: one PR — the fold in driver-sql's aggregate presentation, a conformance cell asserting the same answer on every face (SQLite, live Postgres, live MySQL, the in-memory tier), changeset minor for @objectstack/driver-sql. The older text on the card that split the conformance change into a separate domain:spec card is superseded by the ruling and was not acted on.

1. The confidence-gap measurement — taken FIRST, before any fix

A five-row probe table (void_unit × 3 with amount [number] and price [currency] NULL in every row; full_unit × 2 with 10/20), grouped by business_unit, through each face's own aggregate door. Every row below EXECUTED — none was a skip.

face how it ran all-NULL group sum (number) all-NULL group sum (currency) empty row set sum
driver-sql / better-sqlite3 in-process null null null
driver-sql / live PostgreSQL 16.13 own server on :54329, timezone=Asia/Shanghai, process TZ=America/New_York null null null
driver-sql / live MySQL 8.0.46 own server on :33069, time_zone=+08:00 null null null
objectql in-memory tier (applyInMemoryAggregation) pure function 0 (number) not probed 0
driver-memory — find() door AND aggregate(AST) door in-process driver 0 (number) not probed 0
driver-mongodb lowering buildAggregationPipeline through the package's in-process pipeline evaluator — the instrument its enrolled suite uses; a model of the stages, NOT a live mongod (none is installed here) 0 (number) not probed no row at all ([])
driver-turso REMOTE (RemoteTransport.aggregate, libsql-is-SQLite stub) the enrolled conformance suite, once its harness stopped coercing (§4) null not probed not probed

avg/min/max answer null on every face and count_distinct answers 0 on every face — sum is the only divergence, exactly as the card said. driver-sql probe run: 9 tests, 9 passed, 0 skipped (3 tests × 3 cells) under OS_EXPECT_LIVE_DIALECT_MATRIX=1, so a missing URL would have been a named red, not a skip. node-pg hands count/count_distinct back as strings ("3", "0") and float sums as numbers — the pre-existing #11456 reading, untouched.

driver-memory and driver-mongodb both answer the ruled 0 today: no divergence to file, and nothing in either package is changed by this PR (scope). The mongodb reading is a reading of the LOWERING, not of MongoDB.

2. Symbols re-derived on the branch point 2e6a2ea4c9 — line numbers as found

  • packages/spec/src/data/aggregation-policy.tsexport function emptyGroupValueFor( at :52. Unchanged by this PR, and unchanged by the five origin/main commits merged since (empty diff, with a positive control on migrations/registry.ts that fired).
  • packages/drivers/driver-sql/src/sql-driver.ts['sum', { sql: 'sum', distinct: false }], at :1324 (unchanged: the fold is not in the lowering table); async aggregate( at :8478; the readPresentationKind(table, agg.field) call at :8653; definitions readPresentationKind :12874, presentReadValue :12901, formatOutput :16655.
  • packages/spec/src/data/aggregation-conformance.tsscore: number; at :200.
  • packages/objectql/src/engine.ts — the typeof drv.aggregate === 'function' && allStructuredSupported && !tzRequiresInMemory && !hasAggregationFilter fork at :13816. Unchanged.

None of these numbers was taken from the card; every one matches the PM's own re-derivation.

3. How the all-NULL cell became reachable through the table

score stays non-null — its all-distinct property is load-bearing for the count_distinct control. The fixture gains a second numeric column, AggregationRow.amount: number | null, NULL in every east row and in two of the four west rows, and AGGREGATION_CASES gains three cases:

  • sum(amount) grouped by region — east is NULL in every row and sums to 0east 0 / west 40 (the ruled cell; west at 40 keeps a face that folded EVERY sum to 0 from passing);
  • count(amount) grouped by region — the nulls are realeast 0 / west 2 (the reachability control: COUNT(col) is defined over non-null values on every backend, so east at 0 proves the seed stored NULLs; a harness that wrote 0 for null answers 2);
  • sum(amount) over the whole table skips the nulls → 40 (the partial-null control, the case that was always green).

Every harness that seeds {...row} into a declared object now declares amount as a nullable numeric column (driver-sql conformance, the #11635 suite, sqlite-wasm, turso remote). The driver-sql harness also reads the nulls back through find() and asserts four null amounts, both east rows among them.

4. Two-leg ablation — and the layer that was holding the observable

Leg 1 (fold in place): driver-sql conformance 85/85 on sqlite + live postgres + live mysql, 0 skipped.

Leg 2, first attempt (fold ablated by neutralising the registration line; on-disk proof: marker ABLATION-15546 0 → 1, anchor 1 → 0, mutated blob d4f0496e… differs from HEAD's d418bec2…): 85/85 STILL GREEN. Per the order I did not call the ablation wrong. The holder was the harness: actualFor coerced value: Number(r.n), and Number(null) is 0 — the ruled answer. The same coercion sat in the turso remote, sqlite-wasm and objectql harnesses; the driver-memory harness alone had refused it, for exactly this reason. All four now keep null as null (r.n === null ? null : Number(r.n); node-pg's string count still coerces).

Leg 2, second attempt (same mutation, same on-disk proof): 3 failed / 82 passed — exactly sum(amount) grouped by region, on sqlite, live postgres and live mysql; nothing else moved. Restore by git checkout HEAD -- path, verified by blob hash d418bec214e6a86349f32aa60da61a8184567f5c equal to the HEAD: blob, git diff HEAD empty, git status --porcelain empty, marker count 0. driver-sql's suite resolves ./index.js from src, so no dist sits on that leg's resolution path.

turso REMOTE legs (its own compiler, remote-transport.ts): leg 1 29/29; leg 2 (same mutation shape, marker 0 → 1) 1 failed / 28 passed — the same cell; restore blob-verified 44612eb6511a783fc0708dbc5e0d30b5adba31e9 equal to HEAD.

sqlite-wasm went red once for a different reason: its tests import @objectstack/driver-sql from dist (the KNOWN_UNALIASED_TEST_IMPORTS ledger) and dist predated the fix. After pnpm --filter @objectstack/driver-sql build, node scripts/ablation-dist-preflight.mjs @objectstack/driver-sql foldEmptyAggregateAnswers reports the marker in 4 built files, and the suite is 25/25.

5. One in-place fix outside driver-sql: the turso REMOTE transport

With a null-preserving harness the shared cell was RED on driver-turso's enrolled REMOTE face: RemoteTransport.aggregate is a second compiler and hands SQL's NULL through. Leaving it would make the ruled cell unlandable on an enrolled face, or green only through the coercion that hid the defect. The four in-place conditions hold: same defect class; mechanical, the same fold shape reading emptyGroupValueFor; no other claim on remote-transport.ts — all five remote branches naming turso have tips from 2026-02 to 2026-05 and their diff against origin/main shows the file as 3892 insertions on main's side, i.e. it did not exist at those tips (positive control: my own branch's diff on the turso test is non-empty); same gate family (the turso conformance suite, already enrolled). File surface added to the claim: packages/drivers/driver-turso/src/remote-transport.ts and packages/drivers/driver-turso/src/turso-remote-aggregation-conformance.test.ts. The changeset carries @objectstack/driver-turso: minor. If the seat wants this split out, the turso half is separable: commit 99d4549570 plus the turso hunk of 3611073dea.

6. Hot-file fence — held

Hunks in sql-driver.ts sit at :23 (import), :8501–8506, :8636–8641 and :8733–8786 — all inside aggregate() and the method added directly under it. The three helper definitions are untouched (now at :12939, :12966, :16720 after the shift). Nothing lands in :9600–:11300 (#16570's region). Nothing had to be stopped and reported on the fence.

7. Changeset

minor for @objectstack/driver-sql because the dispatch order and the ruling say minor. Two further entries are my choice under AGENTS.md's "add a changeset for anything that publishes": @objectstack/driver-turso: minor (§5) and @objectstack/spec: minor (AggregationRow gains a required amount key and AGGREGATION_CASES three cases — a published fixture third-party driver authors run). Strike either at review if unwanted.

8. Verification

  • Gates: derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (the order's .claude/scripts/… path does not exist; the agent file's path was used and the PM confirmed). 80 commands, all exit 0 after two prerequisite refusals were resolved by building every package's dist (72 turbo tasks) — check:dual-build-cjs-loads then passed at 104 entry points across 67 packages; check:type-check-debt OOM'd its whole-repo tsc re-measure at the 4 GB heap (Ineffective mark-compacts near heap limit) and passed at 8 GB (76/80 packages type-checked, 4 in the DEBT ledger, 1 exempt). Reconciliation, verbatim: ✓ dispatch-gates --ran: 80 derived famil(ies) accounted for — 80 run, 0 NOT-MEASURED. on the merged tree 8b73784c03.
  • Package checks: driver-sql, driver-turso and spec tsc --noEmit exit 0; pnpm --filter @objectstack/spec check:generated: all 15 generated artifacts up to date (nothing to regenerate).
  • Suites on the final tree 8b73784c03, with the fix: driver-sql conformance + driver-sql: boolean aggregands need a lowering cast on PG (+ a MySQL min/max presentation check) — the ruled false/true + arithmetic answers are unproducible on the PG face #11635 113/113, 0 skipped, three cells live; turso remote 29/29; sqlite-wasm 25/25; driver-memory 54/54; driver-mongodb 86/86 (three files); objectql in-memory 28/28.

验收备注

  • noted, not filed: node-pg returns count/count_distinct as strings while float sums come back as numbers (the [finding] sql-driver-aggregation-conformance.test.ts hard-codes better-sqlite3, so the aggregate-vocabulary standard never runs on Postgres — its pagination sibling already uses the live-dialect cells #11456 reading) — unchanged here; the harnesses coerce non-null answers as before.
  • noted, not filed: the unaliased branch of SqlDriver.aggregate (no alias) is not folded, for the reason presentedOutput already gives — alias is required by AggregationNodeSchema, and that branch lands under a dialect-dependent column name.
  • noted, not filed: for an UNGROUPED aggregate over an empty row set, driver-mongodb's lowering answers no row at all ([]) where the SQL faces answer one row of count: 0, sum: null — the "database omits the row" shape the emptyGroupValueFor docblock describes, repaired today in the engine's in-memory tier, outside this card's cell.
  • noted, not filed: the dispatch order named .claude/scripts/dispatch-gates.mjs; the script lives at scripts/pm/dispatch-gates.mjs.

🤖 Generated with Claude Code


Generated by Claude Code

…ion, and pin it on every face

SQL SUM skips NULLs and answers NULL once it has skipped every row of a
group, so driver-sql answered null where the engine's in-memory tier,
driver-memory and driver-mongodb answer 0 and emptyGroupValueFor rules 0.
Measured null on better-sqlite3, live PostgreSQL 16.13 and live MySQL
8.0.46. Ruled option A on #15546 (maintainer, 2026-09-07): the SQL face
moves. The fold reads the identity from emptyGroupValueFor at the
aggregate door; the statement is unchanged.

The conformance fixture gains a nullable numeric column, amount, NULL in
every east row, and three cases pin the ruled answer on every enrolled
face; every harness that seeds the rows declares the column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…mily and in-memory harnesses

Number(null) is 0 — the ruled answer for the all-null sum cell — so the
unconditional Number(r.n) coercion made that cell green with the
driver-sql fold ablated (measured 85/85 green against a driver answering
null). The harnesses, not the faces, were holding the observable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…ke the local face

TursoDriver picks the remote compiler or SqlDriver's from url, so without
this the same driver answered the all-NULL sum 0 locally and null
remotely. Measured null on the enrolled remote face with a
null-preserving harness. Same fold, same policy read (#15546).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/driver-sql, @objectstack/driver-turso, @objectstack/spec, touching 7 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 6ba0db4e0b6622b6e7adfbea875f1681cd484141.

3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

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

Which tree this was computed on

This run read content/docs from 492599f4d60e44773d53e18455c4c90876a2e578 — the merge of head 8b73784c030641eb8a9db3a1f75805aca39a2bf2 into base 6ba0db4e0b6622b6e7adfbea875f1681cd484141, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 492599f4d60e44773d53e18455c4c90876a2e578 && git checkout 492599f4d60e44773d53e18455c4c90876a2e578
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6ba0db4e0b6622b6e7adfbea875f1681cd484141 8b73784c030641eb8a9db3a1f75805aca39a2bf2 && git checkout -B drift-repro 6ba0db4e0b6622b6e7adfbea875f1681cd484141 && git merge --no-ff 8b73784c030641eb8a9db3a1f75805aca39a2bf2

node scripts/docs-audit/affected-docs.mjs --json 6ba0db4e0b6622b6e7adfbea875f1681cd484141

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6ba0db4e0b6622b6e7adfbea875f1681cd484141 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Contributor

Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16720 @ 8b73784

Verdict: PASS WITH FINDINGS
Ruling implemented: yes — with one execution-lane extension (F1) the maintainer's text did not name, ruled in-lane by the PM seat and recorded here for the merger.

Everything below was re-derived from the PR head (refs/pull/16720/head = 8b73784c030641eb8a9db3a1f75805aca39a2bf2, fetched into a throwaway ref and read with git show/git diff), the issue thread, the workflow files and the check-run API — not from the PR body.

0. The ruling, verbatim (issue #15546, comment 5572006116, hotlong, 2026-09-07T14:21:45Z)

Ruling recorded — A: a non-empty group whose aggregand is NULL in every row sums to 0, on every face; the driver-sql side moves (director seat, summon #17, decision batch #1, 2026-09-07)

Provenance (who / verbatim / where): maintainer, live PM chat with the director seat (session_01XesLUWmuhjuRwmU618AZ1M), 2026-09-07T14:2xZ, batch #1 presented as 1A · 2A · 3B · 4A · 5A with this card as item 2 recommending A (the 5568548951 four-facet block); reply, verbatim: 「同意」.

Ruled. "Three rows whose aggregand is absent" and "zero rows" are the same case for sum: there is nothing to add, so the answer is 0 — the value emptyGroupValueFor already gives the in-memory tier (packages/spec/src/data/aggregation-policy.ts:52-56, read on origin/main). @objectstack/driver-sql folds an all-NULL sum to 0 at its read door so driver-sql and the engine's in-memory aggregate tier answer identically; the spec reducer is not touched. Option B (blank everywhere, change the reducer — a protocol change) is refused.

Execution, domain:engine lane: one PR — the fold in driver-sql's aggregate presentation, a conformance cell in packages/spec/src/data/aggregation-conformance.ts asserting the same answer on every face (SQLite, live Postgres, live MySQL, the in-memory tier), changeset minor for @objectstack/driver-sql. Clause-②: yes (a published aggregate output changes on the SQL driver) ⇒ needs:contract-review hung with the PR, review at CONTRACT_REVIEW_TIER or the quota-exhaustion path with the carrier as compensation. The dispatch order carries the confidence gap as the dev's first measurement: the same cell on Postgres and MySQL, and the two frozen drivers.

Labels: needs-user-decisionpm:queue in one write, read back. Blocked-by: none. Governing text: aggregation-policy.ts:52-56 (unchanged by this ruling). Ledger: objectstack#12708, summon #17.

1. Ruling → diff, item by item

ruling item on head verdict
fold in driver-sql's aggregate presentation (read door, not the statement) SqlDriver.aggregate registers foldedOutput per aliased aggregation and applies foldEmptyAggregateAnswers after the rows return, before presentReadColumns; the ['sum', { sql: 'sum', distinct: false }] lowering and the emitted SQL are unchanged (no COALESCE) implemented
conformance cell in aggregation-conformance.ts, every face AggregationRow.amount: number | null, fixture rows extended, three new AGGREGATION_CASES (§3) implemented
emptyGroupValueFor / the spec reducer not touched git diff 6ba0db4e0..head -- packages/spec = the conformance file only; aggregation-policy.ts byte-identical implemented
changeset minor for @objectstack/driver-sql .changeset/driver-sql-all-null-sum-folds-to-zero.md: "@objectstack/driver-sql": minor implemented
Clause-②: yes carried first line of the PR body present

Widenings beyond the ruling's named surfaces:

  • packages/drivers/driver-turso/src/remote-transport.ts + its harness — the ruling names @objectstack/driver-sql as "the side that moves" and does not name driver-turso. But the ruling also requires the cell to hold "on every face", and the conformance file on origin/main enrolls driver-turso Turso REMOTE compiler — RemoteTransport.aggregate on its own account (its LOCAL mode is deliberately not enrolled because it inherits SqlDriver). With a null-preserving harness that enrolled face is red without a fold. I read this as within the ruling's "conformance cell on every enrolled face" scope, not an unruled scope expansion; the PM seat ruled the same at 5577543465 Q1. Recorded as F1 because it is a second published package moving under a ruling that named one.
  • packages/spec/src/data/aggregation-conformance.ts — explicitly named by the ruling ("a conformance cell in packages/spec/src/data/aggregation-conformance.ts"). Not a widening. The card's older "separate domain:spec card" text is superseded by the ruling, as the PR says.
  • @objectstack/spec: minor and @objectstack/driver-turso: minor changeset entries — not in the ruling; graded in §4 / F2.

2. The fold (sql-driver.ts, measured on head)

Hunks, from git diff -U0: @@ -23 +23 @@ (import), @@ -8500,0 +8501,6 @@, @@ -8629,0 +8636,6 @@, @@ -8721 +8733,54 @@. All inside aggregate() (:8478) and the new method directly under it (:8778). Nothing in :9600–:11300 (the #16570 fence). The three read-presentation helpers sit at :12939 / :12966 / :16720 on head and are untouched (the #16619 fence held too).

  • (a) Only sum folds. foldedOutput.set(agg.alias, identity) runs only when emptyGroupValueFor(funcName) !== undefined, i.e. for count, count_distinct, sum. avg/min/max return undefined from the policy, are never registered, and their NULL passes through. count/count_distinct are registered but COUNT never answers NULL (node-pg hands back the string "0"), so the entry is inert for them — the docblock says so, deliberately.
  • (b) Read from spec, not hard-coded. import { AggregationFunction, emptyGroupValueFor } from '@objectstack/spec/data' at :23; the fold value is emptyGroupValueFor(funcName); no literal 0 appears in the fold path.
  • (c) Aliased branch only. The registration sits inside if (agg.alias) { … }; the unaliased branch is untouched. The stated reason checks out: AggregationNodeSchema.alias is z.string() — required — (query.zod.ts:275), so the unaliased branch is unreachable through the protocol and lands under a dialect-dependent column name; the pre-existing presentedOutput comment makes the same call.
  • (d) foldEmptyAggregateAnswers folds only === null (an undefined column stays visible), mutates in place as presentReadColumns does, and is protected so SqliteWasmDriver and TursoDriver (LOCAL) inherit it.

Observation: aggregate() emits no HAVING; the engine's applyHaving runs on the driver's returned rows, so a having on a folded sum now sees 0 on the SQL face exactly as on the in-memory face. Consistent, no action.

3. Conformance fixture and every enrolled harness

aggregation-conformance.ts on head: amount: number | null at :223; rows 1..6 carry amount = 10, null, 30, null, null, null (east = rows 5,6 → all null; west = 10+30 = 40, two nulls). Three new cases at :525–:565: sum(amount) grouped by regioneast 0 / west 40; count(amount) grouped by regioneast 0 / west 2; sum(amount) whole table → 40. score unchanged (10,20,30,40,50,60, all distinct, score: number at :201).

Harness audit — declares amount nullable numeric / keeps null as null:

harness declares amount null coercion on head
driver-sql sql-driver-aggregation-conformance.test.ts (sqlite/pg/mysql cells) amount: { type: 'number' }, no NOT NULL r.n === null ? null : Number(r.n); read-back asserts 4 null amounts, 2 of them east
driver-sql #11635 suite amount: { type: 'number' } (seeds the verbatim rows) does not iterate AGGREGATION_CASES; its Number(...) asserts are on boolean/score cases — not this cell
driver-sqlite-wasm amount: { type: 'number' } r.n === null ? null : Number(r.n)
driver-turso REMOTE amount: { type: 'number' } r.n === null ? null : Number(r.n)
objectql in-memory n/a (pure function over AGGREGATION_ROWS) r.n === null ? null : Number(r.n)
driver-memory (both doors) n/a (InMemoryDriver.create({...row})) never coerced (value: r.n, by design since #6814)
driver-mongodb lowering n/a (evaluator over AGGREGATION_ROWS) raw row[MEASURE], never coerced; iterates every case

No enrolled harness coerces Number(null)0 any more. The cell is not vacuous on any face.

4. Changeset level for @objectstack/spec

Batch #35 ruling text (pr-automation.yml:660–682): "A purely additive widening of a published package's public surface (a new exported symbol on an index, a new accepted key or value) takes at least minor. … During the launch window major stays refused by check-changeset-no-major and breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition, not by the level." scripts/check-changeset-no-major.mjs header: "Until then [GA] … a required member on a published interface … grades major" only after GA; now it is minor plus a BREAKING banner if breaking.

AggregationRow (interface), AGGREGATION_ROWS, AGGREGATION_CASES are all on packages/spec/api-surface/data.json — published. The act is additive on the fixture (new key on every row, three new cases): minor is the floor and is what the PR grades. Is a required amount on AggregationRow breaking? Only for a consumer that constructs AggregationRow values; no consumer in the tree does (every harness reads AGGREGATION_ROWS), the docblock defines the interface as the shape of the shipped fixture, and the third-party consequence — a harness that does not declare amount goes red on the three new cases — is the conformance table doing its job, stated in the changeset body ("A harness that runs the table MUST declare amount as a nullable numeric column and seed its nulls AS nulls"). I grade minor correct, no **BREAKING** banner / ADR-0087 disposition owed. The flag column precedent (#12947, spec patch, 2026-08-28) is pre-rule and does not bind. CI's LEVEL AXIS did grade the spec entry (green); it is blind to packages/drivers/* (#16713), so the two driver minor entries are ungraded by CI but follow the same rule.

5. Files in the diff (9) and governed paths

.changeset/driver-sql-all-null-sum-folds-to-zero.md (A), packages/drivers/driver-sql/src/sql-driver-11635-boolean-aggregand-answers.test.ts, packages/drivers/driver-sql/src/sql-driver-aggregation-conformance.test.ts, packages/drivers/driver-sql/src/sql-driver.ts, packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-aggregation-conformance.test.ts, packages/drivers/driver-turso/src/remote-transport.ts, packages/drivers/driver-turso/src/turso-remote-aggregation-conformance.test.ts, packages/objectql/src/in-memory-aggregation-conformance.test.ts, packages/spec/src/data/aggregation-conformance.ts.

Governed paths touched: NO — none of docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/**.

6. Tests: ablation reasoning and CI measurement

Ablating the driver-sql fold makes SQL's SUM(amount) for east arrive as null; with the harness now keeping null, actualFor yields { east: null }{ east: 0 } → red on exactly sum(amount) grouped by region — east is NULL in every row and sums to 0. The whole-table case (40) and count(amount) (COUNT never NULL) stay green; every score case is non-null. One case × three dialect cells = 3 failed, matching the PR's 3/82. The west: 40 expectation also refuses a fold-everything-to-0 implementation.

Is the cell measured in CI, not only locally? Yes. ci.yml Temporal Conformance (live PG + MySQL) (:894) runs pnpm --filter @objectstack/driver-sql test — the whole suite — against service containers with OS_TEST_POSTGRES_URL, OS_TEST_MYSQL_URL and OS_EXPECT_LIVE_DIALECT_MATRIX: '1'; live-dialect-matrix.testkit.ts turns a missing URL into a named failure under that flag. The job is gated on filter.outputs.core, which packages/** sets. Test Core shards carry the sqlite cell plus the turso, sqlite-wasm, objectql, driver-memory and driver-mongodb harnesses. All of those are green on head.

7. CI on 8b73784c0 and base drift

Check runs: 50 total — 36 success, 14 skipped, 0 failure, 0 in_progress. Skipped are path-filtered/opt-in only (Auto Label ×5, Check PR Size ×5, Packed-tarball smoke ×2, Console Pin Gate, Build Docs). Red or pending: none. Named greens: Temporal Conformance (live PG + MySQL), Test Core 1–6/6, Build Core, Type Check ×4, Lint & Repo Gates, Check Changeset, Governed Surface Queue Guard, single-writer-path and same-issue guards, Spec property liveness, Dogfood ×3 + Verify CLI.

Base: merge-base with origin/main is 6ba0db4e0 (the PR's base SHA); origin/main is 18 commits ahead (73053ed27). Only one of them touches a file in this diff: 45cfa1b88 (#16619, sql-driver.ts), whose hunks are at :264–:404, :4147, :5309 and :12863+ (the read-presentation helpers) — not in aggregate(). aggregation-conformance.ts, aggregation-policy.ts and remote-transport.ts are untouched on main since the base. git merge-tree origin/main head merges clean. The head does not yet contain #16619; nothing overlaps.

Findings

  • F1 — second package moved under a ruling that named one. RemoteTransport.aggregate (driver-turso) carries a twin fold. It is inside the ruling's "every enrolled face" scope (Turso REMOTE is enrolled on its own account in the conformance file) and the PM seat ruled keep at 5577543465; the maintainer's text itself names only driver-sql. Expectation: keep as is; the merger should be aware they are landing a driver-turso behaviour change (null0 on the remote face) under this card. If it is ever split, the harness must keep null as null — the split must never be made green by restoring the coercion.
  • F2 — @objectstack/spec: minor is the right level and needs no BREAKING banner (§4). Expectation: none required. Optional: one clause in the spec paragraph of the changeset body naming AggregationRow.amount as a required member of the interface (the PR body says it; the changeset says "gains a NULLABLE numeric column").
  • F3 — driver-memory fixture read-back does not assert the amount nulls (it checks region/stage/score only). Not vacuous: the count(amount) control answers east 0 through both doors only if the nulls were stored. Expectation: none required; a one-line amount assertion in that read-back would mirror the driver-sql harness.
  • F4 — count/count_distinct are registered in foldedOutput though they never arrive as NULL. Deliberate and documented; inert. Expectation: none.
  • F5 — the two driver minor entries are not graded by CI's LEVEL AXIS (PUBLISHED_SOURCE_PATH does not match packages/drivers/*, [finding] The changeset LEVEL axis is blind to every NESTED package: packages/*/src/** matches one segment, so 51 of 74 workspace packages (all drivers/services/adapters) can pair Clause-②: yes with patch and stay green #16713). They are correct under batch [WIP] Add query enhancements and advanced validation features #35 by the same reading. Expectation: none for this PR.
  • F6 — the PR body's confidence-gap table calls the mongodb reading "the LOWERING, not MongoDB" and the mongodb evaluator's $sum reduces from 0 over the numeric subset, so that face's 0 is a model of the stages, as stated. Expectation: none; the caveat is already on the record.

Hard limits observed: no approval, no review-with-changes, no merge, no label/body edits, no file edits. One comment.


Generated by Claude Code

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

Labels

Projects

None yet

3 participants