Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/aggregate-boolean-members.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions .changeset/aggregate-field-type-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
80 changes: 58 additions & 22 deletions packages/spec/src/data/aggregate-field-type-compatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -31,6 +39,7 @@ const sorted = (xs: Iterable<string>) => [...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', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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']) {
Expand Down
Loading
Loading