Skip to content

Commit 362dcc3

Browse files
os-elon-muskclaude
andauthored
lint: refuse a dataset measure whose aggregate its field type cannot carry (#19138)
Fixes #16354 Clause-②: yes (narrowing) The lint leg of the aggregate × field-type contract (director ruling, decision batch #59, 2026-09-06, 「both legs, table in spec」). A dataset measure pairs an `aggregate` with a `field`; `AGGREGATE_FIELD_TYPE_COMPATIBILITY` in `@objectstack/spec` declares which of those pairs every backend answers the same way, and nothing in the authoring path read it. New gating rule `measure-aggregate-field-type-refused` in `packages/lint/src/validate-dataset-measure-aggregates.ts`, registered in `AUTHORING_RULES`, so all three commands run it. The verdict is the shared predicate's on every pair — `isAggregateCompatibleWithFieldType`, the same call the compile leg makes — and there is no second table in this repo. ## The take-order test, as an artifact The maintainer's test for a contract-surface card: take a piece of author-written metadata, feed it in before and after the change, and see whether accept/reject flips. Both arms below were **run**, not reasoned from the diff: one probe script, byte-identical in both arms, driving the real shared registry (`runAuthoringRules('lint', …)` — the table `os lint` / `os validate` / `os build` all run). The sample (author-written metadata, three measures over one object): ```ts objects: [{ name: 'crm_opportunity', sharingModel: 'private', fields: { name: { type: 'text' }, units: { type: 'number' }, closed_at: { type: 'datetime' }, win_rate: { type: 'percent' }, } }], datasets: [{ name: 'opportunity_metrics', object: 'crm_opportunity', dimensions: [], measures: [ { name: 'avg_closed_at', aggregate: 'avg', field: 'closed_at' }, // positive control { name: 'avg_units', aggregate: 'avg', field: 'units' }, // negative control { name: 'total_win_rate', aggregate: 'sum', field: 'win_rate' }, // third control ] }] ``` | measure | BEFORE (`c70581bc8`, pristine worktree) | AFTER (this branch) | |---|---|---| | `avg(closed_at)` — `datetime` | **ACCEPTED**, no finding | **REFUSED**, `error` | | `avg(units)` — `number` | ACCEPTED, no finding | ACCEPTED, no finding (unchanged) | | `sum(win_rate)` — `percent` | ADVISED only — `measure-aggregate-incoherent`, `warning` | **REFUSED**, `error`, beside that same warning | | total findings on the sample | 1 | 3 | ⇒ accept/reject flips on author-written metadata. Contract surface. **BEFORE arm** — taken on the pristine worktree (`git status --porcelain` printed 0 lines) at the branch point, through the package source, with `@objectstack/spec` freshly built in this worktree beforehand: ``` $ pnpm exec tsx probe.mjs /home/user/wt-16354/packages/lint/src/index.ts total findings over the whole sample: 1 --- measure[0] avg(closed_at) "avg_closed_at" -> ACCEPTED (no finding) --- measure[1] avg(units) "avg_units" -> ACCEPTED (no finding) --- measure[2] sum(win_rate) "total_win_rate" -> ADVISED (warning) [warning] measure-aggregate-incoherent @ datasets[0].measures[2] ``` **AFTER arm** — same probe, unchanged, on this branch; run twice, through the source and through a freshly built `dist`, with identical verdicts: ``` $ pnpm exec tsx probe.mjs .../packages/lint/src/index.ts # and: node probe.mjs .../packages/lint/dist/index.js total findings over the whole sample: 3 --- measure[0] avg(closed_at) "avg_closed_at" -> REFUSED (error) [error] measure-aggregate-field-type-refused @ datasets[0].measures[0].aggregate --- measure[1] avg(units) "avg_units" -> ACCEPTED (no finding) --- measure[2] sum(win_rate) "total_win_rate" -> REFUSED (error) [warning] measure-aggregate-incoherent @ datasets[0].measures[2] [error] measure-aggregate-field-type-refused @ datasets[0].measures[2].aggregate ``` **Build provenance, because a stale `dist` reads exactly like a real reading.** Both arms resolve `@objectstack/spec` through its build. `packages/spec/dist` was absent in this fresh worktree and was built here (`pnpm --filter '@objectstack/lint^...' build`, run twice: once before the BEFORE arm, once after merging `main`); `packages/lint/dist` was built for the dist arm. **Nothing was served from a turbo cache: no `turbo` process was in either pipeline** — `pnpm --filter … build` invokes each package's own script directly, and `grep -ciE 'cache hit|turbo|FULL TURBO'` over the build logs returns 0. The spec `dist` timestamp is newer than every source it was built from. ## The three controls the card names All three are permanent tests in `validate-dataset-measure-aggregates.test.ts` (23 tests in the file; the whole package is 105 files / 3978 tests, green): 1. **positive control** — `avg` over a `datetime` field **fires**: one `error` at `datasets[0].measures[0].aggregate`, message pinned to name the aggregate, the field, its type and every accepted type. 2. **negative control** — `avg` over a `number` field is **silent**. Generalised rather than left as one case: `avg`/`sum` over the whole numeric class, all four arithmetic and order aggregates over the boolean class (maintainer ruling #11152), `min`/`max` over the temporal class, and `count`/`count_distinct` over **every** declared `FieldType` are each asserted silent. 3. **`sum` over `percent`** **fires** — the pair `analytics-service.ts` already calls incoherent. It now carries two findings: the older advisory about meaning (suppressible) and this gating refusal about the contract. Deliberate, and documented in the rule's header: the two questions disagree elsewhere — `count_distinct` × `percent` is advised and **accepted** by the table, `avg` × `datetime` is refused here and not advised there. And the strongest anti-false-positive assertion, because a false positive here is worse than the gap being closed: the rule's verdict is compared against `isAggregateCompatibleWithFieldType` for **every aggregate × every declared `FieldType`** (6 × 44 = 264 pairs), with floors on *both* sides of the sweep (more than 50 refused, more than 50 accepted) so neither "stopped firing" nor "fires on everything" can satisfy the equality vacuously. ## The message the rule emits, verbatim ``` measure "avg_closed_at" applies aggregate "avg" to field "closed_at", which object "crm_opportunity" declares as `datetime`. That pair is refused by the aggregate × field-type compatibility table in @objectstack/spec, so the number a backend returns for it is a property of the SQL dialect rather than of the data — one coerces the stored form and answers something plausible, another has no such function and fails at query time. "avg" accepts: number, currency, percent, rating, slider, progress, summary, boolean, toggle. ``` hint: ``` Either point "avg" at a field of an accepted type, or aggregate "closed_at" with one its `datetime` type accepts: count, count_distinct, min, max. `count` / `count_distinct` accept every type because they read no arithmetic off the value; a quantity that must be added up or averaged has to be STORED as a numeric field (a computed column) and aggregated as one. The compile leg refuses this same pair with `400 DATASET_INVALID` before any SQL is emitted, so this is the same repair made earlier. ``` Both halves are **computed from the table** — the accepted set for the refused aggregate, and the aggregates that would accept this field's type — never prose restating it, so neither can drift from the rows. ## Where it stands down, and where it reaches further Silent wherever the type cannot be resolved, per the spec module's own instruction that a consumer must not hand the predicate a guess: a dataset naming no base object or one this stack does not define, an object with no readable field map, a field path that resolves to nothing (that is `dataset-field-unknown`'s finding — one typo must not also yield a type verdict), an untyped leaf, a non-string in either position, and an aggregate outside the closed `AggregationFunction` vocabulary. Each is a test. It reaches further than the compile leg in exactly one direction: a dotted `relationship.field` reference. The compile leg returns early on those because its declared-type source answers for the base object only; authoring time has the whole object graph, so the leaf's declared type is a read rather than an inference, and the refusal names the object the leaf lives on. Registry-injected columns are judged on the same axis as authored ones. ## Verification - `pnpm --filter @objectstack/lint test` — 105 files, **3978 passed, 0 skipped**. Re-run after merging current `main` into this branch (the merge landed sibling work inside this package), and again after the last commit. - `pnpm --filter @objectstack/lint typecheck` — clean, including `check:test-typecheck` over the test layer. - `pnpm --filter '@objectstack/lint^...' build` and `pnpm --filter @objectstack/lint build` — clean, dts emitted. - Gates run locally, each exit code captured before any pipe: `check:nul-bytes`, `check-empty-changeset --base origin/main`, `check-adr-0087-registration --base origin/main` (judges this changeset: `[BREAKING+clause-②-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`, `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` and `check:yaml-examples`, and the lint package's own two doc gates. **All green.** - **Two of those gates went red on my first pass 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, and `check:docs-transcript-drift` derives the author-time rule count from the registry and found four CLI transcripts still printing the old one (45 → 46). - ESLint, as a declared narrowing rather than a farm run: `eslint --no-inline-config --format json` over the four touched TypeScript files — **4 files linted, 0 errors, 0 warnings**. The invariance that makes the narrowing a measurement rather than a skipped check is stated by the config itself: this repo runs one `eslint.config.mjs`, which *"never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file, test or not"* — so no verdict on an untouched file can move on account of this diff, which changes no config and no shared roster. The population is that config's own `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` blocks minus their `ignores`; the repo-wide run belongs to CI. - `check:type-check-debt` reported **PREREQUISITE NOT MET (exit 3)** — the whole-workspace closure is not built here, and it says in its own words that this "is NOT a pass and NOT a finding". Recorded as not measured, not as green. Its sibling `check:type-check-coverage` did run and passed. - The gate derivation (`scripts/pm/dispatch-gates.mjs --commands`) names 86 families for this change set; 26 were run locally and the rest — repo-wide scans and populations CI owns — are left to CI, as a declared narrowing. ## Acceptance notes Out of scope here, filed nowhere by this PR, recorded so they are not rediscovered: - **The `dataset` metadata type is not gated at the runtime publish door at all.** `dataset` is a registered type with `allowRuntimeCreate: true` and `supportsOverlay: true`, but `TYPE_TO_STACK_KEY` in `runtime-gate.ts` 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 whose whole failure mode is a chart that renders empty. That is why this rule declares `surfaces: cli` with a reason naming the type axis rather than the snapshot's contents: both collections it reads are carried, so the usual reason does not apply. Mapping that type is a card of its own, and it would hand the door every rule reading `stack.datasets` at once. - **The refusal's divergence and remedy prose lives only in `service-analytics`.** The compile leg builds its message from two local helpers; `@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 can drift. Moving that prose into `@objectstack/spec` beside the table would make both legs read one sentence. - **One pair now yields two findings.** `sum` × `percent` is reported by both this rule (`error`, contract) and `measure-aggregate-incoherent` (`warning`, semantics, suppressible). Consolidating them is a decision about a published rule id's severity and suppressibility, which is its own PR by this repo's own convention. **Note on the declaration — it diverges from the claim comment, deliberately.** The claim carries `Clause-②: no`, which is right about the refusal on its own: narrowing an accept set is a semantic surface and does not by itself touch clause ②. But this diff *also* adds published exports from `@objectstack/lint` (`validateDatasetMeasureAggregates`, `MEASURE_AGGREGATE_FIELD_TYPE_REFUSED`, `DatasetMeasureAggregateFinding`) and an `AUTHORING_RULES` entry — both widening tells against a `no` declaration — so the honest value is `yes`, and `yes (narrowing)` is the spelling for a diff that widens *and* narrows. Body and changeset carry the identical line. The changeset additionally carries the migration per refused class and the ADR-0087 disposition `not-required (already-registered …)`: the two semantic entries registering this exact surface already exist, and the compile-time leg declared the same disposition against the first of them. The claim comment is the seat's to amend, not this PR's. This PR opens as a draft and stays draft. --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e6c34f6 commit 362dcc3

9 files changed

Lines changed: 744 additions & 4 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
Refuse a dataset measure whose `aggregate` the field's declared type cannot carry, at authoring time
6+
7+
A dataset measure pairs an `aggregate` with a `field`, and
8+
`AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec`) declares which of those pairs every
9+
backend answers the same way. Nothing in the authoring path read that table, so `avg` over a
10+
`datetime` field validated clean and shipped: one SQL family coerces the column's canonical UTC
11+
text and returns a plausible number (the average *year*), another has no such function and fails at
12+
query time — the answer decided by the deployment rather than by the document. The analytics service
13+
refuses the pair when a query is built (`400 DATASET_INVALID`); this is the same verdict, from the
14+
same table, at the door the author is standing in front of.
15+
16+
New rule `measure-aggregate-field-type-refused`, gating (`error`), on `os validate` / `os build` /
17+
`os lint`. It resolves the field's declared type on the object graph lint already indexes — including
18+
a dotted `relationship.field` path, whose leaf type the compile leg cannot see — and refuses the
19+
pair when `isAggregateCompatibleWithFieldType` says no. The message names the aggregate, the field,
20+
its declared type and the accepted set, and the hint names the aggregates that type *does* accept,
21+
both computed from the table rather than restated. It stays silent wherever the type cannot be
22+
resolved (an object this stack does not define, a field path that resolves to nothing, an untyped
23+
field, an aggregate outside the closed `AggregationFunction` vocabulary) rather than guessing.
24+
25+
**BREAKING**: metadata that passed `os validate` / `os build` / `os lint` before can now fail. Every
26+
pair this refuses is one the analytics service already refuses at query time, so nothing that
27+
*worked* stops working — but a build that did not fail now does.
28+
29+
Migration, per refused pair — FROM the aggregate the field's type cannot carry, TO one it accepts:
30+
31+
- `avg` / `sum` over a `date` / `datetime` / `time` field → `min` / `max`, which return a real
32+
instant of the field's own type, or `count` / `count_distinct`. A DURATION is not recoverable from
33+
an aggregate over instants: store it as a number (a computed "days open" field) and aggregate that.
34+
- `sum` over a `percent` field → `avg`. A rate does not add; the total routinely exceeds 100%.
35+
- `min` / `max` over the string, option, reference, file, structured-JSON or `formula` classes →
36+
`count` / `count_distinct` for "how many distinct values", or a SORT on the record list for "the
37+
first / last record". String order is collation-dependent, so two backends answer two different
38+
"smallest" values for one document.
39+
- Any other refused pair → read the row for your aggregate in
40+
`AGGREGATE_FIELD_TYPE_COMPATIBILITY`; the refusal message prints it.
41+
42+
A `derived` measure whose `of` names a refused measure is fixed by fixing that measure, not the
43+
`derived` one. A `date` / `datetime` / `text` field used as a DIMENSION — grouping, bucketing,
44+
filtering — is untouched: this is about aggregation only.
45+
46+
Clause-②: yes (narrowing)
47+
48+
<!-- adr-0087: not-required (already-registered dataset-measure-aggregate-field-type-refused, dataset-measure-selecting-aggregate-field-type-refused) The two entries register this exact surface — dataset measure `aggregate` x `field` pairs the table refuses — with the prescription above; #16099 likewise declared not-required against the first id when it widened the same leg. This change adds no surface of its own: it is the second consumer of that one table, at the authoring door. -->

‎content/docs/deployment/cli.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,7 @@ os compile --json # JSON output for CI pipelines
589589
→ Normalizing stack definition...
590590
→ Lowering inline handlers...
591591
→ Validating protocol compliance...
592-
→ Running author-time rules (45)...
592+
→ Running author-time rules (46)...
593593
→ Checking capability providers (#3366)...
594594
→ Collecting package docs (ADR-0046)... 0 collected
595595
→ Writing artifact...

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,30 @@ The react `<ObjectChart>` block is **object-bound** (`objectName` + an inline
189189
gate below against a different rule — its result rows are keyed by the raw field
190190
names, exactly the opposite of the dataset case.
191191

192+
One level down from the axis, the measure itself is checked against the field it
193+
aggregates. A measure pairs an `aggregate` with a `field`, and
194+
`AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec`) declares which pairs
195+
every backend answers the same way. A pair outside it is refused
196+
(`measure-aggregate-field-type-refused`) naming the aggregate, the field, its
197+
declared type and the accepted set:
198+
199+
```ts
200+
// object declares `closed_at` as `datetime`
201+
measures: [{ name: 'avg_closed', aggregate: 'avg', field: 'closed_at' }]
202+
// ↑ refused: the answer would be the dialect's, not the data's
203+
```
204+
205+
`avg` over a temporal column is where this bites hardest: one SQL family coerces
206+
the stored text and returns a plausible number (the average *year*), another has
207+
no such function and fails at query time. `min`/`max` over the same field are
208+
**accepted** — they return a real instant of the field's own type — and
209+
`count`/`count_distinct` are accepted over every type, because they read no
210+
arithmetic off the value. The analytics service refuses the same pair with
211+
`400 DATASET_INVALID` when a query is built; this is the identical verdict,
212+
from the identical table, one door earlier. The rule stays silent wherever the
213+
field's type cannot be resolved (an object this stack does not define, a
214+
dangling field path, an untyped field) rather than guessing.
215+
192216
### 7. Navigation exposing objects nobody can read
193217

194218
Navigation and permissions are separate metadata, each valid on its own — so an
@@ -396,6 +420,7 @@ orthogonal to both, and no cell here can carry it; it is written out in
396420
| Zod-valid but functionally inert declarations — a `summary` with no operations (ADR-0078), a managed object advertising an API method its affordances refuse (#7521) | ✓ | ✓ | ✓ | ✓ᵒ |
397421
| View container shape | ✓ | ✓ | ✓ | — |
398422
| Widget-binding integrity (ADR-0021) | ✓ | ✓ | ✓ | ✓ᵈ |
423+
| Dataset measure `aggregate` × the field's declared type — a pair the spec's compatibility table refuses, e.g. `avg` over a `datetime` field (#16354) | ✓ | ✓ | ✓ | — |
399424
| Dashboard action/route references (ADR-0049) | ✓ | ✓ | ✓ | — |
400425
| Filter placeholder resolvability (#3574) | ✓ | ✓ | ✓ | — |
401426
| Ordering comparands naming a date-range preset — `last_30_days` in a `>=` position (#8793) | ✓ | ✓ | ✓ | ✓ᵈᵛᵒᵖᶠ |
@@ -624,7 +649,7 @@ A clean run walks the registry and reports timing:
624649
Config: /path/to/support-desk/objectstack.config.ts
625650
Load time: 21ms
626651
→ Validating against ObjectStack Protocol...
627-
→ Running author-time rules (45)...
652+
→ Running author-time rules (46)...
628653
→ Checking capability providers (#3366)...
629654
→ Checking package docs (ADR-0046)...
630655

‎content/docs/getting-started/build-with-claude-code.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ visible: 'status != "resolved"'
265265
◆ Validate
266266
────────────────────────────────────────
267267
→ Validating against ObjectStack Protocol...
268-
→ Running author-time rules (45)...
268+
→ Running author-time rules (46)...
269269
270270
✗ Author-time rules failed (1 issue)
271271
• stack · action 'resolve_ticket' visible: bare reference `status` — a

‎content/docs/ui/react-pages.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ objectstack validate
381381
────────────────────────────────────────
382382
→ Loading configuration...
383383
→ Validating against ObjectStack Protocol...
384-
→ Running author-time rules (45)...
384+
→ Running author-time rules (46)...
385385
→ Checking capability providers (#3366)...
386386
→ Checking package docs (ADR-0046)...
387387

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import { validateFunctionalCompleteness } from './validate-functional-completene
103103
import { validateManagedApiMethods } from './validate-managed-api-methods.js';
104104
import { validateViewContainers } from './validate-view-containers.js';
105105
import { validateWidgetBindings } from './validate-widget-bindings.js';
106+
import { validateDatasetMeasureAggregates } from './validate-dataset-measure-aggregates.js';
106107
import { validateDashboardActionRefs } from './validate-dashboard-action-refs.js';
107108
import { validateFilterTokens } from './validate-filter-tokens.js';
108109
import { validateFlowFilterTokens } from './validate-flow-filter-tokens.js';
@@ -601,6 +602,40 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
601602
runtimeTypes: ['dashboard'],
602603
run: (stack) => validateWidgetBindings(stack),
603604
},
605+
// #16354 — the AUTHORING-TIME leg of the aggregate × field-type contract
606+
// (director ruling, decision batch #59, 2026-09-06: "both legs, table in
607+
// spec"; the table is `AGGREGATE_FIELD_TYPE_COMPATIBILITY` in
608+
// `@objectstack/spec`, #16353). The compile leg (`dataset-compiler`,
609+
// `service-analytics`) refuses a refused pair with `400 DATASET_INVALID`
610+
// when a query is built; this one refuses it while the author still has the
611+
// document open. `parsed`, the same tier as `validateWidgetBindings` above,
612+
// because the two read the SAME positions (`datasets[].measures[]`) and must
613+
// not be handed two different documents to judge.
614+
{
615+
name: 'validateDatasetMeasureAggregates',
616+
tier: 'gating',
617+
input: 'parsed',
618+
commands: ALL,
619+
source: 'packages/lint/src/validate-dataset-measure-aggregates.ts',
620+
// NOT RUNTIME_NEEDS_FULL_SNAPSHOT: the two collections this rule reads —
621+
// `objects` and `datasets` — are both carried (#7529). What holds it off
622+
// the door is the type axis: the metadata type that CARRIES the
623+
// declaration is `dataset` (`allowRuntimeCreate: true`), and
624+
// `TYPE_TO_STACK_KEY` in `runtime-gate.ts` has no `dataset` row, so a
625+
// dataset write builds no per-write snapshot and no rule can be dispatched
626+
// for it. Declaring another type here would only re-judge a STORED
627+
// dataset, which the #4463 D4 differential cancels as someone else's
628+
// pre-existing condition — wired, and enforcing nothing. Mapping the
629+
// `dataset` type at the gate is its own card (every rule reading
630+
// `stack.datasets` gains the door at once, including the existence rules).
631+
surfaces: CLI_ONLY,
632+
surfaceReason:
633+
'The declaring metadata type is `dataset`, which `runtime-gate.ts`\'s TYPE_TO_STACK_KEY does '
634+
+ 'not map — a dataset write builds no per-write snapshot, so nothing can dispatch this rule '
635+
+ 'there; declaring any other type would only re-judge a stored dataset, which the publish '
636+
+ 'gate\'s differential cancels as somebody else\'s pre-existing condition.',
637+
run: (stack) => validateDatasetMeasureAggregates(stack),
638+
},
604639
// ADR-0049 / #3367 — a dashboard header action naming a dead target ships a
605640
// button that renders and refuses (or does nothing) on click: a `script`
606641
// target must name a defined action, a `modal` target must name a declared

‎packages/lint/src/index.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@ export {
5050
} from './validate-widget-bindings.js';
5151
export type { WidgetBindingFinding, WidgetBindingSeverity } from './validate-widget-bindings.js';
5252

53+
// [#16354] The authoring-time leg of the aggregate × field-type contract
54+
// (director ruling, decision batch #59: "both legs, table in spec"). Exported
55+
// as its own rule because the verdict is the SPEC TABLE's — the same
56+
// `isAggregateCompatibleWithFieldType` the compile leg calls — so a consumer
57+
// that authors datasets outside a config file (Studio, an MCP/AI author, a
58+
// generator) runs one rule rather than re-deriving the table.
59+
export {
60+
validateDatasetMeasureAggregates,
61+
MEASURE_AGGREGATE_FIELD_TYPE_REFUSED,
62+
} from './validate-dataset-measure-aggregates.js';
63+
export type { DatasetMeasureAggregateFinding } from './validate-dataset-measure-aggregates.js';
64+
5365
export { validateStackExpressions, fieldRuleRootIssue, FIELD_RULE_BOUND_ROOTS } from './validate-expressions.js';
5466
export type { ExprIssue } from './validate-expressions.js';
5567

0 commit comments

Comments
 (0)