From 3db23480f6dc66bafd52f0c70db30119d6848fa9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 19:37:30 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(spec):=20declare=20the=20aggregate=20?= =?UTF-8?q?=C3=97=20field-type=20compatibility=20matrix=20(#16353)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export AGGREGATE_FIELD_TYPE_COMPATIBILITY and isAggregateCompatibleWithFieldType from @objectstack/spec/data: the one table the dataset compiler and the lint rule refuse dataset measures against. Rows follow the director ruling (decision batch #59), resolved against the full FieldType membership through the field-value semantic classes; pinned literally in the test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --- .../aggregate-field-type-compatibility.md | 23 +++ ...aggregate-field-type-compatibility.test.ts | 144 ++++++++++++++++++ .../aggregate-field-type-compatibility.ts | 139 +++++++++++++++++ packages/spec/src/data/index.ts | 5 + 4 files changed, 311 insertions(+) create mode 100644 .changeset/aggregate-field-type-compatibility.md create mode 100644 packages/spec/src/data/aggregate-field-type-compatibility.test.ts create mode 100644 packages/spec/src/data/aggregate-field-type-compatibility.ts diff --git a/.changeset/aggregate-field-type-compatibility.md b/.changeset/aggregate-field-type-compatibility.md new file mode 100644 index 0000000000..5c852bcda6 --- /dev/null +++ b/.changeset/aggregate-field-type-compatibility.md @@ -0,0 +1,23 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): declare the aggregate × field-type compatibility matrix a dataset measure is judged against — `AGGREGATE_FIELD_TYPE_COMPATIBILITY` and `isAggregateCompatibleWithFieldType` (#16353, spec half of #16099) + +A dataset measure pairs an `aggregate` with a `field`, and nothing between author and driver correlated the two: `avg` over a `Field.datetime` compiled to `AVG(col)` and reached the backend, where one SQL family averages the column's storage form and another rejects the call — one metadata document, two answers. Which pairs are accepted is a contract, so it is now declared once in `@objectstack/spec/data`: + +| Aggregate | Accepted field types | +|---|---| +| `count`, `count_distinct` | every `FieldType` | +| `sum` | `number`, `currency`, `rating`, `slider`, `progress`, `summary` — the numeric class EXCEPT `percent` (a rate does not add; `isIncoherentAggregate` already says so) | +| `avg` | the numeric class, `percent` included | +| `min`, `max` | the numeric class plus `date`, `datetime`, `time` — both return a value of the field's own type | +| every other pair | refused | + +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 the driver stores it as a native TIME column and `AnalyticsResult.fields[].type` already describes `min` / `max` over it as temporal. `formula` is refused for arithmetic aggregates whatever its declared `returnType`: it is virtual in SQL storage, no column exists to aggregate. + +**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. + +**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. + +`isIncoherentAggregate` is unchanged and stays the semantic opinion beside this table. The two diverge on exactly one pair: `count_distinct` × `percent` is flagged there and accepted here (the ruling reads `count_distinct` as "any type"). That divergence is pinned in the table's test and reported on #16353 rather than resolved silently. diff --git a/packages/spec/src/data/aggregate-field-type-compatibility.test.ts b/packages/spec/src/data/aggregate-field-type-compatibility.test.ts new file mode 100644 index 0000000000..ea09cccc99 --- /dev/null +++ b/packages/spec/src/data/aggregate-field-type-compatibility.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pins for the aggregate × field-type compatibility table (#16353). + * + * Two totality claims are the card's acceptance and are held here literally: + * 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. + */ + +import { describe, it, expect } from 'vitest'; +import { AggregationFunction } from './query.zod'; +import { FieldType } from './field.zod'; +import { + NUMERIC_VALUE_TYPES, + CALENDAR_DATE_TYPES, + INSTANT_TYPES, + CLOCK_TIME_TYPES, +} from './field-value.zod'; +import { isIncoherentAggregate } from './aggregation-policy'; +import { + AGGREGATE_FIELD_TYPE_COMPATIBILITY, + isAggregateCompatibleWithFieldType, +} from './aggregate-field-type-compatibility'; + +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']; + +describe('AGGREGATE_FIELD_TYPE_COMPATIBILITY — totality', () => { + it('every AggregationFunction member has a row, and no row is for a non-member', () => { + expect(sorted(Object.keys(AGGREGATE_FIELD_TYPE_COMPATIBILITY))).toEqual(sorted(AggregationFunction.options)); + }); + + it('every row member is a declared FieldType', () => { + const all = new Set(FieldType.options); + for (const row of Object.values(AGGREGATE_FIELD_TYPE_COMPATIBILITY)) { + for (const t of row) expect(all).toContain(t); + } + }); + + it('every FieldType member is classified on every row — the predicate answers a boolean for all pairs', () => { + for (const fn of AggregationFunction.options) { + const row = AGGREGATE_FIELD_TYPE_COMPATIBILITY[fn]; + for (const t of FieldType.options) { + expect(isAggregateCompatibleWithFieldType(fn, t)).toBe(row.includes(t)); + } + } + }); + + it('the table and its rows are frozen — consumers read, never edit', () => { + expect(Object.isFrozen(AGGREGATE_FIELD_TYPE_COMPATIBILITY)).toBe(true); + for (const row of Object.values(AGGREGATE_FIELD_TYPE_COMPATIBILITY)) expect(Object.isFrozen(row)).toBe(true); + }); +}); + +describe('AGGREGATE_FIELD_TYPE_COMPATIBILITY — the ruled rows, resolved against the membership', () => { + it('`count` / `count_distinct`: every FieldType', () => { + expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.count)).toEqual(sorted(FieldType.options)); + 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); + expect(isAggregateCompatibleWithFieldType('sum', 'percent')).toBe(false); + }); + + it('`avg`: the numeric class, `percent` included', () => { + expect(sorted(AGGREGATE_FIELD_TYPE_COMPATIBILITY.avg)).toEqual(NUMERIC); + }); + + 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('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'))); + }); + + 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)); + expect(sorted(minOnly)).toEqual(temporal); + }); +}); + +describe('isAggregateCompatibleWithFieldType — the pairs the card is about', () => { + it('refuses the motivating defect: `avg` (and `sum`) over a datetime', () => { + expect(isAggregateCompatibleWithFieldType('avg', 'datetime')).toBe(false); + expect(isAggregateCompatibleWithFieldType('sum', 'datetime')).toBe(false); + expect(isAggregateCompatibleWithFieldType('avg', 'date')).toBe(false); + }); + + it('accepts `min` / `max` over every temporal type — they return a value of the field\'s own type', () => { + for (const t of TEMPORAL) { + expect(isAggregateCompatibleWithFieldType('min', t)).toBe(true); + expect(isAggregateCompatibleWithFieldType('max', t)).toBe(true); + } + }); + + it('refuses arithmetic over the divergence class (booleans) and over the computed / text / structured types', () => { + 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, t)).toBe(false); + } + } + }); + + it('accepts `count` / `count_distinct` over anything, `vector` and `formula` included', () => { + for (const t of FieldType.options) { + expect(isAggregateCompatibleWithFieldType('count', t)).toBe(true); + expect(isAggregateCompatibleWithFieldType('count_distinct', t)).toBe(true); + } + }); + + it('agrees with `isIncoherentAggregate` on `sum` × `percent`; the one divergence is `count_distinct` × `percent`', () => { + // Both refuse the sum of a rate. + expect(isIncoherentAggregate('sum', 'percent')).toBe(true); + expect(isAggregateCompatibleWithFieldType('sum', 'percent')).toBe(false); + // The semantic opinion flags count_distinct of a rate; the ruling reads + // `count_distinct` as "any type" and this table follows the ruling. Pinned + // so the divergence is visible, not discovered (reported on #16353). + expect(isIncoherentAggregate('count_distinct', 'percent')).toBe(true); + expect(isAggregateCompatibleWithFieldType('count_distinct', 'percent')).toBe(true); + }); + + it('fails closed on vocabulary it does not know — driver aliases and retired functions', () => { + expect(isAggregateCompatibleWithFieldType('sum', 'integer')).toBe(false); + expect(isAggregateCompatibleWithFieldType('sum', 'float')).toBe(false); + expect(isAggregateCompatibleWithFieldType('array_agg', 'text')).toBe(false); + expect(isAggregateCompatibleWithFieldType('countDistinct', 'text')).toBe(false); + expect(isAggregateCompatibleWithFieldType('toString', 'text')).toBe(false); + expect(isAggregateCompatibleWithFieldType('', '')).toBe(false); + }); +}); diff --git a/packages/spec/src/data/aggregate-field-type-compatibility.ts b/packages/spec/src/data/aggregate-field-type-compatibility.ts new file mode 100644 index 0000000000..f571bea084 --- /dev/null +++ b/packages/spec/src/data/aggregate-field-type-compatibility.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * 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 + * 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. + * + * ## Why this exists + * + * Nothing between author and driver correlated a measure's `aggregate` with + * the field's type. `avg` over a `Field.datetime` compiled to `AVG(col)` and + * reached the backend, where the answer is a property of the dialect rather + * than of the data: one SQL family averages whatever the column's storage form + * is, another rejects the call outright. Two backends, one metadata document, + * two answers — the shape Prime Directive #12 exists to remove. Which pairs + * are accepted is therefore a narrowing of a published acceptance set, + * declared ONCE here and refused at authoring / compile time — never + * tolerated in a consumer with a fallback. + * + * ## The rule + * + * | 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) | + * | every other pair | refused | + * + * ## How the ruling's categories resolve against the real membership + * + * The ruling named its buckets by category ("numeric", "`integer`-class", + * "temporal"); this file resolves each against the `FieldType` enum through + * the `field-value.zod` semantic classes — the sets the SQL DDL actually + * stores by: + * + * - **numeric** = `NUMERIC_VALUE_TYPES`: `number`, `currency`, `percent`, + * `rating`, `slider`, `progress`, `summary`. "`integer`-class" is not a + * `FieldType` member — `integer` / `int` are driver-internal column aliases + * (`type-compat.ts`, `sql-driver.ts`) — so it resolves to the integer-valued + * authorable members `rating`, `slider`, `progress`, which the driver stores + * as REAL columns beside `number`. `summary` is a roll-up persisted as a + * numeric column. + * - **temporal** = `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES`: + * `date`, `datetime`, `time`. `time` is a native TIME column on every SQL + * dialect the driver emits DDL for, and `AnalyticsResult.fields[].type` + * already describes `min` / `max` over it as temporal (#15768), 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`. `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. Booleans are + * the divergence class exactly: one dialect sums 0/1, another has no + * `sum(boolean)` at all. + * + * ## Relation to `isIncoherentAggregate` + * + * That predicate (`aggregation-policy.ts`) is the SEMANTIC opinion — "does + * this number mean anything" — and `sum` × `percent` is refused here on its + * authority. It also flags `count_distinct` × `percent`, which this table + * ACCEPTS: the ruling reads `count_distinct` as "any type", and counting + * distinct rates is backend-consistent even where it is odd. The two stay + * separate on purpose: this table answers "can every backend give one + * answer", the lint warning answers "is that answer meaningful". + * + * ## What this module deliberately does NOT do + * + * It refuses nothing itself. The refusals are the two consumer legs; a + * consumer that cannot resolve a field's type (a relationship PATH it has no + * metadata for) must NOT call the predicate with a guess — "cannot answer, do + * not block" is the consumer's tier, not this table's. + */ + +import type { AggregationFunction } from './query.zod'; +import { FieldType } from './field.zod'; + +/** + * The numeric class — the `NUMERIC_VALUE_TYPES` membership, spelled out here + * rather than imported so that a type joining that class elsewhere is a + * DECISION in this file (the pin test holds the two equal), never a silent + * widening of what a backend is asked to add up. + */ +const NUMERIC_AGGREGATE_FIELD_TYPES = [ + 'number', 'currency', 'percent', 'rating', 'slider', 'progress', 'summary', +] as const satisfies readonly FieldType[]; + +/** The numeric class minus the rate: what `sum` may add. */ +const ADDITIVE_AGGREGATE_FIELD_TYPES = [ + 'number', 'currency', 'rating', 'slider', 'progress', 'summary', +] as const satisfies readonly FieldType[]; + +/** The temporal class: `min` / `max` return a value of the field's own type. */ +const TEMPORAL_AGGREGATE_FIELD_TYPES = [ + 'date', 'datetime', 'time', +] as const satisfies readonly FieldType[]; + +/** Every declared `FieldType` — the `count` / `count_distinct` row. */ +const ANY_FIELD_TYPE: readonly FieldType[] = Object.freeze([...FieldType.options]); + +/** + * Which `FieldType`s each `AggregationFunction` may be applied to. Total over + * `AggregationFunction` (the `Record` key type makes a missing row a `tsc` + * error) and total over `FieldType` (a type is classified by being in, or out + * of, every row). Consumers read it through + * {@link isAggregateCompatibleWithFieldType}; the table is exported so a + * refusal can NAME the accepted set in its message. + */ +export const AGGREGATE_FIELD_TYPE_COMPATIBILITY: Readonly> = + Object.freeze({ + count: ANY_FIELD_TYPE, + count_distinct: ANY_FIELD_TYPE, + sum: Object.freeze([...ADDITIVE_AGGREGATE_FIELD_TYPES]), + avg: Object.freeze([...NUMERIC_AGGREGATE_FIELD_TYPES]), + min: Object.freeze([...NUMERIC_AGGREGATE_FIELD_TYPES, ...TEMPORAL_AGGREGATE_FIELD_TYPES]), + max: Object.freeze([...NUMERIC_AGGREGATE_FIELD_TYPES, ...TEMPORAL_AGGREGATE_FIELD_TYPES]), + }); + +/** + * May `aggregate` be applied to a field of `fieldType`? The single predicate + * both consumer legs call, so one pair cannot be accepted at authoring and + * refused at compile time. + * + * Fail-closed on vocabulary: a value outside `AggregationFunction` or outside + * `FieldType` answers `false`. The parameters are typed as `string` because + * the lint leg judges metadata BEFORE it is parsed; that is a convenience of + * the signature, not a tolerance — off-vocabulary input is refused, never + * mapped. + */ +export function isAggregateCompatibleWithFieldType(aggregate: string, fieldType: string): boolean { + if (!Object.prototype.hasOwnProperty.call(AGGREGATE_FIELD_TYPE_COMPATIBILITY, aggregate)) return false; + const row: readonly string[] = AGGREGATE_FIELD_TYPE_COMPATIBILITY[aggregate as AggregationFunction]; + return row.includes(fieldType); +} diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index e00c07a3d1..8f83fe5b2e 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -210,6 +210,11 @@ export * from './analytics.zod'; // and build-time coherence validation. export * from './aggregation-policy'; +// Aggregate × field-type compatibility (#16353) — which `AggregationFunction` +// a `DatasetMeasure` may apply to a field of which `FieldType`; the one table +// the dataset compiler and the lint rule both refuse against. +export * from './aggregate-field-type-compatibility'; + // Percent storage scale (0–1 fraction vs whole percentage points) — resolved // from field metadata so renderers never guess it from the value's magnitude. export * from './percent-scale'; From 3da58320a3e7c639fd5987e9e97edab6d63788ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 19:45:10 +0000 Subject: [PATCH 2/3] =?UTF-8?q?chore(spec):=20pin=20the=20aggregate=20?= =?UTF-8?q?=C3=97=20field-type=20table=20in=20the=20api-surface=20and=20ex?= =?UTF-8?q?port-origins=20baselines=20(#16353)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerated by `check:generated --fix` after a full spec build: the two stale shards (api-surface/data.json, export-origins/data.json) each gain the two new exports and nothing else. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --- packages/spec/api-surface/data.json | 2 ++ packages/spec/export-origins/data.json | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 6d5b872370..ad8388155c 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -4,6 +4,7 @@ "exports": [ "ACCEPTED_FILTER_COMPARAND_TYPES (const)", "ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE (const)", + "AGGREGATE_FIELD_TYPE_COMPATIBILITY (const)", "AGGREGATION_CASES (const)", "AGGREGATION_ROWS (const)", "ALL_OPERATORS (const)", @@ -738,6 +739,7 @@ "hookForm (const)", "injectedSystemColumnDefs (function)", "isAcceptedFilterComparand (function)", + "isAggregateCompatibleWithFieldType (function)", "isAnalyticsDateRangeRefusalIssue (function)", "isApiOperationAllowed (function)", "isApiPrimitive (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 48f3e7aa4b..198229b0da 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -4,6 +4,7 @@ "exports": { "ACCEPTED_FILTER_COMPARAND_TYPES": "src/data/filter-comparand-type.ts#ACCEPTED_FILTER_COMPARAND_TYPES (const)", "ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE": "src/data/filter-comparand-type.ts#ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE (const)", + "AGGREGATE_FIELD_TYPE_COMPATIBILITY": "src/data/aggregate-field-type-compatibility.ts#AGGREGATE_FIELD_TYPE_COMPATIBILITY (const)", "AGGREGATION_CASES": "src/data/aggregation-conformance.ts#AGGREGATION_CASES (const)", "AGGREGATION_ROWS": "src/data/aggregation-conformance.ts#AGGREGATION_ROWS (const)", "ALL_OPERATORS": "src/data/filter.zod.ts#ALL_OPERATORS (const)", @@ -725,6 +726,7 @@ "hookForm": "src/data/hook.form.ts#hookForm (const)", "injectedSystemColumnDefs": "src/data/injected-system-column-provenance.ts#injectedSystemColumnDefs (function)", "isAcceptedFilterComparand": "src/data/filter-comparand-type.ts#isAcceptedFilterComparand (function)", + "isAggregateCompatibleWithFieldType": "src/data/aggregate-field-type-compatibility.ts#isAggregateCompatibleWithFieldType (function)", "isAnalyticsDateRangeRefusalIssue": "src/data/analytics.zod.ts#isAnalyticsDateRangeRefusalIssue (function)", "isApiOperationAllowed": "src/data/api-derivation.ts#isApiOperationAllowed (function)", "isApiPrimitive": "src/data/api-derivation.ts#isApiPrimitive (function)", From d23f7705b83a259bfd192b178889a201564ee997 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 20:39:45 +0000 Subject: [PATCH 3/3] fix(spec): fail-closed shape guard on isAggregateCompatibleWithFieldType; correct the published grounds for the boolean and time rows (#16353) Contract-review patch round. The predicate now refuses any non-string input (a property-key lookup alone coerced ['count'] / { toString } to a member spelling); pinned. The TSDoc and changeset no longer claim booleans are the divergence class - #11152 has every backend answer them as numbers - and record that row, plus the min/max refusal over the string classes (#15768 types them as a supported 'string' result), as overrides of existing opinions referred to the maintainer. The time justification names SQLite's canonical TEXT form (#3994). No row changed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --- .../aggregate-field-type-compatibility.md | 4 +- ...aggregate-field-type-compatibility.test.ts | 35 ++++++++++- .../aggregate-field-type-compatibility.ts | 62 ++++++++++++++----- 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/.changeset/aggregate-field-type-compatibility.md b/.changeset/aggregate-field-type-compatibility.md index 5c852bcda6..9603b4f3ee 100644 --- a/.changeset/aggregate-field-type-compatibility.md +++ b/.changeset/aggregate-field-type-compatibility.md @@ -14,7 +14,9 @@ A dataset measure pairs an `aggregate` with a `field`, and nothing between autho | `min`, `max` | the numeric class plus `date`, `datetime`, `time` — both return a value of the field's own type | | every other pair | refused | -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 the driver stores it as a native TIME column and `AnalyticsResult.fields[].type` already describes `min` / `max` over it as temporal. `formula` is refused for arithmetic aggregates whatever its declared `returnType`: it is virtual in SQL storage, no column exists to aggregate. +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. **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. 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 ea09cccc99..b724dd1974 100644 --- a/packages/spec/src/data/aggregate-field-type-compatibility.test.ts +++ b/packages/spec/src/data/aggregate-field-type-compatibility.test.ts @@ -107,7 +107,7 @@ describe('isAggregateCompatibleWithFieldType — the pairs the card is about', ( } }); - it('refuses arithmetic over the divergence class (booleans) and over the computed / text / structured types', () => { + it('refuses arithmetic over booleans (row as ruled; membership referred, see module TSDoc) and over the computed / text / structured types', () => { 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, t)).toBe(false); @@ -141,4 +141,37 @@ describe('isAggregateCompatibleWithFieldType — the pairs the card is about', ( expect(isAggregateCompatibleWithFieldType('toString', 'text')).toBe(false); expect(isAggregateCompatibleWithFieldType('', '')).toBe(false); }); + + it('fails closed on SHAPE — a non-string that would coerce to a member spelling is refused, not looked up', () => { + // `hasOwnProperty.call` applies ToPropertyKey, so without the typeof guard + // `['count']` reads as 'count' and an object with a toString reads as + // 'sum'. A refusal gate must not be talked past by coercion. + const loose = isAggregateCompatibleWithFieldType as unknown as (a: unknown, f: unknown) => boolean; + expect(loose(['count'], 'number')).toBe(false); + expect(loose({ toString: () => 'sum' }, 'currency')).toBe(false); + expect(loose('sum', ['currency'])).toBe(false); + expect(loose('min', { toString: () => 'date' })).toBe(false); + expect(loose(undefined, 'number')).toBe(false); + expect(loose(null, 'number')).toBe(false); + expect(loose('count', undefined)).toBe(false); + expect(loose('count', null)).toBe(false); + expect(loose(1, 'number')).toBe(false); + expect(loose('count', 1)).toBe(false); + 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); + } + // 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']) { + expect(isAggregateCompatibleWithFieldType('min', t)).toBe(false); + expect(isAggregateCompatibleWithFieldType('max', t)).toBe(false); + } + }); }); diff --git a/packages/spec/src/data/aggregate-field-type-compatibility.ts b/packages/spec/src/data/aggregate-field-type-compatibility.ts index f571bea084..c7b31303da 100644 --- a/packages/spec/src/data/aggregate-field-type-compatibility.ts +++ b/packages/spec/src/data/aggregate-field-type-compatibility.ts @@ -46,18 +46,45 @@ * as REAL columns beside `number`. `summary` is a roll-up persisted as a * numeric column. * - **temporal** = `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ `CLOCK_TIME_TYPES`: - * `date`, `datetime`, `time`. `time` is a native TIME column on every SQL - * dialect the driver emits DDL for, and `AnalyticsResult.fields[].type` - * already describes `min` / `max` over it as temporal (#15768), so it sits - * beside the two members the ruling named. + * `date`, `datetime`, `time`. The ruling named the first two; `time` is the + * third temporal class and takes the same treatment: its stored form is a + * dialect question exactly like the other two (native TIME on Postgres and + * MySQL `TIME(3)`, canonical `HH:MM:SS[.fff]` TEXT on SQLite — #3994), the + * canonical form orders chronologically on every one of them, and + * `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`. `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. Booleans are - * the divergence class exactly: one dialect sums 0/1, another has no - * `sum(boolean)` at all. + * `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: + * + * - **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, + * `STRING_SOURCE_FIELD_TYPES`) already types `min` / `max` over them as a + * supported `'string'` result. The refusal is defensible — the ORDER of + * strings is collation-dependent, so two backends can return two different + * "smallest" values — but it overrides that existing opinion, and is + * recorded as such rather than presented as agreement. * * ## Relation to `isIncoherentAggregate` * @@ -126,13 +153,18 @@ export const AGGREGATE_FIELD_TYPE_COMPATIBILITY: Readonly 'sum' }` to a member spelling and let the + * pair through. The `string` parameter types are a convenience of the + * signature, not a tolerance — off-vocabulary or off-shape input is refused, + * never mapped. */ export function isAggregateCompatibleWithFieldType(aggregate: string, fieldType: string): boolean { + if (typeof aggregate !== 'string' || typeof fieldType !== 'string') return false; if (!Object.prototype.hasOwnProperty.call(AGGREGATE_FIELD_TYPE_COMPATIBILITY, aggregate)) return false; const row: readonly string[] = AGGREGATE_FIELD_TYPE_COMPATIBILITY[aggregate as AggregationFunction]; return row.includes(fieldType);