Skip to content

Commit ed7243d

Browse files
claude[bot]claude
andauthored
feat(spec): accept boolean / toggle for sum / avg / min / max in the aggregate × field-type table (#16685) (#16750)
* 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 * docs(spec): retract the d.ts byte-identity claim in the boolean-members 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 * docs(spec): state the boolean-members constant's absence as measured — 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9412320 commit ed7243d

4 files changed

Lines changed: 128 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 — the exported declarations are unchanged (the table's declared type is the same; only its value gains members), the new `BOOLEAN_AGGREGATE_FIELD_TYPES` constant is module-private — absent from `dist/*.d.ts` and from the bundles' export lists — and `api-surface/` and `export-origins/` are untouched — 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: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@
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 cannot contradict each other on the boolean axis
17+
* (#16685) — the cross-pin reaches exactly as far as the `flag` cases.
1218
*/
1319

1420
import { describe, it, expect } from 'vitest';
@@ -19,8 +25,10 @@ import {
1925
CALENDAR_DATE_TYPES,
2026
INSTANT_TYPES,
2127
CLOCK_TIME_TYPES,
28+
BOOLEAN_VALUE_TYPES,
2229
} from './field-value.zod';
2330
import { isIncoherentAggregate } from './aggregation-policy';
31+
import { AGGREGATION_CASES } from './aggregation-conformance';
2432
import {
2533
AGGREGATE_FIELD_TYPE_COMPATIBILITY,
2634
isAggregateCompatibleWithFieldType,
@@ -31,6 +39,7 @@ const sorted = (xs: Iterable<string>) => [...xs].sort();
3139
const NUMERIC = ['currency', 'number', 'percent', 'progress', 'rating', 'slider', 'summary'];
3240
const ADDITIVE = NUMERIC.filter((t) => t !== 'percent');
3341
const TEMPORAL = ['date', 'datetime', 'time'];
42+
const BOOLEAN = ['boolean', 'toggle'];
3443

3544
describe('AGGREGATE_FIELD_TYPE_COMPATIBILITY — totality', () => {
3645
it('every AggregationFunction member has a row, and no row is for a non-member', () => {
@@ -65,32 +74,45 @@ describe('AGGREGATE_FIELD_TYPE_COMPATIBILITY — the ruled rows, resolved agains
6574
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.count_distinct)).toEqual(sorted(FieldType.options));
6675
});
6776

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

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

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]));
86+
it('`min` / `max`: the numeric class plus the temporal class plus the boolean class', () => {
87+
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.min)).toEqual(sorted([...NUMERIC, ...TEMPORAL, ...BOOLEAN]));
88+
expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.max)).toEqual(sorted([...NUMERIC, ...TEMPORAL, ...BOOLEAN]));
8089
});
8190

8291
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')));
92+
// Outside the boolean class, `avg` accepts exactly the numeric class and
93+
// `sum` is that class minus the rate.
94+
const avgNonBoolean = AGGREGATE_FIELD_TYPE_COMPATIBILITY.avg.filter((t) => !BOOLEAN_VALUE_TYPES.has(t));
95+
const sumNonBoolean = AGGREGATE_FIELD_TYPE_COMPATIBILITY.sum.filter((t) => !BOOLEAN_VALUE_TYPES.has(t));
96+
expect(sorted(avgNonBoolean)).toEqual(sorted(NUMERIC_VALUE_TYPES));
97+
expect(sorted(sumNonBoolean)).toEqual(sorted([...NUMERIC_VALUE_TYPES].filter((t) => t !== 'percent')));
8698
});
8799

88100
it('the temporal bucket IS the three field-value temporal classes', () => {
89101
const temporal = sorted([...CALENDAR_DATE_TYPES, ...INSTANT_TYPES, ...CLOCK_TIME_TYPES]);
90102
expect(temporal).toEqual(TEMPORAL);
91-
const minOnly = AGGREGATE_FIELD_TYPE_COMPATIBILITY.min.filter((t) => !NUMERIC_VALUE_TYPES.has(t));
103+
const minOnly = AGGREGATE_FIELD_TYPE_COMPATIBILITY.min.filter(
104+
(t) => !NUMERIC_VALUE_TYPES.has(t) && !BOOLEAN_VALUE_TYPES.has(t),
105+
);
92106
expect(sorted(minOnly)).toEqual(temporal);
93107
});
108+
109+
it('the boolean bucket IS the field-value boolean class, and it is in all four arithmetic / order rows', () => {
110+
expect(sorted(BOOLEAN_VALUE_TYPES)).toEqual(BOOLEAN);
111+
for (const fn of ['sum', 'avg', 'min', 'max'] as const) {
112+
const booleanMembers = AGGREGATE_FIELD_TYPE_COMPATIBILITY[fn].filter((t) => BOOLEAN_VALUE_TYPES.has(t));
113+
expect(sorted(booleanMembers)).toEqual(BOOLEAN);
114+
}
115+
});
94116
});
95117

96118
describe('isAggregateCompatibleWithFieldType — the pairs the card is about', () => {
@@ -107,9 +129,29 @@ describe('isAggregateCompatibleWithFieldType — the pairs the card is about', (
107129
}
108130
});
109131

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

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-
}
205+
it('records the one override of an existing opinion without changing the row: the string classes', () => {
170206
// String classes: min/max refused here; measureResultType (#15768) types
171207
// min/max over them as a supported 'string' result. Override recorded.
172208
for (const t of ['text', 'select', 'lookup', 'autonumber']) {

0 commit comments

Comments
 (0)