feat(spec): accept boolean / toggle for sum / avg / min / max in the aggregate × field-type table (#16685) - #16750
Conversation
…aggregate × field-type table (#16685) Decision batch #80 (2026-09-08) holds ruling #11152 - booleans aggregate as numbers on every backend, no per-aggregate exception - over batch #59's blanket "every other pair refused", which never named booleans. The four arithmetic / order rows gain the BOOLEAN_VALUE_TYPES members; the module TSDoc and the table's pending changeset no longer publish the refusal; the pins hold the boolean rows both literally and against AGGREGATION_CASES. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
📓 Docs Drift Check2 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 — 130 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 754fb97f65a777b26361966e8d94dc5e4aad6a15 && git checkout 754fb97f65a777b26361966e8d94dc5e4aad6a15
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cf33adbd8ea74306f60e1f0b9c827787aeaa383d bee6b0b091a3240e880416cb212ee91d41247168 && git checkout -B drift-repro cf33adbd8ea74306f60e1f0b9c827787aeaa383d && git merge --no-ff bee6b0b091a3240e880416cb212ee91d41247168
node scripts/docs-audit/affected-docs.mjs --json cf33adbd8ea74306f60e1f0b9c827787aeaa383d |
Contract review (
|
| aggregate | boolean |
toggle |
|---|---|---|
sum |
refused → accepted | refused → accepted |
avg |
refused → accepted | refused → accepted |
min |
refused → accepted | refused → accepted |
max |
refused → accepted | refused → accepted |
count / count_distinct stay at 49/49 (ANY_FIELD_TYPE, untouched). Row sizes: sum 6→8, avg 7→9, min 10→12, max 10→12. The mechanism is one new constant BOOLEAN_AGGREGATE_FIELD_TYPES = ['boolean','toggle'] (packages/spec/src/data/aggregate-field-type-compatibility.ts:142-144) spread into the four rows (:161-168); NUMERIC_ / ADDITIVE_ / TEMPORAL_ are byte-identical to base. AGGREGATION_CASES, driver-sql, analytics.zod.ts, seed.zod.ts are not in the diff. None of the five relevant files moved on main between the merge base and current main (8ccf7a1), so the base reading is current. "Nothing else moves" holds.
2. Does the table agree with what ruling #11152's suite actually asserts? — yes, with one attribution caveat (F1)
Read packages/spec/src/data/aggregation-conformance.ts on main, not the PR body. The flag column (AGGREGATION_ROWS, 3 true / 3 false, :230-235) is exercised by seven cases at :358-430: sum(flag)=3, avg(flag)=0.5, min(flag)=0, max(flag)=1, count(flag)=6, count_distinct(flag)=2, and min(flag) grouped by region (east=1, west=0). Every one of those pairs is accepted by the head table. The six enrolled faces listed at :87-118 each iterate for (const c of AGGREGATION_CASES) with no flag filter (verified in all six harness files; driver-sql's runs on SQLite + PG + MySQL cells when provisioned, no case dialect-gated). The ruled driver-sql Postgres cast is at sql-driver.ts:8670-8691 on current main (the card's :8497 is line drift only).
3. Consumer legs — not landed, not touched, not owed
git grep at the PR head for AGGREGATE_FIELD_TYPE_COMPATIBILITY / isAggregateCompatibleWithFieldType outside the module and its test finds only the data/index.ts barrel and the generated api-surface / export-origins entries. No hit under packages/lint/**, services/**, packages/objectql/**, runtime/**. #16099 is open and pm:blocked; the devx lint leg has no file yet. So the PR lands the widened table before either consumer executes it — which is the whole point of the ruling's time box. Nothing downstream to keep green.
4. Changeset and governed paths
.changeset/aggregate-boolean-members.md:"@objectstack/spec": minor— package name matchespackages/spec/package.json; the body states the before (refused via batch Validation Protocol: Cross-Field, Async, and Conditional validation #59's default) and after (accepted, with the row table) explicitly. Not breaking (accept set only widens;dist/*.d.tstype unchanged), so no ADR-0087 marker owed;Check Changesetgreen.minoris correct..changeset/aggregate-field-type-compatibility.md: edited outside the claim's two-file surface. Verified it is still pending (0 hits for the export name inpackages/spec/CHANGELOG.mdonmain), so this corrects unreleased text rather than rewriting history; the diff is two prose lines and the string-class override paragraph is verbatim. Declared in the PR body. Acceptable.- Governed paths: none touched. No
docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md,content/docs/releases/**in the diff.Governed Surface Queue Guardgreen. This is not a maintainer-only merge on governed grounds.
5. Tests — what the file actually pins (re-read, not trusted)
packages/spec/src/data/aggregate-field-type-compatibility.test.ts at head. If the boolean members are removed again (whole class, or either spelling, or from any one of the four rows), these six it blocks redden:
:76-79sumliteral equalsADDITIVE ∪ BOOLEAN:81-83avgliteral equalsNUMERIC ∪ BOOLEAN:85-88minandmaxliterals equalNUMERIC ∪ TEMPORAL ∪ BOOLEAN:108-114BOOLEAN_VALUE_TYPESliterally equals['boolean','toggle']and each of the four rows' boolean members equals that set:131-136predicate accepts all four aggregates over both spellings:138-151cross-pin: the set of functions overflaginAGGREGATION_CASESequals all six, and each such function is accepted forbooleanandtoggle
The cross-pin also reddens in the other direction — if someone later removes the flag cases from AGGREGATION_CASES (option B), :144-146 fails. The old refusal pins are gone (:153-159 now lists only computed/text/structured types; :206-213 keeps only the string-class override). The six-test count the PR body reports for its ablation is consistent with the file; I could not execute it locally (no node_modules in this container), so execution rests on CI, where the spec suite runs inside Test Core, all six shards green.
Findings (none blocking)
- F1 —
toggleis not directly pinned by the conformance suite. Every harness declaresflagastype: 'boolean'; no harness has atogglecolumn. The cross-pin's comment (test.ts:139-142, "each case over it is a pair [finding]AGGREGATION_ROWShas no boolean column, so the cross-driver aggregation conformance family cannot see a boolean aggregand on any face #11152 pins on six backends") is accurate forbooleanand over-attributes fortoggle. Thetogglecells are still sound:toggle ∈ BOOLEAN_VALUE_TYPES(field-value.zod.ts:68-70), anddriver-sqlcollectstoggleintobooleanFieldsalongsideboolean(sql-driver.ts:9636, column mapping:2364), so the Postgrescast(?? as int)at:8685-8691fires for it. The ruling namestoggleexplicitly, so the table is right to carry it. Expectation for this PR: none required ("nothing else moves" forbids touchingAGGREGATION_CASES); an optional one-line comment fix attest.ts:139-142saying thetogglehalf rests on the boolean-class membership rather than a suite row. Atoggleconformance row is a driver-family card, not this one. - F2 — "six backends" carries the suite's own caveat.
aggregation-conformance.ts:97-105records that thedriver-mongodbcell is lowering-only (in-process evaluator, "does not answer 'does MongoDB agree?'"). The head TSDoc (:59-65) and the changeset repeat "enrolled on six backends" without that qualifier. Informational; it is not a table-cell claim and it restates the suite's own enrolment list. - F3 — bucket pins were weakened and re-strengthened correctly. The numeric pin (
test.ts:90-97) and temporal pin (:99-106) now subtract the boolean class before comparing; the new boolean-bucket pin (:108-114) holds the class literally, so a type joining any of the threefield-value.zodclasses elsewhere still reddens this file. Verified, no action. - F4 — surface deviation, accepted. Parent changeset edit (see §4): pending, prose-only, declared.
- F5 — CI. 38 check runs at
499d718: 30 success, 7 skipped (opt-in / path-filtered), 0 failure;Lint & Repo Gatesstillin_progressat time of review (running ~25 min). This verdict is conditional on that gate finishing green; the rest of the matrix — Build Core, all sixTest Coreshards, all Type Check jobs, Temporal Conformance (live PG + MySQL), Check Changeset ×2, Governed Surface Queue Guard, Spec property liveness — is green.
No approval, merge, label or file action taken by this seat.
Generated by Claude Code
…rs changeset; derive the flag-case vocabulary pin from AggregationFunction (#16685) Contract-review patch round. The changeset claimed dist/*.d.ts was byte-identical; it is not - the rewritten module TSDoc ships in dist/data/index.d.ts. It now states what holds: the exported declarations are unchanged, the private BOOLEAN_AGGREGATE_FIELD_TYPES constant is absent from the bundle, and api-surface / export-origins are untouched. The test header claims only what the cross-pin reaches (the boolean axis) and the flag-case vocabulary is derived from AggregationFunction.options instead of a literal six-member list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
…— dist/*.d.ts and the bundles' export lists, not the bundle (#16685) Contract-review patch round 2. The constant does ship inside the bundles as a non-exported binding, so "absent from the bundle" over-claimed; the changeset now says only what was measured: absent from dist/*.d.ts and from the bundles' export lists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
Brings #16750 (boolean/toggle rows added to sum/avg/min/max in the aggregate x field-type compatibility table), which settles the boolean half of the collision this branch reported. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…class it actually withdraws The compile leg was scoped to TEMPORAL source fields in 80ec9f2, but the changeset and the ADR-0087 ledger entry still described the pre-scoping full-table gate. A breaking-change record that overstates what changed tells every consumer reading the release notes that behaviour was withdrawn which was not. F1 — the breaking declaration: - changeset FROM/TO: the two `percent` rows dropped (`sum` x `percent` is a table row this leg does not execute; `avg` x `percent` was never a migration at all), and the surviving `avg` row widened to name all three temporal members it does refuse. - changeset: a new scope section states the temporal scope outright, and that the string rows sit under #16785 (ruled C - the table is to be AMENDED to accept them), the boolean rows were settled as ACCEPT by #16685 / #16750, and `sum` x `percent` is not executed here. - ledger `surface`: scoped to the temporal class; "sum over a percent" removed. - ledger `replacement`: the "`avg` for a `percent`" prescription dropped with the surface rows it belonged to. - ledger `acceptanceCriteria`: qualified to a measure over a `date` / `datetime` / `time` field, and says outright that a field of any other class is neither refused nor certified by this leg. - `registry.ts` REGENERATED with `pnpm --filter @objectstack/spec gen:migration-registry`, never hand-edited; two consecutive runs are byte-identical and the diff is confined to this entry's block. - also corrected: the changeset said "four contradictory annotations"; the sweep reconciled seven source sites plus two test narratives. F2 — the boolean collision is settled, so stop narrating it as live. #16685 was ruled A and #16750 added `boolean` / `toggle` to the four arithmetic / order rows, so the table ACCEPTS them. Reworded in `dataset-compiler.ts`'s scope docblock, the refusal suite's module header, and `measure-result-type.ts` (whose boolean paragraph still referred a missing refusal onward). All three now also record #16785 C for the string rows. F3 — the ledger `reason` presented both dialect halves as measured alike. The SQLite half is pinned by a live `sql.js` suite; the Postgres 42883 half was measured in-session and is pinned by nothing. Said so where it is stated. F4 — the scope-boundary test asserted `isAggregateCompatibleWithFieldType( 'min', 'text') === false`, a verdict #16785 C is about to amend. Dropped: the case now pins only what this PR owns - a non-temporal field is not judged, so the measure compiles and SQL is emitted. Refutability is carried by a second case on `sum` x `text`, a row no ruling is moving, plus a non-vacuity assertion that SQL reached the driver in both. F5 — the changeset now names the two uncovered faces: `/analytics/query` and any `compileDataset` caller wiring no `declaredFieldType` probe. Refs #16737. Review: PR #16778 contract review, comment 5580295870. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Fixes #16685
What changed
AGGREGATE_FIELD_TYPE_COMPATIBILITY(packages/spec/src/data/aggregate-field-type-compatibility.ts) accepts the boolean class —boolean,toggle— on its four arithmetic / order rows:sum,avg,min,max. Nothing else in the table moves;count/count_distinctwere already total overFieldType.Executing the director-seat ruling of decision batch #80 (2026-09-08, comment
5577120138on the card, maintainer verbatim 「其他同意」, option A): batch #59's "every other pair: refused" was a blanket default that never named booleans; ruling #11152 (2026-08-28) — booleans aggregate as NUMBERS on every backend, no per-aggregate exception — is the specific ruling and stands. The spec's ownAGGREGATION_CASESpinssum(flag)=3,avg(flag)=0.5,min(flag)=0,max(flag)=1on six backends anddriver-sqlcasts the aggregand on Postgres (#11635) to make that hold.AGGREGATION_CASESand the driver cast are untouched (「nothing else moves」). Option B is not taken.Diff, four files:
packages/spec/src/data/aggregate-field-type-compatibility.ts— aBOOLEAN_AGGREGATE_FIELD_TYPESconstant in the file's existing spelled-out-class idiom, spread into the four rows. The module TSDoc argued the opposite of the ruling in two places (the "everything else" bullet listed booleans among the refused; a dedicated passage recorded the two-ruling collision as unresolved). Both are rewritten to the ruling: the rule table names the boolean class on each row, a boolean bullet cites [finding]AGGREGATION_ROWShas no boolean column, so the cross-driver aggregation conformance family cannot see a boolean aggregand on any face #11152 as the authority and batch Add modern field types (slider, qrcode, geolocation) with configuration schemas #80 as the ruling that resolved the collision, and the "overrides" passage now records only the one override that remains (the string classes onmin/max).packages/spec/src/data/aggregate-field-type-compatibility.test.ts— the literal row pins gain the boolean members; the numeric / temporal bucket pins exclude the boolean class before comparing toNUMERIC_VALUE_TYPES/ the temporal classes; a new pin holds the boolean bucket equal toBOOLEAN_VALUE_TYPESand present on all four rows; the boolean refusal pins become accept pins; and a cross-pin againstAGGREGATION_CASESasserts that every boolean case the conformance suite requires a backend to answer is a pair this table accepts — the two spec tables can no longer contradict each other..changeset/aggregate-boolean-members.md— new,@objectstack/spec: minor(reasoning below)..changeset/aggregate-field-type-compatibility.md— the table's own PENDING changeset (still in.changeset/, so not yet in any CHANGELOG) said "Booleans are refused … the row ships exactly as batch Validation Protocol: Cross-Field, Async, and Conditional validation #59 stated it" and listedsum×booleanamong the refused examples. Both would have published a falsified statement in the same release this lands in, which is the argument the card itself makes about the TSDoc. Corrected in place: the boolean sentences now point at [finding]AGGREGATION_ROWShas no boolean column, so the cross-driver aggregation conformance family cannot see a boolean aggregand on any face #11152 / batch Add modern field types (slider, qrcode, geolocation) with configuration schemas #80; two example pairs in the narrowing sentence are replaced by pairs that remain genuine refusals (sum×boolean→sum×text,min×text→min×json); the string-classes sentence is kept verbatim while the paragraph's lead moves from "Two refused rows…" to "One refused row…". Declared here as a deviation from the card's two-file surface; the edit is prose only.Changeset level —
minor, judged from the diffNo export is added, removed or renamed:
pnpm --filter @objectstack/spec check:generatedreports all 15 artifacts up to date at499d718(api-surface/,export-origins/,declaration-map/,authorable-surface/, docs references all unchanged) — the exported declarations are unchanged (the table's declared type is the same; only its value gains members) and the newBOOLEAN_AGGREGATE_FIELD_TYPESconstant is module-private — absent fromdist/*.d.tsand from the bundles' export lists (it does ship inside the bundles as a non-exported binding, measured at review). The shippeddist/*.d.tsis NOT byte-identical: it carries the rewritten module TSDoc (a contract-review measurement at499d718; the first version of this section claimed byte-identity on the strength ofcheck:generated, which compares export listings and signatures, not.d.tsbytes — retracted). The accept set only widens (every pair accepted before is still accepted), and widening a published accept set is the same class of change the table's introduction was (decision batch #35 puts additive widening atminor). It rides the same release as the parent changeset, so the version outcome is identical either way;minoris the honest classification. Not breaking, so no ADR-0087 marker is required (check-adr-0087-registration --base origin/mainexit 0).Clause-② —
yesAdding members widens a published accept set; the card's claim declares
Clause-②: yesand this PR carriesneeds:contract-reviewon both carriers.check-widening-tellswould not fire (declaration isyes), and the widening is exactly the ruled one: two members, four rows.Verification
Three commits on the branch:
499d718(the implementation),299ed0b(contract-review patch round 1: a changeset paragraph, a test header comment, one assertion line),bee6b0b(patch round 2: one changeset sentence). The full derivation and suite below ran at499d718; the two patch rounds ran the families that read the paths they touched, recorded here so the narrowing is visible on the PR and not only in a report.Gate narrowing on the patch rounds — declared.
dispatch-gates.mjs --commandsderives 75 families for the branch's whole change set and has no per-path narrowing flag, so the patch-round subsets were hand-selected. At299ed0b, 22 commands, all exit 0:check-adr-0087-registration.mjs,check-changeset-no-major.mjs,check-empty-changeset.mjs(each--base origin/mainand--self-test),check:changeset-gate-self-tests,check:objectui-changeset,check-closing-keyword-parity.mjs(+--self-test),check-comment-mask-adoption.mjs(+--self-test),check-comment-mask-corpus.mjs,check-keyed-text-bounds.mjs(+--self-test),check:doc-authoring,check:nul-bytes, and the test-file familiescheck:cross-package-test-inputs,check:test-source-alias,check:where-matcher,check:objectql-double-limit,check:type-source-resolution; plus the pin file (Tests 21 passed (21)) andpnpm --filter @objectstack/spec typecheck(exit 0). Atbee6b0b, the 17 of those that read.changeset/**or repo-wide text (the same list minus the five test-file families), all exit 0. The other 53 derived families are keyed on thepackages/spec/src/**export / docs / liveness / authorable surfaces, on built output (check:dts-closure,check:dual-build-cjs-loads,check:sourcemap-no-sources-content), or onpackages/**source scans — a.changeset/*.mdedit reaches none of them, and thepackages/**scans that do read a test file ran in CI: at299ed0ball six required contexts (Lint & Repo Gates,TypeScript Type Check,Test Core,Build Core,Dogfood Regression Gate,Temporal Conformance (live PG + MySQL)) aresuccess. At499d718all 75 ran: 73 exit 0, 2 NOT MEASURED (prerequisite not met, CI-owned) — itemised below.pnpm --filter @objectstack/spec build—VERDICT command-exit 0(underos-verify-lock.sh).pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/data/aggregate-field-type-compatibility.test.ts—Test Files 1 passed (1),Tests 21 passed (21).pnpm --filter @objectstack/spec test(thelocalvitest project, the package's own suite) —Test Files 465 passed (465),Tests 12955 passed (12955), 411s,VERDICT command-exit 0.pnpm --filter @objectstack/spec typecheck— exit 0 (tsc --noEmit,check:scripts-typecheck,check:test-typecheckall green; the test file is insidetsconfig.test.json's program).pnpm --filter @objectstack/spec check:generated—All 15 generated artifacts are up to date(exit 0); nothing regenerated, nothing to commit.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths — the change set is taken from the merge base, 4 paths) printed 75 commands; every one was run with the printed spelling recorded byte-for-byte;--ranreconciles75 derived, 75 run, 0 NOT-MEASURED, 0 UNRUN(exit 0). 73 exit 0. Two are NOT MEASURED, both exit 3 PREREQUISITE NOT MET and both needing the whole 87-package closure built, which is CI's run (Build Core,lint.yml):pnpm check:dual-build-cjs-loadsandpnpm check:type-check-debt(--re-measure). Two others were cleared after their prerequisite was met:check-plugin-teardown-shape.mjs --self-test(exit 1 on the shallow clone, exit 0 after fetching its pinned fixture commit621a487, 47 cases) and@objectstack/lint check:doc-formula-expressions(exit 3 before, exit 0 after buildingformula+lint).git grepfor the table and predicate overpackages/**finds only this module, its test and thedata/index.tsbarrel — the two consumer legs (No layer refuses an incoherent aggregate / field-type pair — a dataset measureavgover a datetime works on SQLite and errors on Postgres #16099, the devx lint leg) are not landed, so no downstream test is owed; the public surface listings (api-surface/,export-origins/) are unchanged (see the changeset section), so no import-side re-test is owed either.Reverse verification — the pins fail when the boolean class is emptied
Committed first, then mutated. Subject resolved through the relative
./aggregate-field-type-compatibilityimport (source, notdist/), so no rebuild is part of the loop. Mutation: theBOOLEAN_AGGREGATE_FIELD_TYPESliteral replaced with an empty array carrying anABLATION-16685marker. On-disk proof by anchored counts: removed text 1 → 0, marker 0 → 1, blob92a0a4c1…→98e329ae…. Run:Tests 6 failed | 15 passed (21)— the six failing are exactly the boolean pins (sumrow,avgrow,min/maxrow, the boolean-bucket pin, the four-aggregate accept pin, and theAGGREGATION_CASEScross-pin); the totality pins stay green as they should. Expected direction: red; observed: red. Restore:git checkout HEAD -- ABSOLUTE_PATHspelled withHEADunder atrap … EXIT INT TERM; proofgit diff HEAD --statempty,git status --porcelainempty,git hash-objectof the restored file =92a0a4c1f7aae1ec61f4b9064c90985b7a102651= the HEAD blob, marker count 0.验收备注
driver-sqlboolean cast atsql-driver.ts:8497-8516; onorigin/main5e53d73it sits at:8670-8712. Line drift only, content exactly as the card describes. 承接者:无。api-surface,export-origins,authorable-surface, docs references) would need regeneration was measured false — no export or.describe()changed;check:generatedis green with nothing to regenerate.progressstays insum, andcount_distinct×percent(isIncoherentAggregateflags it, the table accepts it — pinned as a visible divergence). No layer refuses an incoherent aggregate / field-type pair — a dataset measureavgover a datetime works on SQLite and errors on Postgres #16099 remains open; the devx lint leg remains open.packages/spec/src/data/analytics.zod.ts,runtime/src/domains/analytics.tsandpackages/spec/src/data/seed.zod.tsare untouched.🤖 Generated with Claude Code
Generated by Claude Code