Skip to content

Commit 499d718

Browse files
committed
feat(spec): accept boolean / toggle for sum / avg / min / max in the 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
1 parent 5e53d73 commit 499d718

4 files changed

Lines changed: 129 additions & 53 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `AGGREGATE_FIELD_TYPE_COMPATIBILITY` accepts `boolean` / `toggle` for `sum` / `avg` / `min` / `max` — ruling #11152 (booleans aggregate as numbers on every backend) stands over batch #59's blanket default (#16685)
6+
7+
The aggregate × field-type table declared by `@objectstack/spec/data` gains the boolean class (`boolean`, `toggle`) on its four arithmetic / order rows. Two maintainer rulings collided on that class: decision batch #59 (2026-09-06) said "every other pair: refused" without ever naming booleans, while ruling #11152 (2026-08-28) pins that booleans aggregate as NUMBERS on every backend with no per-aggregate exception — `sum(flag)=3`, `avg(flag)=0.5`, `min(flag)=0`, `max(flag)=1`, enrolled on six backends by the spec's own `AGGREGATION_CASES`, and implemented by `driver-sql`'s Postgres cast (#11635). The director ruling of decision batch #80 (2026-09-08, #16685, maintainer verbatim 「其他同意」, option A) holds that the specific ruling stands over the blanket default: the four rows carry both boolean members, and nothing else moves — `AGGREGATION_CASES` and the driver cast are untouched.
8+
9+
| Aggregate | Accepted field types |
10+
|---|---|
11+
| `count`, `count_distinct` | every `FieldType` |
12+
| `sum` | `number`, `currency`, `rating`, `slider`, `progress`, `summary`, **`boolean`, `toggle`** |
13+
| `avg` | the numeric class (`percent` included), **`boolean`, `toggle`** |
14+
| `min`, `max` | the numeric class, `date`, `datetime`, `time`, **`boolean`, `toggle`** |
15+
| every other pair | refused |
16+
17+
Why it matters: `avg(flag)` is the win-rate / SLA-violation-rate shape (#11065) — the reason the conformance table exists. A compatibility table refusing it would refuse a pair every backend is REQUIRED to answer, and the two consumer legs that execute this table (the compile-time refusal in the dataset compiler, #16099, and the authoring-time lint rule) would have turned a supported measure into an authoring error.
18+
19+
**Additive, `minor`.** No export is added, removed or renamed (`dist/*.d.ts` is byte-identical: the table's type is unchanged, only its value gains members), and the accept set only WIDENS — every pair accepted before is still accepted. It rides the same release as the table's own changeset, so the version outcome is the same either way; `minor` is declared because widening a published accept set is the same class of change the table's introduction was (decision batch #35 puts additive widening at `minor`), and because the two consumer legs are the change's real audience: a measure over a boolean field compiles and lints clean.

.changeset/aggregate-field-type-compatibility.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ A dataset measure pairs an `aggregate` with a `field`, and nothing between autho
1616

1717
The ruling (director, decision batch #59, 2026-09-06) named its buckets by category; the table resolves them against the real `FieldType` membership through the `field-value.zod` semantic classes: "numeric" is `NUMERIC_VALUE_TYPES` (`integer` is a driver-internal column alias, not a `FieldType` — the integer-valued authorable members are `rating` / `slider` / `progress`); "temporal" is the three temporal classes, `time` included because its stored form is a dialect question exactly like `date` / `datetime` (native TIME on Postgres and MySQL, canonical `HH:MM:SS[.fff]` TEXT on SQLite), the canonical form orders chronologically on every dialect, and `AnalyticsResult.fields[].type` already describes `min` / `max` over it as temporal (#15768). `formula` is refused for arithmetic aggregates whatever its declared `returnType`: it is virtual in SQL storage, no column exists to aggregate.
1818

19-
Two refused rows override existing opinions and are recorded as such, not presented as agreement. **Booleans** are refused for `sum` / `avg` / `min` / `max` by the ruling's "every other pair: refused", while maintainer ruling #11152 already has every backend answer them as numbers (`sum(flag)=3`, `avg(flag)=0.5`, `min(flag)=0`, `max(flag)=1`, pinned in the spec's `AGGREGATION_CASES`; `driver-sql` casts the aggregand on Postgres to make it hold). That refusal is therefore not grounded in backend divergence; whether booleans belong in those rows is a collision between two rulings and is referred to the maintainer as its own decision — the row ships exactly as batch #59 stated it. **The string classes** are refused for `min` / `max` here, while `service-analytics` (#15768) already types `min` / `max` over them as a supported `'string'` result; the refusal is defensible (string order is collation-dependent) but it overrides that opinion.
19+
**Booleans** (`boolean`, `toggle`) are NOT in the "every other pair" default: maintainer ruling #11152 has every backend answer them as numbers (`sum(flag)=3`, `avg(flag)=0.5`, `min(flag)=0`, `max(flag)=1`, pinned in the spec's `AGGREGATION_CASES`; `driver-sql` casts the aggregand on Postgres to make it hold), and the director ruling of decision batch #80 (2026-09-08, #16685) holds that specific ruling over batch #59's blanket default — the four arithmetic / order rows carry both boolean members (see that changeset). One refused row overrides an existing opinion and is recorded as such, not presented as agreement: **the string classes** are refused for `min` / `max` here, while `service-analytics` (#15768) already types `min` / `max` over them as a supported `'string'` result; the refusal is defensible (string order is collation-dependent) but it overrides that opinion.
2020

21-
**The narrowing, stated plainly.** Every pair outside the table — `avg` × `datetime`, `sum` × `boolean`, `min` × `text`, `sum` × `percent`, and so on — is an authoring shape `DatasetMeasureSchema` accepts today and will be REFUSED once the two consumer legs land: the compile-time refusal in the dataset compiler (#16099) and the authoring-time lint rule (its devx sub-card). A measure whose pair is refused is fixed by changing the aggregate to one the field's type supports (`min` / `max` for a temporal field; `avg` for a `percent`; `count` for anything), never by widening the table.
21+
**The narrowing, stated plainly.** Every pair outside the table — `avg` × `datetime`, `sum` × `text`, `min` × `json`, `sum` × `percent`, and so on — is an authoring shape `DatasetMeasureSchema` accepts today and will be REFUSED once the two consumer legs land: the compile-time refusal in the dataset compiler (#16099) and the authoring-time lint rule (its devx sub-card). A measure whose pair is refused is fixed by changing the aggregate to one the field's type supports (`min` / `max` for a temporal field; `avg` for a `percent`; `count` for anything), never by widening the table.
2222

2323
**Not breaking in this release, `minor` on purpose.** This changeset ships a table and a predicate that nothing yet enforces: `DatasetMeasureSchema` accepts byte-for-byte what it accepted before, no export is removed or narrowed, and no runtime path reads the table yet. It is an additive widening of the published surface — two new exports in `dist/*.d.ts` — which the maintainer ruling of 2026-09-04 (decision batch #35) puts at `minor`. The refusal itself arrives with the consumer legs, whose changesets carry the breaking declaration, its migration prescription and the ADR-0087 disposition; this one names the narrowing so an upgrading author can read the contract before it is executed.
2424

packages/spec/src/data/aggregate-field-type-compatibility.test.ts

Lines changed: 59 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@
77
* every `AggregationFunction` member has a row, and every `FieldType` member
88
* is classified (in or out) on every row. The per-row memberships are pinned
99
* as the LITERAL sets the ruling resolved to, and tied to the `field-value.zod`
10-
* semantic classes so a field type joining the numeric or temporal class
11-
* elsewhere reds this file until the table records a decision.
10+
* semantic classes so a field type joining the numeric, temporal or boolean
11+
* class elsewhere reds this file until the table records a decision.
12+
*
13+
* The boolean rows are pinned twice: literally, and against the spec's own
14+
* conformance suite — every boolean case `AGGREGATION_CASES` requires a
15+
* backend to ANSWER (#11152) must be a pair this table accepts, so the two
16+
* tables in this package can never again contradict each other (#16685).
1217
*/
1318

1419
import { describe, it, expect } from 'vitest';
@@ -19,8 +24,10 @@ import {
1924
CALENDAR_DATE_TYPES,
2025
INSTANT_TYPES,
2126
CLOCK_TIME_TYPES,
27+
BOOLEAN_VALUE_TYPES,
2228
} from './field-value.zod';
2329
import { isIncoherentAggregate } from './aggregation-policy';
30+
import { AGGREGATION_CASES } from './aggregation-conformance';
2431
import {
2532
AGGREGATE_FIELD_TYPE_COMPATIBILITY,
2633
isAggregateCompatibleWithFieldType,
@@ -31,6 +38,7 @@ const sorted = (xs: Iterable<string>) => [...xs].sort();
3138
const NUMERIC = ['currency', 'number', 'percent', 'progress', 'rating', 'slider', 'summary'];
3239
const ADDITIVE = NUMERIC.filter((t) => t !== 'percent');
3340
const TEMPORAL = ['date', 'datetime', 'time'];
41+
const BOOLEAN = ['boolean', 'toggle'];
3442

3543
describe('AGGREGATE_FIELD_TYPE_COMPATIBILITY — totality', () => {
3644
it('every AggregationFunction member has a row, and no row is for a non-member', () => {
@@ -65,32 +73,45 @@ describe('AGGREGATE_FIELD_TYPE_COMPATIBILITY — the ruled rows, resolved agains
6573
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.count_distinct)).toEqual(sorted(FieldType.options));
6674
});
6775

68-
it('`sum`: the numeric class EXCEPT `percent`', () => {
69-
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.sum)).toEqual(ADDITIVE);
76+
it('`sum`: the numeric class EXCEPT `percent`, plus the boolean class', () => {
77+
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.sum)).toEqual(sorted([...ADDITIVE, ...BOOLEAN]));
7078
expect(isAggregateCompatibleWithFieldType('sum', 'percent')).toBe(false);
7179
});
7280

73-
it('`avg`: the numeric class, `percent` included', () => {
74-
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.avg)).toEqual(NUMERIC);
81+
it('`avg`: the numeric class, `percent` included, plus the boolean class', () => {
82+
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.avg)).toEqual(sorted([...NUMERIC, ...BOOLEAN]));
7583
});
7684

77-
it('`min` / `max`: the numeric class plus the temporal class', () => {
78-
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.min)).toEqual(sorted([...NUMERIC, ...TEMPORAL]));
79-
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.max)).toEqual(sorted([...NUMERIC, ...TEMPORAL]));
85+
it('`min` / `max`: the numeric class plus the temporal class plus the boolean class', () => {
86+
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.min)).toEqual(sorted([...NUMERIC, ...TEMPORAL, ...BOOLEAN]));
87+
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.max)).toEqual(sorted([...NUMERIC, ...TEMPORAL, ...BOOLEAN]));
8088
});
8189

8290
it('the numeric bucket IS the field-value numeric class — a type joining it elsewhere must be decided here', () => {
83-
// `avg` accepts exactly the numeric class; `sum` is that class minus the rate.
84-
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.avg)).toEqual(sorted(NUMERIC_VALUE_TYPES));
85-
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.sum)).toEqual(sorted([...NUMERIC_VALUE_TYPES].filter((t) => t !== 'percent')));
91+
// Outside the boolean class, `avg` accepts exactly the numeric class and
92+
// `sum` is that class minus the rate.
93+
const avgNonBoolean = AGGREGATE_FIELD_TYPE_COMPATIBILITY.avg.filter((t) => !BOOLEAN_VALUE_TYPES.has(t));
94+
const sumNonBoolean = AGGREGATE_FIELD_TYPE_COMPATIBILITY.sum.filter((t) => !BOOLEAN_VALUE_TYPES.has(t));
95+
expect(sorted(avgNonBoolean)).toEqual(sorted(NUMERIC_VALUE_TYPES));
96+
expect(sorted(sumNonBoolean)).toEqual(sorted([...NUMERIC_VALUE_TYPES].filter((t) => t !== 'percent')));
8697
});
8798

8899
it('the temporal bucket IS the three field-value temporal classes', () => {
89100
const temporal = sorted([...CALENDAR_DATE_TYPES, ...INSTANT_TYPES, ...CLOCK_TIME_TYPES]);
90101
expect(temporal).toEqual(TEMPORAL);
91-
const minOnly = AGGREGATE_FIELD_TYPE_COMPATIBILITY.min.filter((t) => !NUMERIC_VALUE_TYPES.has(t));
102+
const minOnly = AGGREGATE_FIELD_TYPE_COMPATIBILITY.min.filter(
103+
(t) => !NUMERIC_VALUE_TYPES.has(t) && !BOOLEAN_VALUE_TYPES.has(t),
104+
);
92105
expect(sorted(minOnly)).toEqual(temporal);
93106
});
107+
108+
it('the boolean bucket IS the field-value boolean class, and it is in all four arithmetic / order rows', () => {
109+
expect(sorted(BOOLEAN_VALUE_TYPES)).toEqual(BOOLEAN);
110+
for (const fn of ['sum', 'avg', 'min', 'max'] as const) {
111+
const booleanMembers = AGGREGATE_FIELD_TYPE_COMPATIBILITY[fn].filter((t) => BOOLEAN_VALUE_TYPES.has(t));
112+
expect(sorted(booleanMembers)).toEqual(BOOLEAN);
113+
}
114+
});
94115
});
95116

96117
describe('isAggregateCompatibleWithFieldType — the pairs the card is about', () => {
@@ -107,9 +128,31 @@ describe('isAggregateCompatibleWithFieldType — the pairs the card is about', (
107128
}
108129
});
109130

110-
it('refuses arithmetic over booleans (row as ruled; membership referred, see module TSDoc) and over the computed / text / structured types', () => {
131+
it('accepts `sum` / `avg` / `min` / `max` over booleans — #11152 (numbers on every backend), upheld by decision batch #80', () => {
111132
for (const fn of ['sum', 'avg', 'min', 'max'] as const) {
112-
for (const t of ['boolean', 'toggle', 'formula', 'autonumber', 'text', 'select', 'lookup', 'json', 'vector', 'file']) {
133+
expect(isAggregateCompatibleWithFieldType(fn, 'boolean')).toBe(true);
134+
expect(isAggregateCompatibleWithFieldType(fn, 'toggle')).toBe(true);
135+
}
136+
});
137+
138+
it('accepts every boolean pair the conformance suite requires a backend to ANSWER — the two spec tables cannot contradict', () => {
139+
// `AGGREGATION_ROWS.flag` is the boolean aggregand (declared `type:
140+
// 'boolean'` by every harness); each case over it is a pair #11152 pins
141+
// on six backends. A table refusing one of them would refuse a pair the
142+
// spec elsewhere REQUIRES an answer to (#16685).
143+
const booleanCases = AGGREGATION_CASES.filter((c) => c.field === 'flag');
144+
expect(sorted(new Set(booleanCases.map((c) => c.function)))).toEqual(
145+
sorted(['sum', 'avg', 'min', 'max', 'count', 'count_distinct']),
146+
);
147+
for (const c of booleanCases) {
148+
expect(isAggregateCompatibleWithFieldType(c.function, 'boolean')).toBe(true);
149+
expect(isAggregateCompatibleWithFieldType(c.function, 'toggle')).toBe(true);
150+
}
151+
});
152+
153+
it('refuses arithmetic over the computed / text / structured types', () => {
154+
for (const fn of ['sum', 'avg', 'min', 'max'] as const) {
155+
for (const t of ['formula', 'autonumber', 'text', 'select', 'lookup', 'json', 'vector', 'file']) {
113156
expect(isAggregateCompatibleWithFieldType(fn, t)).toBe(false);
114157
}
115158
}
@@ -160,13 +203,7 @@ describe('isAggregateCompatibleWithFieldType — the pairs the card is about', (
160203
expect(loose(Symbol('count'), 'number')).toBe(false);
161204
});
162205

163-
it('records the two overrides of existing opinions without changing the rows: booleans and the string classes', () => {
164-
// Booleans: refused here by the ruling's default; #11152 / AGGREGATION_CASES
165-
// answer them as numbers on every face. Row kept as ruled, question referred.
166-
for (const fn of ['sum', 'avg', 'min', 'max']) {
167-
expect(isAggregateCompatibleWithFieldType(fn, 'boolean')).toBe(false);
168-
expect(isAggregateCompatibleWithFieldType(fn, 'toggle')).toBe(false);
169-
}
206+
it('records the one override of an existing opinion without changing the row: the string classes', () => {
170207
// String classes: min/max refused here; measureResultType (#15768) types
171208
// min/max over them as a supported 'string' result. Override recorded.
172209
for (const t of ['text', 'select', 'lookup', 'autonumber']) {

0 commit comments

Comments
 (0)