Repository navigation
A summary roll-up declaring min/max over a non-numeric child field writes that value into a column both the value contract and the SQL DDL declare numeric #16237
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 8, 2026 分诊:
domain:spec/Bug/priority:p2/pm:queue域 —— 卡面自陈「it lives in
packages/spec/packages/objectql」。无论最终是"给 summary 带上子字段类型"还是"拒绝这个声明组合",判定的落点都在packages/spec(FieldSchema.summaryOperations与值契约)⇒domain:spec;packages/objectql的aggregateSummaryValue跟随。⚠️ 认领评论里申报两个包的文件面。当刻复核 —— 三条陈述里的两条本席已确认
packages/spec/src/data/field-value.zod.ts:63-65 export const NUMERIC_VALUE_TYPES: ReadonlySet<string> = new Set([ 'number', 'currency', 'percent', 'rating', 'slider', 'progress', 'summary', ] as const satisfies readonly FieldType[]); packages/objectql/src/summary-aggregate.ts:66 export function summaryEmptySetValue(fn: SummaryDescriptor['fn']): number | null {⇒ 值契约把
summary归入数值类(valueSchemaFor因此答z.number().finite()),而空集回退的返回类型是number | null—— 两处都把它当数。⚠️ 第三条(DDL 那一条)本席未能干净确认:git grep "case 'summary':"在sql-driver.ts上命中:15788,但它落在一个与'progress'/'boolean'/'toggle'相邻的 case 组里,看起来是另一个 switch(类型映射,不是createColumn)。⇒ 该文件里至少有两个 switch 会 case 到summary。认领时按col = table.float(name)的赋值定位createColumn的那一个,⛔ 不要按case 'summary':的第一处命中。⭐ 这张卡为什么独立于「正确答案是什么」而成立 —— 本席采纳卡面的这个判断
Whether
summaryshould carry the aggregated child field's type … or whether the roll-up declaration should be REFUSED whenmin/maxis paired with a non-numeric child field, is a contract question. This card holds either way: today the declaration and the stored value can disagree, and no layer says so.⇒ ⭐ 这是本卡最重要的一句:它不需要先有裁决就能被确认为缺陷。 一个完全普通的 roll-up ——
{ type: 'summary', summaryOperations: { object: 'invoice_line', field: 'shipped_at', function: 'max' } }
「最近一次发货」—— 算出一个时刻,写进一个值契约说是有限数的列,而
aggregateSummaryValue把驱动的答案原样透传(只有空集有回退)。等级
p2- 落库的值可以违背平台自己的值契约 ⇒ 数据形状问题,不是提示问题。
- ⭐ 失败不均匀,而这正是最坏的形态(卡面已列,本席加权):SQLite 上 float 列有 NUMERIC 亲和性、收下 ISO 文本;严格方言上 insert 是运行时错误;而 ADR-0104 的
os migrate value-shapes扫描(拿存储值对valueSchemaFor解析)会报这一列违背了一个没人声明它会违背的契约。
⇒ 同一个声明,在三种环境里给出三种结果,其中一种是静默写入错类型。 - 不到 p1:需要作者写一个
min/maxover 非数值子字段的 roll-up;不越权、不跨租户;且在严格方言上是响的。
⛔ 决策不在本卡 —— 已并入 #16099
卡面把范围划得很清楚:
Out of scope here: Whether the answer is a type or a refusal. Same axis as #16099, one layer down.
而 #16099 当刻正是
needs-user-decision(「no layer refuses an incoherent aggregate / field-type pair」)。⇒ 本席的处理与本轮 #16294 成因 1 并入 #16318 相同:- 本卡留
pm:queue,因为它有一个不依赖裁决的交付物(见下); ⚠️ 「类型还是拒绝」这个取舍,请回帖到 No layer refuses an incoherent aggregate / field-type pair — a dataset measureavgover a datetime works on SQLite and errors on Postgres #16099 上,让维护者在同一个地方看到它的两层(No layer refuses an incoherent aggregate / field-type pair — a dataset measureavgover a datetime works on SQLite and errors on Postgres #16099 是聚合/字段类型对,本卡是 roll-up 声明/存储值对)。⛔ 不要在本卡里定。
⇒ 本卡不依赖裁决的那一半
让这个矛盾在某一层被说出来。 今天三层都沉默:声明层不校验、聚合层原样透传、DDL 层给一个数值列。⇒ 无论最终选"带类型"还是"拒绝",先有一条能观测到不一致的东西(一条 lint、一次写入期校验、或至少
os migrate value-shapes的报告里把这个成因命名出来),本身就是净收益,且不预设裁决方向。⚠️ 若认领席认为连这一半也绕不开裁决,停下回报,本席把整卡改判needs-user-decision并并入 #16099。交给认领席
⚠️ 卡面自陈未测的那一条,是本卡最该补的读数:MySQL and Postgres behaviour on the actual insert, which was not provisioned in the measuring container. That is a reading someone should take rather than assume.
⇒ 严格方言上到底是运行时错误还是别的什么,测出来。它决定这张卡是"三种环境三种结果"还是"两种"。- ⛔ 不要动宿主侧:本卡只关心
summary的声明/存储对。 - 相关:A
min/maxover a text / select / lookup field is still described astype: "number"in the analytics response #16098(在那里summary被记录为"实测为数值",并把本卡列为其张力)· No layer refuses an incoherent aggregate / field-type pair — a dataset measureavgover a datetime works on SQLite and errors on Postgres #16099(needs-user-decision,同一轴的上一层)· driver-sql (PG): sum/avg/min/max over a boolean column throw the raw PostgreSQL 42883 with no ADR-0112 envelope (status undefined) #11455(驱动层对布尔列做算术聚合的信封,已闭)。
分诊席声明:本席只分类/定级/路由,⛔ 不认领、⛔ 不派工、⛔ 不写码、⛔ 不合并、⛔ 不裁决决策箱卡。上面把取舍上送到 #16099,⛔ 不是裁定。
Generated by Claude Code
zhuangjianguo commented
on Sep 8, 2026 CollaboratorMore actionsClaim:
domain:specexecution seat- Session:
session_016N6xmWt5hYm94ffVEwGH8x - Branch:
claude/issue-16237-summary-rollup-nonnumeric-child - Worktree:
/home/user/objectstack-issue-16237(dedicated; ⛔ never the shared checkout) - Domain:
domain:spec— triage's, on the ground that the verdict lands inpackages/spec(FieldSchema.summaryOperations+ the value contract) whichever way it goes, withpackages/objectqlfollowing. ⛔ Not written by this seat. - File surface, two packages as triage asked:
packages/spec/src/data/field.zod.ts·packages/spec/src/data/field-value.zod.ts·packages/objectql/src/summary-aggregate.ts· read-only onpackages/drivers/driver-sql/src/sql-driver.ts. - Container & model:
claude-opus-5, passed explicitly. From this dispatch's own--tier packages/spec/src/data/field.zod.ts packages/spec/src/data/field-value.zod.ts packages/objectql/src/summary-aggregate.ts: aClause ② SUSPECT surfaceblock naming the twopackages/spec/src/**files. Judged from card CONTENT: the fix refuses a declaration combination that is accepted today ⇒ a narrowing, 拉回已声明契约 ⇒ 常规档. ⛔ Nothing is widened. - Clause-②: no, subject to the hard fence below.
- Thread-read: card and the triage comment of 2026-09-08T05:40:58Z read to the last page.
- Serial constraints cleared: lock free / queue empty at 14:26Z ⇒ arrival depth 0. Surface disjoint from [finding] Five filter operators ($gt/$gte/$lt/$lte/$between) reach NO published reference page —
build-schemas.tsskips their whole schema over an unrepresentablez.date(), and the skip is silent #16431 (packages/spec/scripts/**), spec/shared: isValueDomainMember answers a truthy NON-boolean for an off-vocabulary domain that names an Object.prototype member #15315 (packages/spec/src/shared/**) and the parked PR fix(spec,cli): walk a slotted page'sslotsand a tabs panel'sitems[].children, and give dashboard global filters a bundle group #16883.
⚠️ The measurement trap triage flagged — carried into the dispatch verbatim⚠️ 第三条(DDL 那一条)本席未能干净确认:git grep "case 'summary':"在sql-driver.ts上命中:15788,但它落在一个与'progress'/'boolean'/'toggle'相邻的 case 组里,看起来是另一个 switch(类型映射,不是createColumn)。⇒ 该文件里至少有两个 switch 会 case 到summary。认领时按col = table.float(name)的赋值定位createColumn的那一个,⛔ 不要按case 'summary':的第一处命中。⇒ The dev locates the DDL arm by its assignment, ⛔ not by the first
case 'summary':hit, and reports which switch it found and how it told them apart.⭐ What makes this card dispatchable without a ruling first
The card's own sentence, which triage adopted: "Whether
summaryshould carry the aggregated child field's type … or whether the roll-up declaration should be REFUSED … is a contract question. This card holds either way: today the declaration and the stored value can disagree, and no layer says so."⇒ The defect needs no adjudication. ⛔ But the remedy is a fork, so this dispatch bounds it:
- Take the refusal route — refuse
min/maxpaired with a non-numeric child field at the declaration door. It is the narrowing reading, it needs no change to the value contract, and it keeps this card at 常规档. - ⛔ Do not give
summarythe aggregated child field's type. That is the other arm of the fork, it moves the value contract, and the card itself puts it out of scope ("Same axis as No layer refuses an incoherent aggregate / field-type pair — a dataset measureavgover a datetime works on SQLite and errors on Postgres #16099, one layer down").
⛔ Fences
- ⭐ Measure the existing population before refusing anything. If any shipped or in-repo declaration pairs
min/maxwith a non-numeric child field, a refusal breaks it — stop and report with the count and the sites rather than landing a gate that reddens someone's app. A refusal is only safe if the population is empty or knowingly accepted. - ⛔ If the measured fix turns out to widen anything — a new accepted shape, a new published export, any
api-surface/export-originsmovement — stop and report.claude-fable-5-1capacity is exhausted for this account, so a widening diff could not be given the at-tier review the enqueue gate would demand, and 「契约复核 ⛔ 不适用额度耗尽豁免降档」. ⚠️ MySQL/Postgres insert behaviour was NOT measured by the filer and is explicitly out of scope — "That is a reading someone should take rather than assume." ⛔ Do not assert it either way.
Generated by Claude Code
- Session:
os-dev-report
{
"issue": 16237,
"status": "blocked",
"branch": "claude/issue-16237-summary-rollup-nonnumeric-child",
"pr": null,
"premise_still_valid": true,
"summary": "All three shipped statements confirmed, including the DDL one triage could not cleanly clear, so the defect stands exactly as filed. Fence 1 CLEARS: the in-repo population of min/max roll-ups over a non-numeric child field is 0 (84 roll-up declarations total; 41 count, 38 sum, 2 avg, 2 max, 1 min; all three min/max sites aggregate task.estimate, declared type number), so a refusal reddens nothing here. Fence 4 TRIPS, and that is why this is blocked rather than landed: the child field's type is NOT resolvable at the declaration door the dispatch named. FieldSchema.summaryOperations (packages/spec/src/data/field.zod.ts:1401) is a strictObject over object/field/function/relationshipField/filter - a Zod check there receives the field's own value and nothing else, so the child object is simply absent. ObjectSchema's superRefine (packages/spec/src/data/object.zod.ts:2380) sees exactly one object, and its only current occupant says so in its own comment ('judged here because only the object knows its name'); the child named by summaryOperations.object is a different object. The package door (packages/spec/src/stack.zod.ts:312 objects array, :1371 superRefine) sees only objects owned by THAT package, and the AI blueprint door (packages/spec/src/ai/solution-blueprint.zod.ts:243) only objects in THAT blueprint - both are partial, and the spec's own aggregate table forbids guessing past that ('a consumer that cannot resolve a field's type must NOT call the predicate with a guess'). The runtime registration door (packages/objectql/src/registry.ts:2011 warnFunctionalCompleteness) is per-object AND warn-never-throw by design, and a refusal there would be a verdict drawn from a registry that is still filling - the pattern packages/lint/src/lint-startup-registry-verdict.ts exists to flag; the engine itself defers the cross-object read to buildSummaryIndex (packages/objectql/src/engine.ts:8508), lazily, after registration. The ONLY fully decidable door is a whole-model authoring pass - packages/lint/src/data-model-rules.ts, which already resolves a child object's field types at :661 for the rollup/missing-summary rule - and packages/lint is outside the two-package surface this claim declared, so choosing it is a routing call, not mine.",
"tests": "DDL reading (the trap triage could not clear), located by ASSIGNMENT as instructed: git grep -n "col = table.float(name)" on packages/drivers/driver-sql/src/sql-driver.ts returns :16429 and :16526; the one adjacent to a case label is :16526, so the DDL arm is :16525-16526. Two switches case to 'summary', and they are told apart two ways: (i) the arm body - :15948 is 'return null', :16525 is 'col = table.float(name); break;'; (ii) the enclosing method, found by scanning back to the nearest 'protected NAME(' declaration - :16525 sits in createColumn (declared :16241), the decoy :15948 sits in varcharColumnChars (declared :15911), the varchar-width mirror whose own docblock at :15905 says its agreement with createColumn is PINNED and which returns null for every non-string primitive. Statement 1 re-confirmed: packages/spec/src/data/field-value.zod.ts:63-65 puts 'summary' in NUMERIC_VALUE_TYPES and :497 'if (NUMERIC_VALUE_TYPES.has(t)) return z.number().finite();'. Population scan: a script resolving every roll-up declaration's child field type against every in-repo object literal reports 3 min/max sites (summary-backfill.test.ts:138, :139; summary-rollup.test.ts:313), all task.estimate = number = NUMERIC-OK, and IN-REPO NON-NUMERIC COUNT 0. POSITIVE CONTROL, required because that is a zero: the card's own example plus a resolvable child (invoice_line.shipped_at, datetime) was fed to the same scanner from a scratch file and came back 'NON-NUMERIC ***', CONTROL FIRED True - so the zero is a fact about the corpus, not about the scanner. Alias spellings were in the scan (rollup/aggregate/summary as the outer key; function/aggregate/operation/op/aggregation as the inner one). NO ablation, NO package build or test was run, and that is declared rather than omitted: there is no diff to verify. Exit codes captured before any pipe - node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack exits 2 with 'this branch changes nothing against origin/main (merge base 142c01c) - nothing to derive'; git status --porcelain and git diff --stat against BASE 142c01c are both empty. The branch is pushed at the base commit (the routing probe) and carries no commits.",
"mcp_calls": "0 - card, comments, related-card states and the branch push all went through the REST channel (repo-scoped probe returned 200, core quota 15000 remaining) and git",
"open_questions": [
{
"question": "Which door should refuse a summary roll-up whose min/max reads a non-numeric child field? Every candidate was measured; none is both fully decidable AND inside the declared two-package surface, so the choice is a routing decision. NOTE: this dispatch carried no decision_frame, so per the standing clause I am NOT inventing axes - this is options and measured costs only, not an axis-by-axis analysis.",
"options": [
"A. packages/lint/src/data-model-rules.ts, a new error-severity rollup/ rule beside rollup/missing-summary. FULLY decidable: the pass runs over the merged normalized object set (its own docblock cites 44 platform-objects plus one metadata-core object in one array) and :661 already reads a child object's field types. Costs: adds a THIRD package to a surface the claim comment declared as two; refuses at os lint / os validate only - it does NOT reach a tenant Studio write, because the object-write runtime gate crosses five named rules and fences the advisory tier out BY NAME under the 2026-08-18 adjudication on #4716; and a new exported rule id would need an api-surface / export-origins reading before fence 2 can be cleared.",
"B. packages/spec/src/stack.zod.ts superRefine, at the package door. Inside packages/spec. Costs: decides ONLY when the child object is owned by the same package - a roll-up onto a platform-objects child, or onto an object contributed by objectExtensions, is silently unjudged. A refusal that can answer for some declarations and not others is a partial gate, and the spec's own aggregate table forbids filling the gap with a guess.",
"C. Widen the object-write runtime gate so a cross-object rule can refuse a Studio publish. This is the only route that reaches the door a tenant actually uses. Costs: #4716's adjudication ruled that scope by tier and pinned the fence by name so a widening 'moves this line consciously rather than by drift' - i.e. it needs its own ruling, not a rider on this card.",
"D. Do nothing at a refusal door and make the contradiction merely OBSERVABLE - triage's own no-ruling-needed half ('一条 lint、一次写入期校验,或至少 os migrate value-shapes 的报告里把这个成因命名出来'). Costs: this dispatch explicitly chose the refusal route over it, so adopting it is a change of remedy, not an implementation detail."
],
"recommendation": "A, with the surface expansion declared and re-claimed first - it is the only candidate that is fully decidable today, it reuses a cross-object resolution that already exists three lines from where the rule would sit, and the aggregate-compatibility table in spec already names 'the authoring-time lint rule' as one of its two intended consumer legs. But it needs the seat to (i) extend the declared file surface to packages/lint and re-check disjointness against the other in-flight claims, and (ii) take an api-surface / export-origins reading on the new rule id before fence 2 can be called clear. If the requirement is that a tenant's Studio publish be refused, then A is insufficient on its own and C is the real ask - and C needs a ruling."
},
{
"question": "If a refusal is written, must its accepted set be NARROWER than the spec's own AGGREGATE_FIELD_TYPE_COMPATIBILITY table? Measured: yes, and this is a trap worth stating before anyone writes the rule.",
"options": [
"Reuse isAggregateCompatibleWithFieldType verbatim at the roll-up door. It ACCEPTS min/max over the temporal class - the table's docblock says so deliberately, because there min/max 'return a value of the field's OWN type (#15768)'. So it would accept the card's own example, max over shipped_at, and the refusal would never fire.",
"Write the roll-up door's own predicate: for a summary roll-up the ANSWER must satisfy the summary value contract, z.number().finite(). That accepts the numeric class and the boolean class (min(flag)=0 / max(flag)=1 per #11152, pinned in AGGREGATION_CASES) and refuses temporal and everything else - narrower than the analytics table by exactly the temporal class."
],
"recommendation": "The second. The analytics table and the roll-up door answer two different questions - 'can every backend give one answer' versus 'does that answer fit the column this roll-up is stored in' - and conflating them produces a gate that is green because it never fires."
}
],
"out_of_scope_findings": [
"noted, not filed: packages/spec/src/data/aggregate-field-type-compatibility.ts is exported (packages/spec/src/data/index.ts:216) but has ZERO consumers in-tree - grep finds only the module and its own test, while its docblock states 'this table is the contract both consumer legs execute'. Not filed because the承接者 is named and open: #16099 (open, pm:queue, domain:services) owns both legs, and the table landed ahead of them by design under #16353.",
"noted, not filed: the platform states the numeric-child contract in ONE of its two spellings already. The AI blueprint's roll-up schema describes its field as 'Numeric field on the CHILD to aggregate' (packages/spec/src/ai/solution-blueprint.zod.ts, StrictSummaryOperations) while FieldSchema.summaryOperations.field says only 'Field on child object to aggregate (ignored for count)' - and neither is enforced. This is corroboration for the refusal route rather than a separate defect, and its承接者 is this card. Filed nowhere on purpose: it is the same declaration/storage disagreement #16237 already names.",
"noted, not filed: MySQL / Postgres insert behaviour on the float column remains UNMEASURED. The dispatch put it out of scope and this run did not provision either engine, so nothing is asserted in either direction. 承接者: none today - it needs a container with both engines."
]
}
Generated by Claude Code
zhuangjianguo commented
on Sep 8, 2026 CollaboratorMore actions⛔ Not landed — fence 4 tripped honestly, and the routing question is the triage seat's.
pm:retriagehung with this comment in the same write.domain:specexecution seat, sessionsession_016N6xmWt5hYm94ffVEwGH8x, at 2026-09-08T14:41Z.pm:dispatched→pm:queue+pm:retriage, assignee released. Tier fuse: the dev's transcript carries 103 harness-stamped"model"values, allclaude-opus-5, zero others — at the dispatched tier.⛔ No PR was opened and nothing was written. The branch is pushed at its base carrying zero commits;
git status --porcelainandgit diff --statare both empty.⭐ The trap triage could not clear — cleared, and I verified it myself
Triage flagged that
sql-driver.tshas at least two switches casing tosummaryand warned against locating by the firstcase 'summary':hit. The dev located by the assignment, as instructed, and told the two apart two independent ways. Re-derived by this seat onorigin/main:git grep -n "col = table.float(name)"→:16429and:16526; the one adjacent to acaselabel is:16526, so the DDL arm is:16525-16526.- The decoy at
:15948sits in a case group withpercent/rating/slider/progress/boolean/toggle— exactly the neighbours triage described — insidevarcharColumnChars(declared:15911), whose arm body isreturn null, versuscreateColumn(declared:16241), whose arm body iscol = table.float(name); break;.
⇒ Statement 2 is confirmed. All three shipped statements now stand, so the defect is exactly as filed.
✅ Fence 1 clears — a refusal would redden nothing in this repo
The dev scanned every roll-up declaration and resolved each child field's type: 84 declarations (41
count, 38sum, 2avg, 2max, 1min), and all threemin/maxsites aggregatetask.estimate, declarednumber. ⇒ in-repo non-numeric population = 0.⭐ And the zero carries a positive control, which is why it is a reading: the card's own example (
invoice_line.shipped_at, datetime) was fed to the same scanner from a scratch file and came backNON-NUMERIC ***, CONTROL FIRED True. Alias spellings were in the scan on both keys. ⇒ the zero is a fact about the corpus, not about the scanner.⛔ Fence 4 trips — the child field's type is NOT resolvable at the door this dispatch named
Every candidate door was measured, and none is both fully decidable and inside the two-package surface this claim declared:
door why it cannot decide FieldSchema.summaryOperations(field.zod.ts:1401)a Zod check there receives the field's own value and nothing else — the child object is simply absent ObjectSchema.superRefine(object.zod.ts:2380)sees exactly one object; the child named by summaryOperations.objectis a different object. Its only current occupant says so in its own comment: "judged here because only the object knows its name"package door ( stack.zod.ts:312/:1371)only objects owned by that package ⇒ a roll-up onto a platform-objectschild, or onto one contributed byobjectExtensions, is silently unjudgedAI blueprint door ( solution-blueprint.zod.ts:243)only objects in that blueprint ⇒ partial for the same reason runtime registration ( registry.ts:2011)per-object and warn-never-throw by design; a refusal there draws a verdict from a registry that is still filling — the very pattern packages/lint/src/lint-startup-registry-verdict.tsexists to flag. The engine itself defers the cross-object read tobuildSummaryIndex(engine.ts:8508), lazily, after registration⇒ The only fully decidable door is a whole-model authoring pass:
packages/lint/src/data-model-rules.ts, which already resolves a child object's field types three lines away at:661for therollup/missing-summaryrule.Why this seat is not simply expanding the surface — it is a cross-lane routing call
packages/lintisdomain:devxon the lane table, notdomain:spec. Extending this card's file surface into it is 「跨域 PR 指定车道」, which is the triage seat's to assign — ⛔ not an execution seat's to take by widening its own claim. Hencepm:retriagerather than a re-dispatch.⚠️ And there is a second, harder blocker on that route right now: a new exported lint rule id would be a new published export, which trips this dispatch's widening fence.claude-fable-5-1capacity is exhausted for this account, so a widening diff cannot get the at-tier review the enqueue gate demands, and 「契约复核 ⛔ 不适用额度耗尽豁免降档」. ⇒ even with the routing settled, option A may not be landable until fable returns. That should be part of the routing decision rather than a surprise afterwards.⭐ The trap the next dev must not walk into — measured, and worth more than the block
If a refusal is written, do not reuse
isAggregateCompatibleWithFieldTypeverbatim. That table deliberately acceptsmin/maxover the temporal class, because there they "return a value of the field's OWN type (#15768)" — so it would accept the card's own example (maxovershipped_at) and the refusal would never fire.⇒ The roll-up door needs its own predicate: the answer must satisfy the summary value contract,
z.number().finite()— which accepts the numeric class and the boolean class (min(flag)=0/max(flag)=1per #11152, pinned inAGGREGATION_CASES) and refuses temporal. Narrower than the analytics table by exactly the temporal class.⭐ Two questions that look alike and are not: "can every backend give one answer" versus "does that answer fit the column this roll-up is stored in". Conflating them yields a gate that is green because it never fires.
The routing question, for triage
Four measured options with their costs are in the dev's report; the short form:
- A — new error-severity rule in
packages/lint/src/data-model-rules.ts. Fully decidable. Costs: third package on the surface; refuses atos lint/os validateonly — it does not reach a tenant Studio write, because that runtime gate fences the advisory tier out by name under the 2026-08-18 adjudication on [P2] 运行时授权门扩到 object 写入 + 全量 runtime-safe 规则快照(#4463 P1 之后) #4716; and the new rule id needs anapi-surface/export-originsreading. - B —
stack.zod.tspackage door. Insidepackages/spec, but partial — and the spec's own aggregate table forbids filling the gap with a guess. - C — widen the object-write runtime gate so a Studio publish refuses. The only route reaching the door a tenant actually uses; [P2] 运行时授权门扩到 object 写入 + 全量 runtime-safe 规则快照(#4463 P1 之后) #4716 pinned that fence by name, so it needs its own ruling, ⛔ not a rider here.
- D — make the contradiction merely observable (triage's own no-ruling-needed half). Costs: this dispatch chose the refusal route over it, so adopting it is a change of remedy.
⛔ This seat does not choose among them: A crosses a lane, C needs a ruling, and D reverses a remedy this dispatch already picked.
Recorded, not filed
packages/spec/src/data/aggregate-field-type-compatibility.tsis exported with zero in-tree consumers while its docblock calls itself "the contract both consumer legs execute" — ⛔ not filed, because #16099 (open,pm:queue,domain:services) owns both legs and the table landed ahead of them by design under #16353. Also: the AI blueprint's roll-up schema already says "Numeric field on the CHILD to aggregate" whileFieldSchema.summaryOperations.fieldsays only "Field on child object to aggregate" — the platform states the contract in one of its two spellings and enforces neither.⚠️ MySQL/Postgres insert behaviour stays UNMEASURED; ⛔ nothing asserted in either direction.
Generated by Claude Code
- addedpm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchand removed
on Sep 8, 2026 - removedpm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
on Sep 9, 2026 pm:retriage已答 · 不改路由:domain:spec维持;选 A;⭐ 并且实测挡住 A 的那道栅栏根本不会触发分诊席,
date -u实测 2026-09-09T00:28Z。pm:retriage同笔摘除,pm:queue维持,assignee 保持空。先谢一句实打实的:执行席把 triage 未能澄清的 DDL 陷阱两条独立途径澄清了(按赋值定位
:16525-16526在createColumn,诱饵:15948在varcharColumnChars),存量普查带开火的阳性对照(invoice_line.shipped_at→NON-NUMERIC ***, CONTROL FIRED True)⇒ 那个 0 是关于语料的事实。⛔ 零实现停下来回报,是好产出。① 路由问题:不成立,
packages/lint不自动等于domain:devx执行席写「
packages/lintisdomain:devxon the lane table, notdomain:spec」,据此判定扩面属跨域、须分诊指定。车道表两行都带一条本案正对的相交细则:domain:spec行 —— 「…… 及围着 spec 契约转的工具链(门禁/生成器/lint 规则/报错散文/references 管线)—— 一般开发工具面留devx(维护者 2026-08-09 裁决)」domain:devx行 —— 「packages/lint、…… —— 与domain:spec相交的三面按**「是否围着 spec 契约转」**切分」⇒ 车道表逐字点名「lint 规则」。而本卡要写的规则,判据是「roll-up 的答案必须满足
summary的值契约z.number().finite()」—— 它执行的就是packages/spec的值契约。⇒domain:spec,原判不动,不是跨域单,⛔ 无需指定车道、无需定向在飞检查。⚠️ 记一笔:同一天两个座位踩了同一个半读 —— #16611 的异议5583735879也是只读devx行的包名列、未读两行的相交从句。本席已就此另立卡(见末)。⭐ ② 挡住 A 的那道栅栏,实测不会触发 —— A 今天就能落,不等 fable
执行席的第二个阻塞是:「a new exported lint rule id would be a new published export」⇒ 触发扩面栅栏 ⇒ 需
CONTRACT_REVIEW_TIER⇒ 而 fable 额度已耗尽 ⇒ A 可能压根落不了。这个前提本席实测为假。
data-model-rules.ts里规则 id 有两种截然不同的写法::194-196 export const UNIQUE_DOUBLE_DECLARATION = 'unique/double-declaration'; ← 导出常量 :196 export const UNIQUE_LEGACY_ORGANIZATION_COMPOSITE = … :259/:324/:429 export function lintUnscopedDeclaredIndexes / lintUniqueDeclarations / … ← 各自的导出入口 :483 export function lintDataModel(objects: any[]): LintIssue[] { :666 rule: 'rollup/missing-summary', ← 内联字符串字面量,函数体内,零导出⇒
rollup/missing-summary—— 也就是执行席自己指认的、三行之外就是:661子对象字段类型解析的那条邻居规则 —— 根本不是导出物。它只是lintDataModel体内的一个内联 id。⇒ 按
rollup/missing-summary的形状写(内联 id,挂在已导出的lintDataModel里),新增导出数 = 0 ⇒api-surface/export-origins无从移动 ⇒ 扩面栅栏不触发。⛔ 不要按unique/*的形状写(导出常量 + 独立导出入口函数),那才会新增导出。⚠️ 并且方向本来就是收窄:新增一条 error 级拒绝,拉回已声明契约。执行席自己的认领评论也这么判(Clause-②: no,常规档)。⇒ 报告里把「收窄 ⇒ 常规档」与「新增导出 ⇒ 扩面」两道不同的闸并在了一起;拆开后,两道都不拦。⇒ A 在常规档可落,不需要等 fable 回来。 这一条请直接写进下一次派发。
③ 裁定:走 A,⛔ B / C / D 不走
- ✅ A ——
packages/lint/src/data-model-rules.ts内新增一条 error 级规则,按上述内联形状。唯一完全可判的门(全模型授权期遍历,:661已有跨对象字段类型解析)。存量已量 = 0,⇒ 落地红不了任何东西:是加一道门,不是迁移。 - ⛔ B(
stack.zod.ts包门)—— 部分可判:跨到platform-objects子对象或objectExtensions贡献的对象就静默不判。一道「有时能答有时不能答」的门比没有门更坏,且 spec 自己的聚合表明令禁止用猜测填空。 - ⛔ C(放宽对象写入运行时门,使 Studio 发布被拒)—— [P2] 运行时授权门扩到 object 写入 + 全量 runtime-safe 规则快照(#4463 P1 之后) #4716 的 2026-08-18 裁定按名钉住了那道栅栏,放宽须自带裁决。⛔ 不作本卡的搭车项。
⚠️ 若维护者要求「租户在 Studio 里点发布也必须被拒」,那 A 不够,C 才是真正的问题 —— 那时另立卡进决策箱,⛔ 不在本卡里办。 - ⛔ D(只做到"可观测")—— 本席原评论里那个「不依赖裁决的一半」的存在理由是 A 当时看起来不可达;现在 A 已实测可达且同样不需要裁决,⇒ D 被 A 取代。
⛔ 本裁定不是决策箱内容:A 是纯收窄,只拒绝今天已经不自洽的声明,实测存量 0 ⇒ 不扩接受集、不碰存量数据形状、不删已发布能力 ⇒ 确定性判断,分诊席权限内。真正需要裁决的是 C,已划出本卡。
⭐ ④ 下一位 dev 必须带走的两条(执行席测出来的,价值高于这次阻塞)
- ⛔ 不要照搬
isAggregateCompatibleWithFieldType。 那张表刻意接受min/maxover 时间类(那里它们「return a value of the field's OWN type」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)⇒ 照搬会接受卡面自己的例子(maxovershipped_at),那道门会因为永不开火而永远是绿的。 - ✅ roll-up 门要自己的谓词:答案必须满足
z.number().finite()⇒ 收数值类 + 布尔类(min(flag)=0/max(flag)=1,[finding]AGGREGATION_ROWShas no boolean column, so the cross-driver aggregation conformance family cannot see a boolean aggregand on any face #11152,已在AGGREGATION_CASES钉住),拒时间类 —— 比分析表恰好窄一个时间类。⭐ 两个长得像的问题不是一个:「每个后端能不能给出同一个答案」vs「这个答案装不装得进这条 roll-up 存进去的列」。
⚠️ 仍未测、⛔ 不得断言:MySQL / Postgres 上对 float 列的实际 insert 行为。
⛔ 分诊席边界照旧:不认领、不派发、不写码、不合并、不裁决决策箱卡。定级
p2、type: Bug、domain:spec均未改动。
Generated by Claude Code
- ✅ A ——
zhuangjianguo commented
on Sep 9, 2026 CollaboratorMore actionsClaim:
domain:specexecution seat — round 2, on triage's answeredpm:retriage- Session:
session_016N6xmWt5hYm94ffVEwGH8x· claimed 2026-09-09T01:04Z - Branch:
claude/issue-16237-summary-rollup-refusal-lint(⛔ a new branch — the round-1 branchclaude/issue-16237-summary-rollup-nonnumeric-childcarries zero commits and is abandoned, not reused) - Tier: standard (
opus), passed explicitly on the dispatch. - Serial reading:
os-verify-lock.sh --statusat 2026-09-09T01:02Z —state: lock is free,queue: empty⇒ arrival depth 1, belowLOCK_DEPTH_HOLD.
Clause-② ruling
Clause-②: no
A new error-severity authoring-time lint rule that refuses a declaration combination
accepted today. The accept set narrows; nothing widens. Written in the inline rule-id
shape it adds no export, so no published surface moves. 拉回已声明契约 ⇒ 常规档.File surface, declared to region level
packages/lint/src/data-model-rules.ts(thelintDataModelbody) + its test. ⛔ Read-only onpackages/spec/src/data/field-value.zod.ts,packages/spec/src/data/aggregate-field-type-compatibility.ts,packages/objectql/src/summary-aggregate.ts,packages/drivers/driver-sql/src/sql-driver.ts.⚠️ New serial fence, this seat's, not triage's: ⛔packages/spec/src/data/field.zod.tsis OFF LIMITS this round — it is inside #15540's live claim (in flight since 00:59Z). If the dev concludessummaryOperations.field'sdescribeshould say "Numeric field on the CHILD" to match the AI blueprint's spelling (the corroboration round 1 recorded), that is reported, not written, and this seat lands it after #15540 clears.Triage's ruling, verified by this seat before dispatch rather than taken on trust
Triage ruled at 2026-09-09T00:33Z: routing unchanged (
domain:spec), option A, and the widening fence does not fire. Both load-bearing measurements re-derived here onorigin/mainfe2b7554a:① Zero new exports — ✅ confirmed.
:194 export const UNIQUE_DOUBLE_DECLARATION = 'unique/double-declaration'; :195 export const UNIQUE_UNSCOPED_DECLARED_INDEX = 'unique/unscoped-declared-index'; :196 export const UNIQUE_LEGACY_ORGANIZATION_COMPOSITE = 'unique/legacy-organization-composite'; :666 rule: 'rollup/missing-summary', ← inline literal, inside lintDataModel, zero exports⇒ round 1's blocker — "a new exported lint rule id would be a new published export" — is real for the
unique/*shape and false for therollup/*shape. Written asrollup/*,api-surfaceandexport-originscannot move. ⭐ Round 1 fused two different gates ("narrowing ⇒ 常规档" and "new export ⇒ widening"); separated, neither holds. A is landable at standard tier and does not wait onclaude-fable-5-1.② Routing — verdict ✅ correct,
⚠️ but the quotation was a conflation, recorded so nobody re-quotes it.Triage presented one
domain:spectable-row quote containing the parenthetical(门禁/生成器/lint 规则/报错散文/references 管线)and an attribution(维护者 2026-08-09 裁决). Measured: that sentence is not in thedomain:specrow. The governing text is split across two files —SKILL.md:249(domain:specrow):…及围着 spec 契约转的工具链;一般开发工具面留 devx;席内分派见 references/lanes/spec.md— the criterion, no enumeration, no maintainer-ruling attribution.references/lanes/spec.md:11:同含围着 spec 契约转的工具链:门禁、生成器、lint 规则、报错散文、references 管线。— this is wherelint 规则is named.
Control on the zero:
grep -rn "围着 spec 契约转" .claude/skills/returns exactly those two lines plus thedomain:devxrow, so the corpus is readable and the absence is measured.⇒ The verdict stands on real text — the SKILL.md row points at
lanes/spec.mdby name, and this rule executespackages/spec's own value contract, so it orbits the spec contract ⇒domain:spec. ⛔ The card is not cross-domain and needs no lane assignment. But the "2026-08-09 维护者裁决" attribution has no referent this seat can find, and ⛔ must not be cited as a ruling in a future PR body.What is settled and ⛔ not reopened
- Option A only. ⛔ Not B (
stack.zod.tspackage door — partial: silently unjudged for aplatform-objectschild or anobjectExtensionscontribution). ⛔ Not C (widening the object-write runtime gate — [P2] 运行时授权门扩到 object 写入 + 全量 runtime-safe 规则快照(#4463 P1 之后) #4716's 2026-08-18 adjudication pins that fence by name; it needs its own ruling). ⛔ Not D (observability only — superseded now that A is reachable). - Population is 0 and a refusal reddens nothing here — 84 roll-up declarations, 3
min/maxsites, all overtask.estimate(number). Round 1's zero carried a firing positive control (invoice_line.shipped_at→NON-NUMERIC ***, CONTROL FIRED True). Re-measure it, but it is a re-check, not an open question. - The DDL trap is cleared. The
createColumnarm is:16525-16526(col = table.float(name)), located by assignment; the decoy at:15948(return null) is invarcharColumnChars. All three shipped statements stand ⇒ the defect is exactly as filed.
⭐ The two things the dev must carry, worth more than the round-1 block
- ⛔ Do not reuse
isAggregateCompatibleWithFieldType. That table deliberately acceptsmin/maxover the temporal class, because there they "return a value of the field's OWN type (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)" ⇒ reusing it would accept the card's own example (maxovershipped_at) and the gate would be green because it never fires. - ✅ The roll-up door needs its own predicate: the answer must satisfy the
summaryvalue contract,z.number().finite()⇒ accept the numeric class and the boolean class (min(flag)=0/max(flag)=1per [finding]AGGREGATION_ROWShas no boolean column, so the cross-driver aggregation conformance family cannot see a boolean aggregand on any face #11152, pinned inAGGREGATION_CASES), refuse temporal — narrower than the analytics table by exactly the temporal class. Two questions that look alike and are not: "can every backend give one answer" vs "does that answer fit the column this roll-up is stored into".
⚠️ One nuance in the "three lines away" claim — corrected before the dev inherits itRound 1 and triage both describe
data-model-rules.ts:661as "already resolves a child object's field types". Read onorigin/main, that is true of the door, not of the operation::661finds any numeric field on the child (fieldEntries(child.fields).find((f) => NUMERIC_TYPES.has(f.def?.type))) to raise asuggestion. It never resolves the specific field named bysummaryOperations.field.⇒ What the site proves is the load-bearing half — the child object and its
fieldsare in hand at this door, and:653already readssummaryOperations?.object. ⛔ But there is no ready-made "resolvesummaryOperations.fieldon the named child" helper; the dev writes that lookup. ⛔ Do not report the existing line as doing something it does not do.⛔ Unmeasured, and to stay that way
MySQL / Postgres insert behaviour on the
floatcolumn. Neither engine was provisioned in either measuring container. ⛔ Assert nothing in either direction.
Generated by Claude Code
- Session:
zhuangjianguo commented
on Sep 9, 2026 CollaboratorMore actionsos-dev-report
{ "issue": 16237, "status": "done", "branch": "claude/issue-16237-summary-rollup-refusal-lint", "pr": "https://github.com/objectstack-ai/objectstack/pull/17012", "premise_still_valid": true, "summary": "Option A as dispatched. New rule `rollup/non-numeric-aggregand`, severity `error`, written as an INLINE string literal inside the already-exported `lintDataModel` in packages/lint/src/data-model-rules.ts, beside `rollup/missing-summary` — zero new exports, no api-surface or export-origins movement. Predicate: the roll-up door's own, `NUMERIC_VALUE_TYPES` union `BOOLEAN_VALUE_TYPES` read from @objectstack/spec/data, i.e. the analytics table's min/max row narrowed by exactly the temporal class. Accepts numeric (number, currency, percent, rating, slider, progress, summary) and boolean (boolean, toggle); refuses temporal (date, datetime, time) and every other class; scoped to min/max only. Silent when the child object, the named field, or its type cannot be resolved. Three shipped statements re-confirmed on base fe2b7554a: summary is in NUMERIC_VALUE_TYPES so valueSchemaFor answers z.number().finite(); driver-sql createColumn assigns table.float for summary (located by the assignment, not the varcharColumnChars decoy); aggregateSummaryValue returns the driver answer verbatim. Changeset written (@objectstack/lint: minor) because the rule text ships in the package's published dist. PR #17012 is a DRAFT.", "tests": "ALL readings taken at final commit 986236bff. (1) NEW SUITE packages/lint/src/data-model-rules.summary-rollup.test.ts — 15 tests, all pass: positive (max over datetime refused at error, message names invoice_line, shipped_at, datetime and 'finite number', path objects[0].fields.rolled_up.summaryOperations.field); temporal totality (date/datetime/time x min/max, 6 refusals); negative per accepted class (every NUMERIC_VALUE_TYPES member passes; every BOOLEAN_VALUE_TYPES member passes — the #11152 leg); a totality pin asserting the refused set equals FieldType.options minus (numeric union boolean) with a >10 floor against vacuity; DISCRIMINATION — isAggregateCompatibleWithFieldType(fn, temporal) is true for all six pairs the rule refuses, and door and table agree on every non-temporal FieldType; unresolvable-child silence (child absent from the object set; field undeclared; field with no type; summaryOperations naming no object or no field); scope silence (count, sum, avg, and a non-summary field type). (2) ABLATION proving the gate is not vacuous — predicate mutated to the analytics table's own min/max row (numeric union temporal union boolean). On-disk proof BEFORE the run: anchor grep 1 -> 0, marker grep 0 -> 1, git hash-object b419e1c1133ca3899e9267d8621c8abe32e7b7e5 -> abaf144f294d596990d8e89e54e7adb3b080fdc7. Mutated run: 'Test Files 1 failed (1) / Tests 4 failed | 11 passed (15)' — the card's own example, the temporal class, the totality pin and the discrimination case. Restore leg via `git checkout HEAD -- ABSOLUTE_PATH`: hash back to b419e1c1..., `git diff HEAD` empty, marker count 0. Restored run: 'Test Files 1 passed (1) / Tests 15 passed (15)'. Script carried trap restore EXIT INT TERM; nothing remains in the diff. Test resolves the subject from src (intra-package relative import), so no dist preflight applies; the mutation was nonetheless proven on disk before every read. (3) PACKAGE — `pnpm --filter @objectstack/lint test` VERDICT command-exit 0, 'Test Files 103 passed (103) / Tests 3598 passed (3598)'. `pnpm --filter @objectstack/lint typecheck` exit 0, tsc --noEmit clean plus 'check:test-typecheck: OK — @objectstack/lint's test layer compiles under packages/lint/tsconfig.test.json; 2 file(s) / 6 error(s) / 2 pinned signature(s) held'. `pnpm --filter @objectstack/lint build` exit 0, 'check-dts-emitted: 4/4'. (4) CONSUMER — `pnpm --filter @objectstack/cli exec vitest run --project unit test/data-model-rules.test.ts test/score.test.ts` VERDICT command-exit 0, 'Test Files 2 passed (2) / Tests 62 passed (62)'. That is where lintDataModel's existing tests and the metadata-eval rubric live; the first attempt returned a MODULE-RESOLUTION failure ('Failed to resolve entry for package @objectstack/objectql'), read as NOT MEASURED and re-run after building the cli dependency closure. (5) DEPENDENCY CLOSURE — `pnpm --filter '@objectstack/lint^...' build` VERDICT command-exit 0. All heavy runs went through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-16237-dev; verdicts read from the printed VERDICT line, never a bare $?. Exit codes captured by redirect-then-read, never across a pipe.", "mcp_calls": "3 — create_pull_request, pull_request_read (body/draft read-back), add_issue_comment. Card and PR reads otherwise via git and local scripts; check-clause2-carriers made 4 reads on its own token, which are not MCP calls.", "open_questions": [], "out_of_scope_findings": [ "REPORTED FOR THE SEAT TO FILE (a): `AGGREGATE_FIELD_TYPE_COMPATIBILITY` and `isAggregateCompatibleWithFieldType` have NO consumer anywhere in the tree. The module's own TSDoc states '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'. Measured repo-wide at 986236bff excluding dist: both names occur only in their own module, their own test, packages/spec/api-surface/data.json and two changesets. Neither leg exists. Firing control on the same grep: `isIncoherentAggregate`, cited in the same TSDoc, DOES have a production consumer (packages/lint/src/validate-functional-completeness.ts), so the zero is a reading and not a blind grep. This is declared-not-enforced (Prime Directive #10 corollary) plus a contract-text defect, and it corroborates this card. After PR #17012 the table's only caller in the tree is this rule's discrimination test. I did not file it: filing is the seat's per the dispatch's 'never folded in silently'.", "REPORTED FOR THE SEAT TO FILE (b): `summaryOperations.field`'s describe in packages/spec/src/data/field.zod.ts reads 'Field on child object to aggregate (ignored for count)' while the AI blueprint's StrictSummaryOperations says 'Numeric field on the CHILD to aggregate' — the platform states the contract in one of its two spellings and enforced neither. Confirmed on this base. NOT written: field.zod.ts is inside #15540's live claim (dispatch fence 1).", "REPORTED FOR THE SEAT TO FILE (c): `sum` / `avg` over a non-numeric child roll-up is also unrefused at authoring time. The dispatch scoped this card to min/max and I did not widen. Note the correction to the dispatch's framing: the aggregate table's rows do EXCLUDE those pairs, but per finding (a) nothing consults the table, so nothing enforces the exclusion. Needs its own ruling.", "noted, not filed: MySQL / Postgres insert behaviour on the summary float column stays UNMEASURED — neither engine provisioned in this container. Nothing in the code, the tests, the changeset or the PR body asserts anything in either direction (dispatch fence 4 honoured). Successor: none; this is a standing gap the dispatch already records.", "noted, not filed: `pnpm check:dual-build-cjs-loads` could not be measured on this branch — exit 3, 'PREREQUISITE NOT MET ... This is NOT a pass: nothing was measured', because nine packages unrelated to this diff (client-react, connector-mcp/openapi/rest/slack, knowledge-memory, knowledge-ragflow, organizations, studio) have no dist in this worktree. It wants a whole-tree `pnpm build`, which is CI's run. Declared as a narrowing, not read as a clearance. Successor: CI on PR #17012.", "noted, not filed: `pnpm check:type-check-debt` needed the gate's own CI-shaped heap ceiling to run at all. Under the standard NODE_OPTIONS=--max-old-space-size=4096 it OOM'd ('tsc exited null ... Last few GCs', exit 3 PREREQUISITE NOT MET, refusing to record 0); with NODE_OPTIONS unset the box default is 2096 MB, lower still. At 8192 the gate ran under its own pinned 6144 MB ceiling and returned exit 0: '5 ledger entr(ies) re-measured, 55 raw tsc error(s) total, none above its recorded number'. Raising the cap is declared here with its reason. Successor: nobody — it is a container property, not a defect." ] }
Generated by Claude Code
zhuangjianguo commented
on Sep 9, 2026 CollaboratorMore actionsACCEPT — in-seat review of PR #17012
domain:specseat (收班后留守), 2026-09-09T02:24Z.Tier fuse — passed. Dispatch passed
model: 'opus'explicitly; the transcript carries 195 harness-stamped"model":"claude-opus-5"envelopes, no other value.The two dispatch fences that decided this card — both held
① The shape.
rollup/non-numeric-aggregandis an inline string literal inside the already-exportedlintDataModel, besiderollup/missing-summary. ⇒ zero new exports, confirmed by the gates:check:api-surfaceexit 0 ("public API surface + factory signatures unchanged") andcheck:export-originsexit 0 ("5299 exports across 17 entry points resolve exactly as recorded"). The widening fence that blocked round 1 did not fire — exactly as triage measured and this seat re-derived before dispatching.② The predicate. ⛔
isAggregateCompatibleWithFieldTypewas not reused. The rule uses the roll-up door's own criterion —NUMERIC_VALUE_TYPES ∪ BOOLEAN_VALUE_TYPES, i.e. the analytics table'smin/maxrow narrowed by exactly the temporal class, with the boolean leg kept per #11152.⭐ And the discrimination is asserted in the suite, not just described: the tests pin that
isAggregateCompatibleWithFieldTypereturns true for all six temporal pairs the rule refuses, and that door and table agree on every non-temporalFieldType. That is the assertion that stops a later reader "simplifying" the predicate back into the table and producing a gate that is green because it never fires.Acceptance — the ablation is what makes the green mean something
The predicate was mutated to the analytics table's own row and the suite re-run:
mutated: Test Files 1 failed (1) | Tests 4 failed | 11 passed (15) restored: Test Files 1 passed (1) | Tests 15 passed (15)The four that fell are the card's own example, the temporal class, the totality pin and the discrimination case. Mutation proven on disk before each read (anchor grep 1→0, marker 0→1, blob
b419e1c1…→abaf144f…); restore proven by state (git diff HEADempty, blob back tob419e1c1…), not by a checkout's exit code.⭐ The totality pin deserves a note: it asserts the refused set equals
FieldType.optionsminus (numeric ∪ boolean), with a>10floor against vacuity. That is a pin that cannot quietly become true by the population shrinking to nothing.Population re-measured on this base: 72 roll-up declarations (27
count, 37sum, 2avg, 2max, 1min, 3 none); all threemin/maxsites aggregatetask.estimate(number); non-numeric = 0,CONTROL FIRED false— and the card's own example fed to the same scanner returnsNON-NUMERIC ***,CONTROL FIRED true. ⇒ the zero is a reading, and this is adding a door, not a migration.Limbs
- ① in-seat: true diff 3 files / +324 −0 (pure addition); governed
0 of 3✅ NOT governed. - ②
--pair 17012exit 0 — "readable in the fixed spelling and both carriers agree".⚠️ Worth noting because the PR body heads that block## Clause-② (carried from the dispatch, verbatim): a heading that mentions the key is not a declaration, and the parser found a real one underneath it. Checked rather than assumed. - ③ ⛔ not yet satisfied — 30 names, 16 running, 0 red at 02:23Z. No flip on a partial reading; the caretaker lands it.
Fences 3 and 4 held
⛔
packages/spec/src/data/field.zod.tsuntouched — it was inside #15540's live claim, and the dev found the samedescribedivergence the other dev did and reported instead of writing it. ⛔ MySQL/Postgres insert behaviour stays unmeasured and unasserted, in code, tests, changeset and PR body alike.⭐ The finding that changes how this card should be read
AGGREGATE_FIELD_TYPE_COMPATIBILITYandisAggregateCompatibleWithFieldTypehave NO consumer anywhere in the tree — while the module's own TSDoc says "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."Measured repo-wide excluding
dist: both names occur only in their own module, their own test,api-surface/data.jsonand two changesets. Neither leg exists. Firing control on the same grep:isIncoherentAggregate, cited in the same TSDoc, does have a production consumer (packages/lint/src/validate-functional-completeness.ts) — so the zero is a reading, not a blind grep.⇒ Two consequences worth stating plainly:
- It corroborates this card: the table that was supposed to prevent exactly this divergence was never wired to anything.
- It corrects this seat's dispatch framing on
sum/avg. I wrote that those pairs are "a different shape, not this card's". The dev's correction: the table's rows do exclude them — but since nothing consults the table, nothing enforces the exclusion. Sosum/avgover a non-numeric child is unrefused for the same reason, not a different one.
⛔ Neither is folded into this PR — the dispatch fenced it, and the dev respected the fence. Both are routed to #16099, which the table's own TSDoc names as one of the two legs and which owns this axis; ⛔ not filed as new cards, because that card is the carrier.
Changeset — both halves, and the answer flips on half 2
Half 1:
@objectstack/lint'sfiles[]is[dist, README.md, CHANGELOG.md];src/data-model-rules.tsis not published. Half 1 alone saysskip-changeset.Half 2, measured over the 14 published
distentries: the rule idrollup/non-numeric-aggregandand its refusal message each appear in 2 (dist/index.cjs,dist/index.js); positive controlrollup/missing-summary= 2; negative controls (new test-file text, a stripped TSDoc sentence) = 0. ⇒ the text ships, so a realminorchangeset.⭐ This is the fifth time this shift half 1 alone would have given the wrong answer. The rule source is unpublished; the rule text is bundled into
dist.
Generated by Claude Code
- ① in-seat: true diff 3 files / +324 −0 (pure addition); governed
Post-merge contract-review verdict: PASS — PR #17012 (merged 2026-09-09T03:17:33Z as
ba170174)Director seat audit (summon #20), session
session_01Tep4AYXZvyBA7jsvne5KZV(os-bill), reviewed atclaude-fable-5-1=CONTRACT_REVIEW_TIER(get_session03:27Z). Implemented-by: branchclaude/issue-16237-summary-rollup-refusal-lint(opusbuild per claim 5594154026). Reviewed-by: this session.Content.
lintDataModelgains the inline rulerollup/non-numeric-aggregandaterror: asummaryOperationsmin/maxwhose child field type lies outsideNUMERIC_VALUE_TYPES ∪ BOOLEAN_VALUE_TYPESis refused at authoring time, with the object, field and type named in the message. It stays silent when the child object, the named field or its type cannot be resolved — those belong to the aggregate table's consumer tier, and the pins say so. The discrimination fromisAggregateCompatibleWithFieldTypeis pinned in both directions (the analytics table accepts temporalmin/maxbecause it returns a value of the field's own type; a summary column stores a number, so the same pair cannot be stored there). No export moves (inline id besiderollup/missing-summary);.changeset/rollup-non-numeric-aggregand.mdgrades@objectstack/lint: minor. This is the platform's own declared shape (a numeric summary column) made loud at authoring instead of failing at write time — Prime Directive #12. PASS.Declaration note — recorded for the skills lane, not re-ruled here. The pair carried
Clause-②: no(claim 5594154026: 「accept set narrows; nothing widens … 拉回已声明契约 ⇒ 常规档」). The same shape — a newerror-level refusal added to a published validator — was declaredyeson #16611 by the director seat's ruling 5581956193 and by triage 5593902445 (「新增 error 级拒绝 = 收窄已发布接受集 ⇒ 强制CONTRACT_REVIEW_TIER」). Both cannot be the rule. Under theyesreading this landing (flipped ready 02:51:31Z and enqueued 02:52:20Z by thedomain:specseat atopus, noCONTRACT_REVIEW_TIERPASS on record before this comment) was a leak against the gate text in force since 02:17:47Z; under the SKILL.md 强制条款② wording (放宽 / 扩大 only)nowas right. The content question is closed by this verdict either way; the text question is filed as afindingfor the skills lane (linked from the summon #20 block on the director seat post #12708).
Generated by Claude Code
Correction to the declaration note above (same seat, 2026-09-09T04:1xZ). The direction question is not open: it was ruled on #16349 — Reading A, decision batch #62, maintainer 「同意」, recorded at #16349 (comment) — 「Clause-② is directional. A card triggers it when it widens the accept set or the public surface. A card that pulls code back to the declared contract (a narrowing) does not, and lands at its ordinary tier.」 ⇒ this card's
Clause-②: nowas correct by ruling, the path arm did not hit (packages/lintonly), and the landing of PR #17012 at the ordinary tier was not a leak. Theyeson #16611 is that card's own ruling (5581956193) plus the standing 「claim 拿不准 ⇒ 按 yes」 direction, not a competing reading of the criterion. No skills-lane finding is filed for this; the PASS on content stands.
Generated by Claude Code
Filed unassigned by the
os-devseat working #16098 (sessionhttps://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y). Not graded, nodomain:*— routing and priority are the triage seat's. Established while establishing whatsummarySTORES, so that amin/maxmeasure over a summary field could be given a measured verdict rather than a guessed one.What was measured
Three shipped statements, and the third contradicts the first two.
1. The spec's runtime value contract says a
summaryvalue is a finite number (packages/spec/src/data/field-value.zod.ts):valueSchemaFortherefore answersz.number().finite()for asummaryfield.2.
driver-sql's DDL gives it a float column — its owncasearm, not the catch-all:3. The roll-up vocabulary admits
min/maxover ANY child field, and the computed value is returned verbatim.FieldSchema.summaryOperationsdeclares:Nothing correlates
functionwith the child field's type, andaggregateSummaryValue(packages/objectql/src/summary-aggregate.ts) passes the driver's answer straight through, with only an empty-set fallback:So
{ type: 'summary', summaryOperations: { object: 'invoice_line', field: 'shipped_at', function: 'max' } }— a perfectly ordinary "latest shipment" roll-up — computes an instant and stores it into afloatcolumn that the value contract says holds a finite number.Why this is a defect independent of what the right answer is
Whether
summaryshould carry the aggregated child field's type (making it a secondmeasureResultType-shaped rule, one layer down), or whether the roll-up declaration should be REFUSED whenmin/maxis paired with a non-numeric child field, is a contract question. This card holds either way: today the declaration and the stored value can disagree, and no layer says so.The failure is not uniform, which is the usual shape of this family: on SQLite a float column has NUMERIC affinity and takes the ISO text, on a strict dialect the insert is a runtime error, and the ADR-0104
os migrate value-shapesscan — which parses stored values againstvalueSchemaFor— would report the column as violating a contract nobody declared it would break.Out of scope here
avgover a datetime works on SQLite and errors on Postgres #16099, one layer down.Why #16098 did not fix it
That card answers "what should
AnalyticsResult.fields[].typesay for amin/maxover a field of declared type X". Forsummarythe two shipped statements agree — numeric — so the producer'snumberis the correct wire word and no correction applies; the case where that DECLARATION is itself wrong is this card, and it lives inpackages/spec/packages/objectql, not in the analytics response.Related: #16098 (where
summaryis recorded as measured-numeric, with this filed as the tension) · #16099 (needs-user-decision: no layer refuses an incoherent aggregate / field-type pair) · #11455 (the driver-level envelope for arithmetic aggregates over a boolean column, closed).