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
75 changes: 75 additions & 0 deletions .changeset/dashboard-widget-compareto-converged.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
"@objectstack/spec": major
"@objectstack/service-analytics": major
---

**BREAKING — `dashboard.widgets[].compareTo` converges on the analytics executor's contract (#5011).**

The widget declared three period-over-period arms with confident TSDoc. The analytics
executor implements one shape, and it was never the same one — so on the ADR-0021 dataset
path (the spec's own "single author-facing analytics shape") **all three arms were
broken**, in two different ways:

- `compareTo: 'previousPeriod'` / `'previousYear'` were **silently DROPPED** by the dataset
renderer. The widget rendered its base numbers and the comparison the author asked for
simply was not there.
- `compareTo: { offset: '7d' }` was forwarded into `DatasetSelection.compareTo`, whose
contract is `{ kind, dimension }` and has no `offset` in it — so the executor threw
`compareTo requires a timeDimension "undefined"` and the whole widget errored out.

All three worked on the legacy inline chart path. Same key, two fates, and the failing one
was the path the spec calls canonical.

`compareTo` is now a thin projection of the contract that is actually implemented:

```ts
compareTo?: { kind: 'previousPeriod' | 'previousYear'; dimension?: string }
```

There is no widget-side vocabulary left to drift from the executor's, so `declared =
enforced` holds by construction rather than by review.

## FROM → TO

| v16 | v17 | Fix |
|:--|:--|:--|
| `compareTo: 'previousPeriod'` | `compareTo: { kind: 'previousPeriod' }` | `os migrate meta --from 16` rewrites it |
| `compareTo: 'previousYear'` | `compareTo: { kind: 'previousYear' }` | `os migrate meta --from 16` rewrites it |
| `compareTo: { offset: '1y' }` | `compareTo: { kind: 'previousYear' }` | `os migrate meta --from 16` rewrites it — `1y` **is** `previousYear` |
| `compareTo: { offset: '7d' \| '1M' \| … }` | **no faithful target** | State the window on the widget's own `filter` and compare with `{ kind: 'previousPeriod' }`, which shifts by that window's own length |

The last row is deliberately *not* rewritten. `previousPeriod` shifts by the length of
whatever window the filter resolves to, which equals `7d` only when that window happens to
be seven days — a mechanical rewrite would silently change which rows the comparison
column counts, turning a loud failure into a wrong number. It is registered as the
`dashboard-widget-compareto-offset` semantic migration; the schema rejects the key with the
prescription in hand.

Retired at the schema, so every old spelling is a parse error carrying its own upgrade —
including the bare strings, which are dispatched by value so a *typo* is still told it is a
typo rather than told it "was removed".

## `dimension` is optional — resolved by the executor, not by a renderer

Omit it and `dataset-executor.ts` resolves it, by its own long-standing criterion (a
`timeDimensions` entry carrying a `dateRange`):

- exactly one candidate → that one is shifted;
- **zero** → a loud error: a comparison is only defined against a bounded window;
- **two or more** → a loud error **listing the candidates by name**, never a silent
first-wins. Picking `created_at` when the author meant `close_date` produces a comparison
that is *wrong* rather than *missing*, which is the failure nobody audits.

This is a producer-side resolution rule, not consumer-side tolerance (Prime Directive
#12): every caller — dashboard widget, report, raw `queryDataset` — gets the same dimension
or the same error, and no renderer is ever in a position to guess one.

## Notes

- `DatasetCompareTo.dimension` is now optional. Callers that always passed it are
unaffected; callers that relied on the old "must be present" typing get a wider type.
- The converged slot is **union-free**. That is not cosmetic: zod collapses a failed union
into one bare `Invalid input`, so curated guidance written inside a union arm never
reaches the author (#5014). This slot's prescriptions are top-level and do.
- objectui's legacy inline chart path adapts separately (objectui#3337), which also deletes
the `DatasetWidget` string-drop workaround this change makes unnecessary.
2 changes: 1 addition & 1 deletion content/docs/references/ui/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Dashboard header action
| **actionType** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional | Type of action for the widget action button |
| **actionIcon** | `string` | optional | Icon identifier for the widget action button |
| **filter** | `any` | optional | Presentation-scope filter (runtimeFilter) |
| **compareTo** | `'previousPeriod' \| 'previousYear' \| { offset: string }` | optional | Period-over-period comparison window |
| **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`) |
| **dataset** | `string` | ✅ | Dataset name to bind (ADR-0021) |
| **dimensions** | `string[]` | optional | Dimension names — X/group/split |
| **values** | `string[]` | ✅ | Measure names — Y (at least one) |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/dashboards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ selects `dimensions` (X / group / split) and `values` (the measures to plot):
| `layout` | `object` | optional | Grid position and size (auto-flowed into the grid when omitted) |
| `chartConfig` | `object` | optional | Advanced chart configuration |
| `colorVariant` | `enum` | optional | KPI/card accent color |
| `compareTo` | `enum \| object` | optional | Period-over-period comparison window |
| `compareTo` | `object` | optional | Period-over-period comparison: `{ kind: 'previousPeriod' \| 'previousYear', dimension? }`. Omit `dimension` when the selection dates exactly one time dimension — the runtime resolves it, and errors naming the candidates rather than guessing when it cannot. |
| `options` | `object` | optional | Renderer extras **plus** the query keys below |

### Widget `options`
Expand Down
2 changes: 1 addition & 1 deletion docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ not verdicts).
| `component.zod.ts` | 29 | authorable | **next candidate** — SDUI component defs; check React-prop open slots first (p) |
| `theme.zod.ts` | 14 | authorable | **strict as of #4001 批 15** — all 14 sites. The `(p)` resolved to authorable on two doors, both measured: `stack.zod.ts` declares `themes: z.array(ThemeSchema)` (so `defineStack()` parses every theme on boot and on `objectstack build`), and `defineTheme()` parses one directly. A BFS from all 24 metadata-type roots plus `ObjectStackSchema` reaches every schema in the file, with `PageSchema`/`DashboardSchema`/`ReportSchema`/`WebhookSchema`/`StateMachineSchema` passing as positive controls and 批 13's no-door shapes failing as negative controls **in the same run**. Note what is NOT claimed: `theme` is deliberately absent from `BUILTIN_METADATA_TYPE_SCHEMAS`, so a stored theme row is not validated by the metadata REST door — the gate is the authoring one, and the file says so rather than implying reach it lacks. **The `passthrough` question was asked per BLOCK, not per file**, and the answer split: objectui's `ThemeEngine` reads `colors`/`borderRadius`/`shadows`/`typography.fontFamily` through FIXED maps (an extra key is read by nothing, ever), but spreads `fontSize`/`fontWeight`/`lineHeight`/`letterSpacing`/`duration`/`timing`/`zIndex` with `Object.entries` into `--font-size-<key>` … — the #4909 open shape at the runtime. Closed anyway, on two measurements: `.strip` already discarded those extras before the engine saw them (so no author depends on the openness and nothing the renderer receives changes), and `customVars` is a DECLARED escape hatch that emits an arbitrary CSS custom property by name, so closing the token scales removes no capability and only removes a second, undocumented way to spell one — the way whose typos are indistinguishable from intent. Curation is measured throughout: the shadcn vocabulary (`card`→`surface`, `foreground`→`text`, `destructive`→`error`) comes from objectui's own `COLOR_TO_CSS_MAP`, which RENAMES every palette key on the way out; `md`→`base` on `fontSize` and `base`→`normal` on `fontWeight` are a same-file scale disagreement (`borderRadius`/`shadows` declare `md`, `fontSize` does not); `radius`→`base` because `base` is emitted as the bare `--radius`, the one radius variable objectui's CSS actually reads; and `easeIn`→`ease_in` because `animation.timing` is the file's single snake_case vocabulary, so the camelCase spelling is an author obeying AGENTS.md #3 rather than making a typo. The eight #3494 removals get one distinct tombstone each. ⚠️ **Two of those tombstones deliberately prescribe NO replacement slot**: `touchTarget`/`keyboardNavigation` read like they should point at `ui/touch.zod.ts`/`ui/keyboard.zod.ts`, which 批 13 measured as having no carrier at all (#4988) — prescribing them would walk an author out of a loud rejection into a silent one, the ledger's finding 7. ⚠️ **Separately filed, not answered here**: `--font-size-*`, `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--z-*`, `--duration-*`, `--timing-*`, `--font-heading` and `--font-mono` have ZERO first-party consumers (only the colour vars, `--radius*`, `--shadow*` and `--font-sans` are read). That is ADR-0049 liveness, not unknown keys, and the two must not be run together — strictness makes a dropped key loud, it cannot make a slot live |
| `app.zod.ts` | 18 | authorable | **strict as of #4001 PR B** — `AppSchema` + branding / area / context-selector / contribution, and the nav-item union converted to `z.discriminatedUnion('type', …)` (the union-error question, settled empirically: matched-branch-only errors, exact recursive paths, `toJSONSchema` clean). Per-target `params` stay open. PR A (#4142) tombstoned the seven audit-dead keys first |
| `dashboard.zod.ts` | 11 | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DashboardWidgetSchema` has been strict since the ADR-0021 cutover; 批 14 closed the two NESTED holes inside it (`compareTo`'s object arm, `layout`), the same strict-shell-over-strip-children silhouette 批 13 found on `page.components[]`. `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch) and the `responsive` tombstone (#4876) is untouched. ⚠️ `compareTo` is a UNION, so its curated prescription is produced but not delivered — `zodIssuesToFields` maps only top-level issues and a failed union collapses to a bare `Invalid input` (#5014). The REJECTION is unaffected |
| `dashboard.zod.ts` | 11 | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DashboardWidgetSchema` has been strict since the ADR-0021 cutover; 批 14 closed the two NESTED holes inside it (`compareTo`'s object arm, `layout`), the same strict-shell-over-strip-children silhouette 批 13 found on `page.components[]`. `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch) and the `responsive` tombstone (#4876) is untouched. ⚠️ **The `compareTo` union caveat this row carried is RESOLVED, and it is the one entry in this table whose limit was dissolved rather than worked around.** 批 14 recorded that `compareTo` was a UNION, so its curated prescription was produced but never delivered — `zodIssuesToFields` maps only top-level issues and a failed union collapses to a bare `Invalid input` (#5014) — with the rejection itself unaffected. **#5011 removed the union**: the slot converged onto the analytics executor's own contract, `{ kind, dimension? }`, a plain strict object whose message IS top-level. The reason was not the message, it was worse — all three declared arms were broken on the ADR-0021 dataset path (the two strings silently dropped by the renderer, `{ offset }` throwing `compareTo requires a timeDimension "undefined"`), while all three worked on the legacy inline path: same key, two fates, the failing one blessed. The union-free shape is the design benefit, pinned in `dashboard-compareto.test.ts` so it cannot silently return. **#5014 still binds every OTHER curated message this campaign has put inside a union arm** — this row is one slot's correction, not the finding's retraction |
| `widget.zod.ts` | 9 | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 16)** — the `(p)` resolved NEGATIVE for the whole file, the second such run after 批 13's five. Three independent measurements on 2026-08-04: (1) nothing under `packages/spec/src` imports this module except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for a widget shape — `field.widget` is a `z.string()` naming a registered *component* and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack` (4 766 nodes) reaches none of the six shapes, while `PageSchema` / `ObjectListViewSchema` resolve in the same run, a fresh `z.object` and a deliberate look-alike both resolve unreachable, and a synthetic carrier flips all six to reachable; (3) zero `.parse()` / `.safeParse()` in `objectstack`, `objectui` or `cloud` outside this file's own tests — objectui re-exports the inferred TYPES only and under different names (`RuntimeWidgetManifest` / `FieldWidgetComponentProps`, #4115 / #3161), and a `cloud` code search returns 0 for every symbol against a working index (`"@objectstack/spec"` → 345). ADR-0049 enforce-or-remove is **#5055**. ⚠️ **The campaign's own BFS said REACHABLE on the first run** — a false positive in the derived-clone bridge, filed as **#5056**: zod's `.describe()` returns a clone that SHARES the original `_zod.def`, so `WidgetManifestSchema.name` / `.label` (a described `SnakeCaseIdentifierSchema` / `I18nLabelSchema`) are def-identical to the same leaves on live schemas, and a bridge firing on ANY one shared property links two unrelated shapes. 2 shared keys of 20. The error is one-directional — it can only manufacture a door, i.e. it can only make a batch tighten something dead. Corrected to whole-shape overlap in `ui/door-reachability.testkit.ts` and pinned in `widget.test.ts` |
| `page.zod.ts` | 7 | authorable | partially strict (ADR-0089) |
| `chart.zod.ts` | 7 | **mixed — 5 authorable, 2 no gate** | **5 strict as of #4001 批 15**; 2 deliberately left open. `ChartConfigSchema` / `ChartAxis` / `ChartSeries` / `ChartAnnotation` / `ChartInteraction` are `root-graph`-reachable from the `dashboard` and `report` metadata roots (`DashboardWidget.chartConfig`, `ReportChartSchema`), so they are judged on the stored-metadata path and are now closed. **`ChartAggregateSchema` and `ChartGroupBySchema`'s object arm are NOT**, and this is the batch's real finding. They are not 批 13's no-door case — their carrier is LIVE: `aggregate` is a real authorable prop on the react tier's `<ObjectChart objectName aggregate={…}>` (ADR-0081), published in the generated react-blocks contract, and objectui's `ObjectChart` reads `schema.aggregate` to run the query. What is missing is the PARSE: neither schema is reachable from any metadata-type root or from `ObjectStackSchema` (both `UNREACHABLE` in the run where the five above come back `root-graph`), nothing in the three repos calls `.parse()` on them outside this file's unit tests, and the gate that DOES judge an authored `aggregate` — the react-page publish lint — re-derives the rules by hand (`CHART_FUNCTIONS`, the count/field requirement, the result-column naming) and never checks unknown keys. `react-blocks.ts` publishes the prop as a hand-written TYPE STRING; the Zod schema beside it is not what the contract is generated from. So `groupby` / `dateGranularty` are silently dropped today and would go on being silently dropped after a `strictObject` here — `.strict()` is a property of a parse. A fourth class, **`no gate`**: carrier live, parse absent. Distinct from `no door` (批 13), where the carrier itself does not exist. The contract-first fix is to make the publish gate PARSE the schema instead of re-deriving it — a `packages/lint` change, filed rather than smuggled into a spec strictness batch. Recorded in three places (schema-adjacent comment, test pin incl. a standing BFS assertion that goes red the day a carrier key appears, this row). ⚠️ One correction shipped with the tightening: the `clickAction` migration text #3752 wrote into this file prescribed **`drillDown`, which is not a key this protocol declares anywhere** — it is an untyped `(schema as any).drillDown` read inside objectui's `ObjectChart`. Promoting that sentence into a strict rejection would have handed an author the platform's authority for a key the same gate then rejects: finding 7, third occurrence, this time caught before shipping. The prose and the tombstone now name `onSegmentClick` / `ReportSchema.drilldown` / the widget's `options` bag, all of which exist. Filed separately. **`chart` 6 → 7 at the re-measurement** — no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break |
Expand Down
Loading
Loading