Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/lint-dataset-measure-aggregate-field-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
'@objectstack/lint': minor
---

Refuse a dataset measure whose `aggregate` the field's declared type cannot carry, at authoring time

A dataset measure pairs an `aggregate` with a `field`, and
`AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec`) declares which of those pairs every
backend answers the same way. Nothing in the authoring path read that table, so `avg` over a
`datetime` field validated clean and shipped: one SQL family coerces the column's canonical UTC
text and returns a plausible number (the average *year*), another has no such function and fails at
query time — the answer decided by the deployment rather than by the document. The analytics service
refuses the pair when a query is built (`400 DATASET_INVALID`); this is the same verdict, from the
same table, at the door the author is standing in front of.

New rule `measure-aggregate-field-type-refused`, gating (`error`), on `os validate` / `os build` /
`os lint`. It resolves the field's declared type on the object graph lint already indexes — including
a dotted `relationship.field` path, whose leaf type the compile leg cannot see — and refuses the
pair when `isAggregateCompatibleWithFieldType` says no. The message names the aggregate, the field,
its declared type and the accepted set, and the hint names the aggregates that type *does* accept,
both computed from the table rather than restated. It stays silent wherever the type cannot be
resolved (an object this stack does not define, a field path that resolves to nothing, an untyped
field, an aggregate outside the closed `AggregationFunction` vocabulary) rather than guessing.

**BREAKING**: metadata that passed `os validate` / `os build` / `os lint` before can now fail. Every
pair this refuses is one the analytics service already refuses at query time, so nothing that
*worked* stops working — but a build that did not fail now does.

Migration, per refused pair — FROM the aggregate the field's type cannot carry, TO one it accepts:

- `avg` / `sum` over a `date` / `datetime` / `time` field → `min` / `max`, which return a real
instant of the field's own type, or `count` / `count_distinct`. A DURATION is not recoverable from
an aggregate over instants: store it as a number (a computed "days open" field) and aggregate that.
- `sum` over a `percent` field → `avg`. A rate does not add; the total routinely exceeds 100%.
- `min` / `max` over the string, option, reference, file, structured-JSON or `formula` classes →
`count` / `count_distinct` for "how many distinct values", or a SORT on the record list for "the
first / last record". String order is collation-dependent, so two backends answer two different
"smallest" values for one document.
- Any other refused pair → read the row for your aggregate in
`AGGREGATE_FIELD_TYPE_COMPATIBILITY`; the refusal message prints it.

A `derived` measure whose `of` names a refused measure is fixed by fixing that measure, not the
`derived` one. A `date` / `datetime` / `text` field used as a DIMENSION — grouping, bucketing,
filtering — is untouched: this is about aggregation only.

Clause-②: yes (narrowing)

<!-- 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. -->
2 changes: 1 addition & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@ os compile --json # JSON output for CI pipelines
→ Normalizing stack definition...
→ Lowering inline handlers...
→ Validating protocol compliance...
→ Running author-time rules (45)...
→ Running author-time rules (46)...
→ Checking capability providers (#3366)...
→ Collecting package docs (ADR-0046)... 0 collected
→ Writing artifact...
Expand Down
27 changes: 26 additions & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,30 @@ The react `<ObjectChart>` block is **object-bound** (`objectName` + an inline
gate below against a different rule — its result rows are keyed by the raw field
names, exactly the opposite of the dataset case.

One level down from the axis, the measure itself is checked against the field it
aggregates. A measure pairs an `aggregate` with a `field`, and
`AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec`) declares which pairs
every backend answers the same way. A pair outside it is refused
(`measure-aggregate-field-type-refused`) naming the aggregate, the field, its
declared type and the accepted set:

```ts
// object declares `closed_at` as `datetime`
measures: [{ name: 'avg_closed', aggregate: 'avg', field: 'closed_at' }]
// ↑ refused: the answer would be the dialect's, not the data's
```

`avg` over a temporal column is where this bites hardest: one SQL family coerces
the stored text and returns a plausible number (the average *year*), another has
no such function and fails at query time. `min`/`max` over the same field are
**accepted** — they return a real instant of the field's own type — and
`count`/`count_distinct` are accepted over every type, because they read no
arithmetic off the value. The analytics service refuses the same pair with
`400 DATASET_INVALID` when a query is built; this is the identical verdict,
from the identical table, one door earlier. The rule stays silent wherever the
field's type cannot be resolved (an object this stack does not define, a
dangling field path, an untyped field) rather than guessing.

### 7. Navigation exposing objects nobody can read

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

Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ visible: 'status != "resolved"'
◆ Validate
────────────────────────────────────────
→ Validating against ObjectStack Protocol...
→ Running author-time rules (45)...
→ Running author-time rules (46)...

✗ Author-time rules failed (1 issue)
• stack · action 'resolve_ticket' visible: bare reference `status` — a
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/react-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ objectstack validate
────────────────────────────────────────
→ Loading configuration...
→ Validating against ObjectStack Protocol...
→ Running author-time rules (45)...
→ Running author-time rules (46)...
→ Checking capability providers (#3366)...
→ Checking package docs (ADR-0046)...

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

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

export { validateStackExpressions, fieldRuleRootIssue, FIELD_RULE_BOUND_ROOTS } from './validate-expressions.js';
export type { ExprIssue } from './validate-expressions.js';

Expand Down
Loading
Loading