Skip to content

lint: refuse an incoherent dataset measure (aggregate × field type) at authoring time, from the spec's compatibility matrix (lint leg of #16099) #16354

Description

@os-zhuang

Lint leg of #16099, split by director ruling (decision batch #59, 2026-09-06, "both legs, table in spec").

Blocked-by: #16353 (the AggregationFunction × FieldType compatibility matrix, exported from @objectstack/spec). This card consumes that table; it declares no rows of its own.

Scope

  • A packages/lint rule over dataset measures: for each { aggregate, field }, resolve the field's declared type from the object schema lint already loads and refuse the pair when isAggregateCompatibleWithFieldType says no.
  • Message names the aggregate, the field, its type, and the accepted types for that aggregate — so the author's fix is visible without reading the spec.
  • Sibling to the compile-time leg on No layer refuses an incoherent aggregate / field-type pair — a dataset measure avg over a datetime works on SQLite and errors on Postgres #16099 (dataset-compiler, services): this rule reaches the author earliest; it cannot see a dataset assembled at runtime, which is why both legs exist.
  • Tests: the positive control from the card (avg over datetime fires), a negative control (avg over number silent), and sum over percent (fires — the row analytics-service.ts already calls incoherent).

Acceptance

  • rule consumes the spec table; no local copy of the rows
  • documented in the lint rule reference
  • changeset present

Activity

  1. self-assigned this
    on Sep 18, 2026
  2. os-elon-musk commented on Sep 18, 2026

    @os-elon-musk
    Collaborator

    Claim: PM loop round R11 — taken under the maintainer's new take order: contract-surface cards first. · 认领时刻 2026-09-18T21:45Z
    Session: session_019srGWGCBBCBHqcDoRZpQRh
    Branch: claude/issue-16354-dataset-measure-aggregate-field-type-lint
    Worktree: wt-16354
    Domain: domain:spec
    Seat: domain:spec#3
    File surface: packages/lint/src/data-model-rules.ts(或该卡所需的新规则文件)+ 其测试;⛔ 出面:packages/lint/src/validate-{expressions,field-consumers,object-references,org-axis-red-lines,rule-compilability,rule-schema-formats,security-posture,sharing-rule-enforceability,translation-references}*(四张 open PR 持着)、packages/spec(那张表已导出,本卡只消费)、其余一切
    Container & model: M,mode:subagent,model: opus —— 该面无路径推导的档位强制
    Clause-②: no
    Thread-read: 5736040067
    Serial constraints cleared: data-model-rules.ts · data-model-rules.summary-rollup.test.ts · validate-dataset-references.ts 在 33 张 open PR、391 行文件行里 0 持有(图重建于 2026-09-18T21:20Z;亮控:同图正确点出 packages/lint 下 16 行被 #19080 / #18319 / #19060 持着,全部是别的 validate-* 文件)⇒ 面干净;⛔ 但同包有四张 PR 在写,别的文件一个不碰


    为什么现在取它:它命中维护者刚下的判据

    维护者的一句话测试:拿一份作者写的元数据,改动前后喂进去,接受还是拒绝的结果变了吗? 本卡:一个 { aggregate: 'avg', field: <datetime 字段> } 的 dataset measure 今天被接受,落地后被 lint 拒绝 ⇒ 收紧,命中五条判据的第一条 ⇒ 契约面卡。

    ⇒ 本席按新全序取它,而不是取那些「一个字节都不变」的辅助面卡。⭐ 并且方向已裁:卡面逐字 「split by director ruling (decision batch #59, 2026-09-06, "both legs, table in spec")」 ⇒ ⛔ 不重议 lint 腿要不要做。

    前置已现读,⛔ 不继承卡面

    • 阻塞者已解:spec: declare the aggregate × field-type compatibility matrix (AggregationFunction × FieldType) that dataset measures are refused against (spec half of #16099) #16353 state=closed · state_reason=completed(2026-09-08T00:48Z);分诊在 5731151503 里另量了它交付的东西而不只是状态。本席复核:isAggregateCompatibleWithFieldType 现读存在于 packages/spec/src/data/aggregate-field-type-compatibility.ts 并带自有测试。
    • 车道是刚改的:分诊于 2026-09-18T20:53Z(R+319)把本卡由 domain:devx 改判 domain:spec,依据 SKILL.md:241 的唯一例外(围着 spec 契约转的 packages/lint 面归 spec)。⇒ 本席在本车道取它是合法的,⛔ 不是越界。
    • 消费路径已存在:分诊量到 packages/lint/src/data-model-rules.summary-rollup.test.ts:28 已 import 那张表 ⇒ ⛔ 不需要新建导入通道。

    ⚠️ 本条初版漏了本次动作的时刻 —— post-stamped 回报 「no {{NOW}} substituted」,即这条认领当时不带取数时刻,而板面/树/队列读数与认领恒须带 UTC 时刻。已补在首行,值取自平台写下本评论的时刻;三条 {{WAS:}} 引用读数原样未动。


    Generated by Claude Code

  3. os-elon-musk commented on Sep 18, 2026

    @os-elon-musk
    Collaborator

    os-dev-report

    {
      "issue": 16354,
      "status": "done",
      "branch": "claude/issue-16354-dataset-measure-aggregate-field-type-lint",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/19138",
      "session": "session_019srGWGCBBCBHqcDoRZpQRh",
      "premise_still_valid": true,
      "summary": "New gating lint rule `measure-aggregate-field-type-refused` in packages/lint/src/validate-dataset-measure-aggregates.ts, registered in AUTHORING_RULES (all three commands) and exported from the index barrel: it walks datasets[].measures[], resolves the field's declared type through indexObjectGraph/resolveFieldPath (the object graph lint already loads, no new loading path) and refuses the pair when isAggregateCompatibleWithFieldType says no. No second table in lint: the verdict is the shared predicate's on every pair, and the accepted set plus the way out are computed from AGGREGATE_FIELD_TYPE_COMPATIBILITY rather than restated. Documented in the lint rule reference (content/docs/deployment/validating-metadata.mdx: a doors-table row plus a worked example beside the dataset-measure section); changeset present. PREMISE VERIFIED on my own base, not inherited: isAggregateCompatibleWithFieldType exists at packages/spec/src/data/aggregate-field-type-compatibility.ts on origin/main, data-model-rules.summary-rollup.test.ts:28 already imports it, and the BEFORE arm measured that nothing in the lint path consulted it for dataset measures. TWO THINGS THE CARD DID NOT SAY, both measured here: (1) the COMPILE leg has already landed (dataset-compiler judges every aggregate through one door since decision batch #127) and TWO ADR-0087 semantic entries already register this exact refusal surface, so my changeset declares not-required (already-registered ...) exactly as #16099 did rather than touching packages/spec; (2) the honest Clause-2 value for this diff is `yes`, not the claim's `no` -- it adds published exports and a registry entry, both widening tells -- so body and changeset both carry `Clause-2: yes (narrowing)`, the gate's own spelling for a diff that widens AND narrows, which check-adr-0087-registration then read as [BREAKING+clause-2-narrowing] and accepted. The claim comment is the seat's to amend. Assignee was already set by the PM (os-elon-musk) and I did not touch it; the newest Claim: comment names my branch.",
      "tests": "BEFORE/AFTER VERDICT ARTIFACT (run, not reasoned). One probe script, byte-identical in both arms, driving runAuthoringRules('lint', {normalized, parsed}) -- the shared registry os lint / os validate / os build all run. Sample: object crm_opportunity with units:number, closed_at:datetime, win_rate:percent; dataset opportunity_metrics with three measures. BEFORE (pristine worktree at branch point c70581bc8, `git status --porcelain` printed 0 lines): `pnpm exec tsx probe.mjs packages/lint/src/index.ts` -> total findings 1; avg(closed_at)=ACCEPTED (no finding); avg(units)=ACCEPTED; sum(win_rate)=ADVISED (warning measure-aggregate-incoherent only). AFTER (this branch, same probe): total findings 3; avg(closed_at)=REFUSED error measure-aggregate-field-type-refused @ datasets[0].measures[0].aggregate; avg(units)=ACCEPTED (unchanged); sum(win_rate)=REFUSED error + the same warning. Re-run through a freshly built dist (`node probe.mjs packages/lint/dist/index.js`): identical verdicts. => accept/reject flips on author-written metadata. BUILD PROVENANCE: packages/spec/dist was ABSENT in this fresh worktree and built here via `pnpm --filter '@objectstack/lint^...' build` (twice: before the BEFORE arm, and again after merging main); packages/lint built for the dist arm. NO TURBO IN EITHER PIPELINE -- pnpm ran each package's own script directly -- and `grep -ciE 'cache hit|turbo|FULL TURBO'` over both build logs returns 0, so nothing was served from cache. THE THREE CONTROLS, all permanent tests: (1) positive, avg over datetime FIRES (one error, message pinned to name aggregate + field + declared type + every accepted type); (2) NEGATIVE, avg over number is SILENT -- generalised to avg/sum over the whole numeric class, all four arithmetic/order aggregates over the boolean class (#11152), min/max over the temporal class, and count/count_distinct over EVERY declared FieldType; (3) sum over percent FIRES. Plus a full-surface sweep: the rule's verdict equals isAggregateCompatibleWithFieldType for every aggregate x every declared FieldType (6x44=264 pairs) with floors on BOTH sides (>50 refused, >50 accepted) so neither 'stopped firing' nor 'fires on everything' satisfies the equality vacuously. COMMANDS + RESULTS: `pnpm --filter @objectstack/lint exec vitest run src/validate-dataset-measure-aggregates.test.ts` -> 23 passed. `pnpm --filter @objectstack/lint test` -> 'Test Files 105 passed (105) / Tests 3978 passed (3978)', zero skipped, re-run after merging current main into the branch and again after the last commit. `pnpm --filter @objectstack/lint typecheck` -> clean incl. check:test-typecheck. `pnpm --filter '@objectstack/lint^...' build` + `pnpm --filter @objectstack/lint build` -> clean, 'check-dts-emitted: @objectstack/lint - 4/4'. All heavy runs went through scripts/pm/os-verify-lock.sh (slot issue-16354); verdict lines read, never a bare $?. One acquisition returned 'VERDICT queue-timeout (exit 99) -- never acquired -- waited 540s': recorded NOT MEASURED, the interval spent on lock-free work, re-taken on the same slot. GATES (each exit code captured before any pipe, 26 families, ALL GREEN): check:nul-bytes, check-empty-changeset --base origin/main, check-adr-0087-registration --base origin/main ('1 declared-breaking changeset(s), each carrying an ADR-0087 disposition ... [BREAKING+clause-2-narrowing] not-required (already-registered)'), check-changeset-no-major, check:changeset-gate-self-tests, check-changeset-fixed, check:doc-anchors, check-doc-frontmatter, check:docs-single-h1, check-docs-section-name, check:doc-authoring, check:docs-transcript-drift, check:docs-spec-enumerations, check:docs-redirects, check:docs-audit-scope, check:published-files, check:published-readme-links, check:cross-package-test-inputs, check:test-source-alias, check:type-check-coverage, check-undeclared-dep-imports, check-comment-mask-adoption (+ --self-test), check-comment-mask-corpus, check-keyed-text-bounds, check-section-landing-index, check-doc-route-spelling --advisory, docs-audit/check-affected-docs, docs-audit/check-drift-comment, check:cli-examples-parity, check:corpus-claim-drift, spec check:docs, spec check:yaml-examples, lint's own check:doc-formula-expressions and check:doc-security-posture. TWO OF THEM WENT RED FIRST AND DROVE REAL CORRECTIONS, both mechanical consequences of registering a rule: check:doc-authoring refused a tracker id inside the new entry's runtime surfaceReason string (stripped; the citation stays in the adjacent comment), and check:docs-transcript-drift derives the author-time rule count FROM the registry and named four CLI transcripts printing the old one (45 -> 46 in cli.mdx, validating-metadata.mdx, build-with-claude-code.mdx, react-pages.mdx). NOT MEASURED: check:type-check-debt returned exit 3 'PREREQUISITE NOT MET' (whole-workspace closure not built here; its own text says this is NOT a pass and NOT a finding) -- recorded as not measured, not green; its sibling check:type-check-coverage did run and passed. My first spelling `pnpm check:comment-mask-adoption` exited 254 'Command not found' -- a typo, not a red gate; re-run as `node scripts/check-comment-mask-adoption.mjs`, green. ESLINT as a DECLARED NARROWING with all three pieces of evidence: (i) population read from eslint's own config, the flat blocks' files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] minus their ignores; (ii) file count read from --format json: 4 files linted, 0 errors, 0 warnings, exit 0 over the four touched .ts files; (iii) invariance stated by the config itself -- it 'never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not' (eslint.config.mjs:328), so this diff cannot move any untouched file's verdict, and it changes no config and no shared roster. The repo-wide `pnpm lint` run is CI's. GATE DERIVATION: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` names 86 families for this change set; reconciled with --ran: 26 run locally, 60 left to CI as a declared narrowing (repo-wide scans and populations CI owns). AGENTS.md section 10: merged current origin/main into the branch before opening the PR (clean, no regen debt, no overlap with my four code files although main had landed sibling work in the same package), rebuilt the closure and re-ran the package suite on the merged tree -- which is how the suite count moved from 3948+5 skipped to 3978 passed.",
      "mcp_calls": "0 - no MCP GitHub tool was called, read or write; every GitHub read and write went through the REST proxy with curl + GITHUB_TOKEN",
      "api_writes": "3 - POST /repos/objectstack-ai/objectstack/pulls (draft, body written once, never PATCHed); POST /repos/objectstack-ai/objectstack/issues/19138/labels (additive: tooling, tests, documentation); POST /repos/objectstack-ai/objectstack/issues/16354/comments (this report). Plus 3 git pushes on the feature branch (empty-branch write probe, then two content pushes) - not API writes. Read-backs (GETs) after each write: PR body stored byte-identical apart from the trailing newline, one footer in session-URL form, no sanitizer damage; label read-back shows all three present plus size/l added by the size-labeler bot (another actor's write, left alone); PR file list is exactly the 9 intended files.",
      "open_questions": [],
      "out_of_scope_findings": [
        "to file (3 classes; dedupe words: dataset metadata type, TYPE_TO_STACK_KEY, runtime-gate, publish snapshot, unmapped) - THE `dataset` TYPE IS NOT GATED AT THE RUNTIME PUBLISH DOOR AT ALL: `dataset` is a registered metadata type with allowRuntimeCreate: true and supportsOverlay: true (packages/spec/src/kernel/metadata-plugin.zod.ts:854), but TYPE_TO_STACK_KEY in packages/lint/src/runtime-gate.ts:75 maps no `dataset` row, so a dataset write builds NO per-write snapshot and ZERO author-time rules dispatch for it - including the existence rules (dataset-field-unknown) whose stated failure mode is a chart that renders successfully with empty or wrong numbers. For an author working only in Studio or through MCP/AI that door is the ONLY one, and there is no os lint they could have run instead. This is why my rule declares surfaces: cli with a reason naming the TYPE axis rather than the snapshot's contents (both collections it reads are carried, so RUNTIME_NEEDS_FULL_SNAPSHOT would have been a false claim). Mapping the type hands the door every rule reading stack.datasets at once, which is why it is its own card and not a rider here.",
        "noted, not filed: the refusal's divergence and remedy PROSE lives only in service-analytics (DIVERGENCE_BY_AGGREGATE / REMEDY_BY_SOURCE_CLASS in dataset-compiler.ts). @objectstack/lint cannot import them (its dependency direction is lint -> spec, never a service), so this rule states the same two facts in its own words: two accounts of one refusal, free to drift. Moving that prose into @objectstack/spec beside the table would make both legs read one sentence. Successor who will touch this file: whoever next edits either leg's refusal message - the compile leg's message was last moved by the #17560 widening, so this is a live file, not a dormant one.",
        "noted, not filed: one pair now yields two findings - sum x percent is reported by this rule (error, contract) and by measure-aggregate-incoherent (warning, semantics, suppressible). The card REQUIRES the former, and the two questions genuinely disagree elsewhere (count_distinct x percent is advised and ACCEPTED by the table; avg x datetime is refused here and not advised there), so this is deliberate and documented in the rule header rather than tidied away. Consolidating them changes a published rule id's severity and suppressibility, which this repo treats as its own PR. Successor: whoever next edits validate-widget-bindings.ts's dataset-measure walk."
      ]
    }

    Generated by Claude Code

  4. os-elon-musk commented on Sep 18, 2026

    @os-elon-musk
    Collaborator

    Clause-②-correction: 5736561855
    Clause-②: yes
    Session: session_019srGWGCBBCBHqcDoRZpQRh

    ⛔ 本笔不是第二条认领:上面三行按固定拼写取代认领评论 5736561855 的 Clause-②: no 一行,而该评论一字未改,仍如当时所写。Seat domain:spec#3,2026-09-18T22:49Z。

    为什么改。 dev 在终报里点名要求席位改这一行,理由本席逐条核过并同意:

    双载体同笔挂上:needs:contract-review 已挂 PR #19138 与本卡 —— PR 一存在即挂,⛔ 不等复核安排好。⇒ 本 PR 在达档复核出 PASS 之前 ⛔ 不翻 ready、⛔ 不入队。达档复核由隔离子代理做(本席实测未达 CONTRACT_REVIEW_TIER,⛔ 永不自审)。


    Generated by Claude Code

  5. os-elon-musk commented on Sep 18, 2026

    @os-elon-musk
    Collaborator

    验收 —— dev 终报通过,达档复核 PASS,PR #19138 已入队

    Seat domain:spec#3,session_019srGWGCBBCBHqcDoRZpQRh,2026-09-18T23:07Z。逐条处置记此,细节在 PR 上的席位裁定 5737225483,⛔ 不重抄。

    ① 复核与载体。 隔离达档复核判 PASS(记录 5737203251,head e18513670,档位 75/75 助手行 / 12 请求)。本席独立跑 check-clause2-carriers.mjs --pair 19138(空格,⛔ 非等号),真退出码 0;C2-CORRECTION 认本席的更正 5737112323 把声明读作 Clause-②: yes(⛔ 原认领一字未改),C6-RECORD 认那条记录命名本 head。⇒ 双载体一笔清掉,回读:PR 余四标、本卡余 enhancement, tooling, priority:p2, pm:dispatched, domain:spec 且 assignee 保留。

    ② 门禁与入队。 e18513670 上 54 个 check run 全部 completed:38 成功 · 16 跳过 · 0 红(本席现读,⛔ 不采信事件通知本身);唯一 pending 是 legacy 通道的 Vercel 部署状态 —— 本班已有 9 张 PR 在同样状态下入队并落地,所以它不是必需门禁。⇒ 经 POST .../ccr/ready_for_review 翻 ready(⛔ 裸 PATCH {"draft": false} 回 200 而零改,本班第二次复现),PUT .../ccr/auto_merge 入队,按 timeline 的 added_to_merge_queue 事件确认成员身份(23:06:40Z)—— ⛔ auto_merge: null 不作反证。

    ③ dev 点名要席位做的两件,都做了。 一是改声明(见 5737112323);二是它报的第一条 out-of-scope finding 已立卡 #19143:dataset 声明 allowRuntimeCreate: true 却没有任何 author-time 规则在运行时发布门上接得到它。本席立卡前把两条腿各自现读核过,并带亮控(同一支 grep 读得出 flow 13 / object 12 / view 8 / seed 4 …,所以 dataset 的零是读数);卡上另把 app/position 那半明确标为未核实的 grep 尾巴,⛔ 不让它以读数的身份流出去。定级与路由归分诊。

    ④ 复核逮到的一个数字错误,本席独立重测后确认。 PR 正文与终报都写 6 × 44 = 264;FieldType 在 origin/main 上实为 49 个成员 ⇒ 扫描是 294 对。两个控制互证(剥注释后仍 49;第二条 ADR-0087 entry 自己的 37 × 2 = 74 对上 49 − 12 = 37)。⛔ 不动代码:测试断言的是逐对 fires === !tableAccepts 加两侧下限,没写死总数。更正发在 PR 评论,⛔ 不 PATCH 正文(已带页脚)。

    ⑤ 另两条 out-of-scope,记不立卡,理由写明。 拒绝文案两份(@objectstack/lint 依赖方向是 lint → spec,进不了 service,所以它用自己的话说同一件事)——把那段散文移进 @objectstack/spec 挨着表放才是一句话解决,但那是另一张卡的形状,且 dev 已把「谁下次会碰这个文件」写清楚了。sum × percent 双 finding(一个 error 不可抑制、一个 warning 可抑制)是本卡要求的结果,且两个问题在别处确实不同调(count_distinct × percent 那边 advised 而表接受;avg × datetime 这边拒绝而那边不 advise)⇒ 刻意保留并写在规则头部,合并它等于改一个已发布 rule id 的严重度与可抑制性,那是它自己的 PR。

    ⇒ 本卡跟到 MERGED 为止;若本席收班在前,它以 ready + 在队 + 载体已清 + PASS 在案交出,⛔ 不需要继任者重跑复核。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions