diff --git a/.changeset/aggregate-boolean-members.md b/.changeset/aggregate-boolean-members.md new file mode 100644 index 0000000000..6d1f705324 --- /dev/null +++ b/.changeset/aggregate-boolean-members.md @@ -0,0 +1,19 @@ +--- +"@objectstack/spec": minor +--- + +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) + +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. + +| Aggregate | Accepted field types | +|---|---| +| `count`, `count_distinct` | every `FieldType` | +| `sum` | `number`, `currency`, `rating`, `slider`, `progress`, `summary`, **`boolean`, `toggle`** | +| `avg` | the numeric class (`percent` included), **`boolean`, `toggle`** | +| `min`, `max` | the numeric class, `date`, `datetime`, `time`, **`boolean`, `toggle`** | +| every other pair | refused | + +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. + +**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. diff --git a/.changeset/aggregate-field-type-compatibility.md b/.changeset/aggregate-field-type-compatibility.md index 9603b4f3ee..f542b78b8b 100644 --- a/.changeset/aggregate-field-type-compatibility.md +++ b/.changeset/aggregate-field-type-compatibility.md @@ -16,9 +16,9 @@ A dataset measure pairs an `aggregate` with a `field`, and nothing between autho 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. -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. +**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. -**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. +**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. **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. diff --git a/packages/spec/src/data/aggregate-field-type-compatibility.test.ts b/packages/spec/src/data/aggregate-field-type-compatibility.test.ts index b724dd1974..3bec0cf0cb 100644 --- a/packages/spec/src/data/aggregate-field-type-compatibility.test.ts +++ b/packages/spec/src/data/aggregate-field-type-compatibility.test.ts @@ -7,8 +7,14 @@ * every `AggregationFunction` member has a row, and every `FieldType` member * is classified (in or out) on every row. The per-row memberships are pinned * as the LITERAL sets the ruling resolved to, and tied to the `field-value.zod` - * semantic classes so a field type joining the numeric or temporal class - * elsewhere reds this file until the table records a decision. + * semantic classes so a field type joining the numeric, temporal or boolean + * class elsewhere reds this file until the table records a decision. + * + * The boolean rows are pinned twice: literally, and against the spec's own + * conformance suite — every boolean case `AGGREGATION_CASES` requires a + * backend to ANSWER (#11152) must be a pair this table accepts, so the two + * tables in this package cannot contradict each other on the boolean axis + * (#16685) — the cross-pin reaches exactly as far as the `flag` cases. */ import { describe, it, expect } from 'vitest'; @@ -19,8 +25,10 @@ import { CALENDAR_DATE_TYPES, INSTANT_TYPES, CLOCK_TIME_TYPES, + BOOLEAN_VALUE_TYPES, } from './field-value.zod'; import { isIncoherentAggregate } from './aggregation-policy'; +import { AGGREGATION_CASES } from './aggregation-conformance'; import { AGGREGATE_FIELD_TYPE_COMPATIBILITY, isAggregateCompatibleWithFieldType, @@ -31,6 +39,7 @@ const sorted = (xs: Iterable) => [...xs].sort(); const NUMERIC = ['currency', 'number', 'percent', 'progress', 'rating', 'slider', 'summary']; const ADDITIVE = NUMERIC.filter((t) => t !== 'percent'); const TEMPORAL = ['date', 'datetime', 'time']; +const BOOLEAN = ['boolean', 'toggle']; describe('AGGREGATE_FIELD_TYPE_COMPATIBILITY — totality', () => { 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 expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.count_distinct)).toEqual(sorted(FieldType.options)); }); - it('`sum`: the numeric class EXCEPT `percent`', () => { - expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.sum)).toEqual(ADDITIVE); + it('`sum`: the numeric class EXCEPT `percent`, plus the boolean class', () => { + expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.sum)).toEqual(sorted([...ADDITIVE, ...BOOLEAN])); expect(isAggregateCompatibleWithFieldType('sum', 'percent')).toBe(false); }); - it('`avg`: the numeric class, `percent` included', () => { - expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.avg)).toEqual(NUMERIC); + it('`avg`: the numeric class, `percent` included, plus the boolean class', () => { + expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.avg)).toEqual(sorted([...NUMERIC, ...BOOLEAN])); }); - it('`min` / `max`: the numeric class plus the temporal class', () => { - expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.min)).toEqual(sorted([...NUMERIC, ...TEMPORAL])); - expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.max)).toEqual(sorted([...NUMERIC, ...TEMPORAL])); + it('`min` / `max`: the numeric class plus the temporal class plus the boolean class', () => { + expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.min)).toEqual(sorted([...NUMERIC, ...TEMPORAL, ...BOOLEAN])); + expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.max)).toEqual(sorted([...NUMERIC, ...TEMPORAL, ...BOOLEAN])); }); it('the numeric bucket IS the field-value numeric class — a type joining it elsewhere must be decided here', () => { - // `avg` accepts exactly the numeric class; `sum` is that class minus the rate. - expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.avg)).toEqual(sorted(NUMERIC_VALUE_TYPES)); - expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.sum)).toEqual(sorted([...NUMERIC_VALUE_TYPES].filter((t) => t !== 'percent'))); + // Outside the boolean class, `avg` accepts exactly the numeric class and + // `sum` is that class minus the rate. + const avgNonBoolean = AGGREGATE_FIELD_TYPE_COMPATIBILITY.avg.filter((t) => !BOOLEAN_VALUE_TYPES.has(t)); + const sumNonBoolean = AGGREGATE_FIELD_TYPE_COMPATIBILITY.sum.filter((t) => !BOOLEAN_VALUE_TYPES.has(t)); + expect(sorted(avgNonBoolean)).toEqual(sorted(NUMERIC_VALUE_TYPES)); + expect(sorted(sumNonBoolean)).toEqual(sorted([...NUMERIC_VALUE_TYPES].filter((t) => t !== 'percent'))); }); it('the temporal bucket IS the three field-value temporal classes', () => { const temporal = sorted([...CALENDAR_DATE_TYPES, ...INSTANT_TYPES, ...CLOCK_TIME_TYPES]); expect(temporal).toEqual(TEMPORAL); - const minOnly = AGGREGATE_FIELD_TYPE_COMPATIBILITY.min.filter((t) => !NUMERIC_VALUE_TYPES.has(t)); + const minOnly = AGGREGATE_FIELD_TYPE_COMPATIBILITY.min.filter( + (t) => !NUMERIC_VALUE_TYPES.has(t) && !BOOLEAN_VALUE_TYPES.has(t), + ); expect(sorted(minOnly)).toEqual(temporal); }); + + it('the boolean bucket IS the field-value boolean class, and it is in all four arithmetic / order rows', () => { + expect(sorted(BOOLEAN_VALUE_TYPES)).toEqual(BOOLEAN); + for (const fn of ['sum', 'avg', 'min', 'max'] as const) { + const booleanMembers = AGGREGATE_FIELD_TYPE_COMPATIBILITY[fn].filter((t) => BOOLEAN_VALUE_TYPES.has(t)); + expect(sorted(booleanMembers)).toEqual(BOOLEAN); + } + }); }); describe('isAggregateCompatibleWithFieldType — the pairs the card is about', () => { @@ -107,9 +129,29 @@ describe('isAggregateCompatibleWithFieldType — the pairs the card is about', ( } }); - it('refuses arithmetic over booleans (row as ruled; membership referred, see module TSDoc) and over the computed / text / structured types', () => { + it('accepts `sum` / `avg` / `min` / `max` over booleans — #11152 (numbers on every backend), upheld by decision batch #80', () => { for (const fn of ['sum', 'avg', 'min', 'max'] as const) { - for (const t of ['boolean', 'toggle', 'formula', 'autonumber', 'text', 'select', 'lookup', 'json', 'vector', 'file']) { + expect(isAggregateCompatibleWithFieldType(fn, 'boolean')).toBe(true); + expect(isAggregateCompatibleWithFieldType(fn, 'toggle')).toBe(true); + } + }); + + it('accepts every boolean pair the conformance suite requires a backend to ANSWER — the two spec tables cannot contradict', () => { + // `AGGREGATION_ROWS.flag` is the boolean aggregand (declared `type: + // 'boolean'` by every harness); each case over it is a pair #11152 pins + // on six backends. A table refusing one of them would refuse a pair the + // spec elsewhere REQUIRES an answer to (#16685). + const booleanCases = AGGREGATION_CASES.filter((c) => c.field === 'flag'); + expect(sorted(new Set(booleanCases.map((c) => c.function)))).toEqual(sorted(AggregationFunction.options)); + for (const c of booleanCases) { + expect(isAggregateCompatibleWithFieldType(c.function, 'boolean')).toBe(true); + expect(isAggregateCompatibleWithFieldType(c.function, 'toggle')).toBe(true); + } + }); + + it('refuses arithmetic over the computed / text / structured types', () => { + for (const fn of ['sum', 'avg', 'min', 'max'] as const) { + for (const t of ['formula', 'autonumber', 'text', 'select', 'lookup', 'json', 'vector', 'file']) { expect(isAggregateCompatibleWithFieldType(fn, t)).toBe(false); } } @@ -160,13 +202,7 @@ describe('isAggregateCompatibleWithFieldType — the pairs the card is about', ( expect(loose(Symbol('count'), 'number')).toBe(false); }); - it('records the two overrides of existing opinions without changing the rows: booleans and the string classes', () => { - // Booleans: refused here by the ruling's default; #11152 / AGGREGATION_CASES - // answer them as numbers on every face. Row kept as ruled, question referred. - for (const fn of ['sum', 'avg', 'min', 'max']) { - expect(isAggregateCompatibleWithFieldType(fn, 'boolean')).toBe(false); - expect(isAggregateCompatibleWithFieldType(fn, 'toggle')).toBe(false); - } + it('records the one override of an existing opinion without changing the row: the string classes', () => { // String classes: min/max refused here; measureResultType (#15768) types // min/max over them as a supported 'string' result. Override recorded. for (const t of ['text', 'select', 'lookup', 'autonumber']) { diff --git a/packages/spec/src/data/aggregate-field-type-compatibility.ts b/packages/spec/src/data/aggregate-field-type-compatibility.ts index c7b31303da..92a0a4c1f7 100644 --- a/packages/spec/src/data/aggregate-field-type-compatibility.ts +++ b/packages/spec/src/data/aggregate-field-type-compatibility.ts @@ -4,7 +4,8 @@ * Aggregate × field-type compatibility — the ONE table saying which * `AggregationFunction` may be applied to a field of which `FieldType` * (#16353; director ruling, decision batch #59, 2026-09-06: "both legs, table - * in spec"). A `DatasetMeasure` pairs an `aggregate` with a `field`; this + * in spec"; the boolean rows by decision batch #80, 2026-09-08, #16685 — see + * below). A `DatasetMeasure` pairs an `aggregate` with a `field`; this * table is the contract both consumer legs execute — the compile-time refusal * in the dataset compiler (#16099) and the authoring-time lint rule — so the * two cannot drift into two accounts of one pair. @@ -26,9 +27,9 @@ * | Aggregate | Accepted field types | * |---|---| * | `count`, `count_distinct` | every `FieldType` — counting rows or distinct values reads no arithmetic off the value | - * | `sum` | the numeric class EXCEPT `percent` — a rate does not add (see `isIncoherentAggregate`) | - * | `avg` | the numeric class, `percent` included | - * | `min`, `max` | the numeric class plus the temporal class — both return a value of the field's OWN type (#15768) | + * | `sum` | the numeric class EXCEPT `percent` — a rate does not add (see `isIncoherentAggregate`) — plus the boolean class | + * | `avg` | the numeric class, `percent` included, plus the boolean class | + * | `min`, `max` | the numeric class plus the temporal class — both return a value of the field's OWN type (#15768) — plus the boolean class | * | every other pair | refused | * * ## How the ruling's categories resolve against the real membership @@ -54,29 +55,33 @@ * `AnalyticsResult.fields[].type` already describes `min` / `max` over it as * temporal (#15768, `TEMPORAL_SOURCE_FIELD_TYPES`). So it sits beside the * two members the ruling named. - * - **everything else** — the text family, booleans, option types, references, - * files, structured JSON, `vector`, and the computed `formula` / - * `autonumber` — is refused for `sum` / `avg` / `min` / `max`, the ruling's - * "every other pair: refused". `formula` carries a declared `returnType`, - * but it is VIRTUAL in SQL storage (no column is emitted), so no arithmetic - * aggregate can be lowered to it whatever that type says; `autonumber` is a - * formatted string. + * - **boolean** = `BOOLEAN_VALUE_TYPES`: `boolean`, `toggle`. In every + * arithmetic / order row on the authority of maintainer ruling #11152 + * (2026-08-28): booleans aggregate as NUMBERS on every backend, with no + * per-aggregate exception. That ruling is pinned by the spec's own + * conformance suite (`AGGREGATION_CASES` in `aggregation-conformance.ts`: + * `sum(flag)=3`, `avg(flag)=0.5`, `min(flag)=0`, `max(flag)=1`, enrolled on + * six backends) and implemented by `driver-sql`, which casts a boolean + * aggregand to `int` on Postgres so that the one dialect storing a real + * `boolean` column answers the same numbers (#11635). Batch #59's "every + * other pair: refused" never named booleans — it was a blanket default — + * and the director ruling of decision batch #80 (2026-09-08, #16685, + * maintainer verbatim 「其他同意」, option A) holds that the specific ruling + * #11152 stands over that default: the four rows carry both members and + * nothing else moves. `avg(flag)` is the win-rate / SLA-violation-rate + * shape (#11065) — the reason `AGGREGATION_CASES` exists — so a table that + * refused it would refuse a pair every backend is REQUIRED to answer. + * - **everything else** — the text family, option types, references, files, + * structured JSON, `vector`, and the computed `formula` / `autonumber` — is + * refused for `sum` / `avg` / `min` / `max`, the ruling's "every other pair: + * refused". `formula` carries a declared `returnType`, but it is VIRTUAL in + * SQL storage (no column is emitted), so no arithmetic aggregate can be + * lowered to it whatever that type says; `autonumber` is a formatted string. * - * Two rows the ruling's default covers are recorded here as OVERRIDES of - * existing opinions, not as settled ground — the row stands as ruled, the - * text says only what this tree can defend: + * One row the ruling's default covers is recorded here as an OVERRIDE of an + * existing opinion, not as settled ground — the row stands as ruled, the text + * says only what this tree can defend: * - * - **Booleans** (`boolean`, `toggle`) are refused for the four arithmetic / - * order aggregates by the ruling's default, yet the runtime already ANSWERS - * them: maintainer ruling #11152 pins that booleans aggregate as numbers on - * every face with no per-aggregate exception (`AGGREGATION_CASES` in - * `aggregation-conformance.ts`: `sum(flag)=3`, `avg(flag)=0.5`, - * `min(flag)=0`, `max(flag)=1`, six backends), and `driver-sql` casts a - * boolean aggregand to `int` on Postgres to make that hold (#11635). So the - * refusal is NOT grounded in backend divergence — the backends agree. Whether - * booleans belong in these rows is a collision between two rulings (batch - * #59 and #11152) and is referred to the maintainer as its own decision; the - * row is left exactly as batch #59 stated it until that decision lands. * - **The string classes** (`STRING_VALUE_TYPES`, `SINGLE_OPTION_TYPES`, * `REFERENCE_VALUE_TYPES`, `autonumber`) are refused for `min` / `max` here, * while `service-analytics`' `measureResultType` (#15768, @@ -127,6 +132,17 @@ const TEMPORAL_AGGREGATE_FIELD_TYPES = [ 'date', 'datetime', 'time', ] as const satisfies readonly FieldType[]; +/** + * The boolean class — the `BOOLEAN_VALUE_TYPES` membership, spelled out for + * the same reason as the numeric class above (the pin test holds the two + * equal). Booleans aggregate as numbers on every backend with no + * per-aggregate exception (#11152, upheld by decision batch #80 — see the + * module TSDoc), so the class sits in all four arithmetic / order rows. + */ +const BOOLEAN_AGGREGATE_FIELD_TYPES = [ + 'boolean', 'toggle', +] as const satisfies readonly FieldType[]; + /** Every declared `FieldType` — the `count` / `count_distinct` row. */ const ANY_FIELD_TYPE: readonly FieldType[] = Object.freeze([...FieldType.options]); @@ -142,10 +158,14 @@ export const AGGREGATE_FIELD_TYPE_COMPATIBILITY: Readonly