feat(spec): declare the aggregate × field-type compatibility matrix (AggregationFunction × FieldType) dataset measures are refused against (#16353) - #16684
Conversation
…16353) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf
…and export-origins baselines (#16353) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 130 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d232adddc49a668e2b152759b0e5b39fdcc043e7 && git checkout d232adddc49a668e2b152759b0e5b39fdcc043e7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c8e5ac645f65dbaed0e44d8e40644f28a156a022 d23f7705b83a259bfd192b178889a201564ee997 && git checkout -B drift-repro c8e5ac645f65dbaed0e44d8e40644f28a156a022 && git merge --no-ff d23f7705b83a259bfd192b178889a201564ee997
node scripts/docs-audit/affected-docs.mjs --json c8e5ac645f65dbaed0e44d8e40644f28a156a022
|
…ype; 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf
Fixes #16353
Spec half of the split ruled on the analytics compile card (director seat, decision batch #59, 2026-09-06, "both legs, table in spec"). #16099 remains open — the compile-time refusal is that card's leg and is not addressed here; the authoring-time lint rule is its devx sub-card. Both legs consume the one table this PR declares.
Clause-②: yes — a previously accepted authoring shape (
DatasetMeasureSchemaaccepts anyaggregate×fieldpair today) becomes refused once the two consumer legs land against this table. The PR carriesneeds:contract-review.Patch round (contract review VERDICT: FAIL, three findings adopted verbatim; no row changed): (1) the published ground for the boolean row was false and is replaced by what this tree can defend; (2) the predicate is now fail-closed on SHAPE as well as vocabulary; (3) the
min/maxrefusal over the string classes is named as an override ofmeasureResultType(#15768). Thetimejustification is corrected for SQLite (canonical TEXT, #3994). Details in each section below.What changed
packages/spec/src/data/aggregate-field-type-compatibility.ts, exported from@objectstack/spec/data:AGGREGATE_FIELD_TYPE_COMPATIBILITY: Readonly Record over AggregationFunction of readonly FieldType arrays— frozen; total overAggregationFunction(theRecordkey type makes a missing row atscerror) and total overFieldType(every member is in or out of every row).isAggregateCompatibleWithFieldType(aggregate, fieldType)— the one predicate both legs call. Fail-closed on vocabulary (a driver alias such asinteger, a retiredarray_agg, the ruling'scountDistinctspelling all answerfalse) AND on shape: anything that is not a string answersfalse. The shape half is the patch-round fix — a property-key lookup alone applies ToPropertyKey, so['count']and an object whosetoStringreturns'sum'coerced to member spellings and passed a refusal gate; atypeofguard now precedes the lookup.isIncoherentAggregate, and — as of the patch round — the two rows that override existing opinions, recorded as overrides rather than as agreement.aggregate-field-type-compatibility.test.ts(18 cases): row totality againstAggregationFunction.options; classification totality againstFieldType.options; the ruled rows pinned as literal sets; the numeric bucket held equal toNUMERIC_VALUE_TYPESand the temporal bucket toCALENDAR_DATE_TYPES∪INSTANT_TYPES∪CLOCK_TIME_TYPES; the motivating defect (avg×datetime) refused; theisIncoherentAggregatedivergence pinned visibly; new: the shape guard pinned over arrays,toStringobjects,null,undefined, numbers and a symbol on both parameters, and the two recorded overrides pinned as the rows they are.packages/spec/src/data/index.ts: oneexport *line.check:generated --fixafter a full spec build:api-surface/data.jsonandexport-origins/data.jsoneach gain exactly the two new exports (4 added lines in total). The patch round changed no signature;check:generatedon the rebuilt dist reports all 15 artifacts current..changeset/aggregate-field-type-compatibility.md,@objectstack/spec: minor— states the narrowing, why it is not breaking in this release, and (patch round) the two recorded overrides with their honest grounds.The table (ruled rows, finalised against the full membership)
Measured on
origin/mainatd5c40222f3:FieldTypehas 49 members,AggregationFunctionhas 6 (count,sum,avg,min,max,count_distinct).count,count_distinctFieldTypesumnumber,currency,rating,slider,progress,summary— numeric class EXCEPTpercentavgnumber,currency,percent,rating,slider,progress,summarymin,maxdate,datetime,timeHow the ruling's categories resolved (the part the card delegated to the spec seat):
NUMERIC_VALUE_TYPESinfield-value.zod.ts(number,currency,percent,rating,slider,progress,summary) — the class the SQL DDL stores as REAL columns (sql-driver.tscreateColumn).integer-class" → noFieldTypemember is namedinteger;integer/intare driver-internal column aliases (type-compat.ts,sql-driver.ts). The integer-valued authorable members arerating,slider,progress, which land in the numeric bucket above.date,datetime)" → the ruling named two;timeis the third temporal class (CLOCK_TIME_TYPES) and takes the same treatment. Corrected justification: its stored form is a dialect question exactly like the other two — native TIME on Postgres,TIME(3)on MySQL, canonicalHH:MM:SS[.fff]TEXT on SQLite (Field.time repeats the #3912 pattern: writes unnormalised, repaired only on read — window filters and ORDER BY are silently wrong on SQLite #3994) — the canonical form orders chronologically on every one of them, andAnalyticsResult.fields[].typealready describesmin/maxover it as temporal (A dataset measure over a datetime is typednumberin the analytics response, and a metric tile ignores theformatstyle it is handed — measured on 17.3.0 #15768,TEMPORAL_SOURCE_FIELD_TYPESliststime). Placed withdate/datetimeformin/maxonly; reported as a completion the ruling did not spell out.sum/avg/min/max(39 members):address,audio,autonumber,avatar,boolean,checkboxes,code,color,composite,email,file,formula,html,image,json,location,lookup,markdown,master_detail,multiselect,password,phone,qrcode,radio,record,repeater,richtext,secret,select,signature,tags,text,textarea,toggle,tree,url,user,vector,video.formulais VIRTUAL in SQL storage (createColumnemits no column), so no arithmetic aggregate can be lowered to it whatever its declaredreturnType.Two refused rows that override existing opinions (recorded, not resolved; rows unchanged)
boolean,toggle) — patch-round correction. The previous text called them "the divergence class" (one dialect sums 0/1, another has nosum(boolean)). That is false at this tree: maintainer ruling [finding]AGGREGATION_ROWShas no boolean column, so the cross-driver aggregation conformance family cannot see a boolean aggregand on any face #11152 pins that booleans aggregate as numbers on every face with no per-aggregate exception —AGGREGATION_CASESinpackages/spec/src/data/aggregation-conformance.tspinssum(flag)=3,avg(flag)=0.5,min(flag)=0,max(flag)=1across six backends — anddriver-sqlcasts a boolean aggregand tointon Postgres to make that hold (sql-driver.ts, driver-sql: boolean aggregands need a lowering cast on PG (+ a MySQL min/max presentation check) — the ruledfalse/true+ arithmetic answers are unproducible on the PG face #11635). The refusal is therefore NOT grounded in backend divergence; the backends agree. The row stays exactly as batch Validation Protocol: Cross-Field, Async, and Conditional validation #59 stated it ("every other pair: refused"). Whether booleans belong insum/avg/min/maxis a collision between two maintainer rulings (batch Validation Protocol: Cross-Field, Async, and Conditional validation #59 vs [finding]AGGREGATION_ROWShas no boolean column, so the cross-driver aggregation conformance family cannot see a boolean aggregand on any face #11152) and goes to the maintainer as its own decision card; the TSDoc and changeset now say so instead of the false claim.min/max— undeclared contradiction, now declared.service-analytics'measureResultType(A dataset measure over a datetime is typednumberin the analytics response, and a metric tile ignores theformatstyle it is handed — measured on 17.3.0 #15768, merged) typesmin/maxoverSTRING_SOURCE_FIELD_TYPES(STRING_VALUE_TYPES∪SINGLE_OPTION_TYPES∪REFERENCE_VALUE_TYPES∪autonumber) as a supported'string'result. This table refuses those pairs. The refusal is defensible — string ORDER is collation-dependent, so two backends can return two different "smallest" values — but it overrides an existing runtime opinion, which the first round's sweep missed. Row unchanged; named here, in the TSDoc, in the changeset and in the report.Conflicts and forks reported rather than resolved silently (unchanged from round 1; all judged defensible at review)
count_distinct×percent. The existing spec opinion isisIncoherentAggregateinpackages/spec/src/data/aggregation-policy.ts(the card'sanalytics-service.tsattribution was corrected on the card by the PM), and it flags this pair; the ruling readscount_distinctas "any type". The table follows the ruling; the divergence is pinned. Open question 1.timeinmin/max— completion beyond the two members the ruling named. Open question 2.progressstays insumby the mechanical rule (the ruling excluded onlypercent, onisIncoherentAggregate's authority). Open question 3.formulawithreturnType: 'number'— refused (virtual in SQL storage; aFieldType-keyed table cannot seereturnType). Open question 4.Why the changeset is
minor, not breakingThis PR ships a table and a predicate that nothing yet enforces:
DatasetMeasureSchemaaccepts byte-for-byte what it accepted before, no export is removed or narrowed, no runtime path reads the table. Two new exports in the published.d.tsare an additive widening of the public surface (minorper the 2026-09-04 ruling on #15294). The refusal — and the breaking declaration, migration prescription and ADR-0087 disposition — arrives with the consumer legs' changesets. The changeset text states the narrowing so an upgrading author reads the contract before it is executed.Verification — patch round (all at
d23f7705b8, the final commit; tree clean)turbo run build --concurrency=2 --filter=@objectstack/formula --filter=@objectstack/lintunderos-verify-lock.sh(builds@objectstack/specas their dependency with its full build script:gen:schema+gen:openapi+ tsup + DTS +check-dts-emitted+check-dev-prereqs --stamp) —VERDICT command-exit 0, 4 tasks successful;gen:schemamoved no tracked file.vitest run src/data/aggregate-field-type-compatibility.test.ts src/data/aggregation-policy.test.ts src/data/field-value.test.ts—Test Files 3 passed (3) · Tests 49 passed (49)(18 in the new file).pnpm --filter @objectstack/spec typecheck—VERDICT command-exit 0; test-layer ledger unchanged (54 file(s) / 261 error(s) / 145 pinned signature(s) held).check:generatedon the rebuilt dist —✓ All 15 generated artifacts are up to date.(no signature moved; no regeneration needed this round).typeofline (anchor count 1→0, marker 0→1 on disk; blobc7b31303…→cb2a65c7…) →Tests 1 failed | 17 passed (18): exactly the shape pin red, everything else green. Restored withgit checkout HEAD -- ABSOLUTE_PATHunder an EXIT/INT/TERM trap; proof:git diff HEADempty,git status --porcelainempty, disk blobc7b31303…equals theHEADblob, marker count 0. Round 1's row ablation (percentintosum→ 3 pins red, byte-identical restore) stands.requireentries (rebuilt dist): all four entries load; both./dataentries answersum×percentfalse,min×timetrue, and — through the published build —['count']×numberfalse and an object whosetoStringreturns'sum'×currencyfalse.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths; from the merge base atd23f7705b8): 76 commands, identical to round 1's list, all run, exact strings recorded,--ranverdict✓ 76 derived famil(ies) accounted for — 76 run, 0 NOT-MEASURED. Exit codes: 74 ×0(line 31check:doc-formula-expressionsnow0directly, its prerequisite built in the same turbo run); two × gate-declared3(PREREQUISITE NOT MET — the gate's own code, not a shell timeout; nothing measured, not a finding):pnpm check:dual-build-cjs-loads— needs every package'sdist/(whole-farm build; no package-scoped mode). Declared narrowing: the CJS probe above over the only package the diff touches. CI runs the full gate.pnpm check:type-check-debt— the re-measure needs the whole package closure built. Declared narrowing: spec's owntypecheckgreen (above);check:type-check-coverage(line 72) exit 0. CI runs the full gate.pnpm check:nul-bytesexit 0 (line 59).验收备注
countDistinct; the enum member iscount_distinct. The predicate answersfalseto the camelCase spelling (pinned). 承接者:无.formulafields are virtual in SQL storage (createColumnemits no column), so acount×formulameasure — accepted by this table per the ruling's "any type" — has no column forCOUNT(col)to read. A field-materialisation question for the compile leg, orthogonal to aggregate × type. 承接者:No layer refuses an incoherent aggregate / field-type pair — a dataset measureavgover a datetime works on SQLite and errors on Postgres #16099 (compile leg). Not reproduced here (DDL reading only), so not filed as a defect.sum/avg/min/max(batch Validation Protocol: Cross-Field, Async, and Conditional validation #59 vs [finding]AGGREGATION_ROWShas no boolean column, so the cross-driver aggregation conformance family cannot see a boolean aggregand on any face #11152) — the maintainer's own decision card, per the coordinator.AggregationFunctionDECLARATION ispackages/spec/src/data/query.zod.ts:159(DatasetMeasureSchemaisui/dataset.zod.ts:142);api/analytics.zod.tsonly imports it andshared/enums.zod.tsonly mentions it in comments.packages/spec/src/data/analytics.zod.tshas zero hits (confirmed) and is untouched. Neither declaration file was edited.维护者速读(草稿)
改了什么:在
@objectstack/spec/data里新增一张「聚合函数 × 字段类型」兼容表和一个判定函数。表按董事裁决(第 59 批)填好六行:count/count_distinct对全部 49 种字段类型放行;sum只对可相加的数值类型放行(不含百分比);avg对含百分比的数值类型放行;min/max再加上日期、日期时间、时间三种。其余搭配一律记为拒绝。本 PR 只声明这张表,不做任何拒绝。补丁轮:判定函数对非字符串输入(数组、带 toString 的对象)一律拒绝;文档不再声称「布尔值是后端分歧类」——现有裁决 #11152 已让所有后端把布尔值当数字聚合——改为如实记录该行沿用裁决、成员归属另立决策卡;min/max拒绝字符串类字段一事,注明它覆盖了 #15768 已有的运行时意见。为什么改:今天一个数据集度量可以把「平均值」套在日期时间字段上,编译成
AVG(col)直达数据库——SQLite 返回一个毫秒平均数,Postgres 直接报错。同一份元数据在两个后端给出两个答案,正是 Prime Directive #12 要消灭的形态。哪些搭配可接受是一份契约,应当在 spec 声明一次,由编译端(#16099)和 lint 端两条腿共同执行。风险与代价(含回滚):本 PR 本身不改变任何现有行为——schema 接受的内容逐字节不变,没有运行时读取这张表。真正的收窄随两条消费腿落地,届时今天能写的部分度量会被拒绝,需要作者把聚合改为字段类型支持的那一个。回滚只需删除这个模块、一行 barrel 导出和两处基线条目。需要维护者留意的判断:① 布尔值是否应进入
sum/avg/min/max——第 59 批裁决(默认拒绝)与 #11152(所有后端按数字作答)相撞,表暂按第 59 批,问题另立决策卡;②min/max拒绝字符串类字段,而 #15768 已把它们的结果类型定为字符串——拒绝有理(排序依赖 collation),但覆盖了既有意见;③ 现有isIncoherentAggregate认为count_distinct × percent不合理,而裁决说count_distinct对任意类型放行;④ 裁决只点名date/datetime,本表把time一并放进min/max;⑤progress留在sum里。席位意见:(留空,由席位定稿)
你要做的:确认上面五个判断是否符合预期,尤其是①的布尔值归属决策;若认可,本 PR 作为两条消费腿的前置合入。合并不关闭 #16099。
🤖 Generated with Claude Code — session https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf