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
55 changes: 55 additions & 0 deletions .changeset/dashboard-stageorder-gated-to-funnel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
"@objectstack/spec": minor
---

fix(spec)!: `dashboard.widgets[].options.stageOrder` is refused on every widget type that does not read it (#17344, finding 1)

<!-- adr-0087: registered dashboard-widget-stage-order-non-funnel-refused -->

**BREAKING** — an accept-set narrowing on a published authoring surface. `options.stageOrder` was an ungated member of the widget `options` bag and parsed on every widget `type`; it is now refused at parse on every type except `funnel`. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying `stageOrder` on a non-`funnel` widget now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `dashboard-widget-stage-order-non-funnel-refused`.

## What was wrong

The key never failed. It failed to *order*.

`options` is the open renderer-extras bag, so nothing closed over `stageOrder`: a `horizontal-bar` widget carrying an authored seven-stage contract lifecycle parsed, booted, and forwarded the array to the renderer — which never looked at it, and rendered alphabetically by display label instead.

Measured at this repo's `.objectui-sha` pin `53ded82b`: the forwarded `categoryOrder` prop has exactly **one** read in the charts plugin — `buildCategoryRank(categoryOrder)` at `AdvancedChartImpl.tsx:1514` — and it sits inside the `chartType === 'funnel'` guard opened at line 1473. The prop's only other occurrences in that file are its declaration (247) and its destructure (850). The producer has no gate either: `DatasetWidget.tsx:1468` builds the explicit order for **any** widget and forwards it whenever non-empty.

So the authored order was accepted by the metadata layer, carried all the way to the chart, and dropped there with nothing anywhere to say so. A chart rendered in an order the author did not ask for, and did not ask for it *visibly* — it just looked deliberate. That is ADR-0049's enforce-or-remove shape, and a doc sentence saying "only `funnel` reads this" is not enforcement: it is prose the author has to read first.

## What it does now

`DashboardWidgetSchema` carries an object-level check that refuses `stageOrder` unless the widget's `type` is `funnel`.

It has to be object-level: `stageOrder` lives inside `DashboardWidgetOptionsSchema` while the `type` that decides whether it means anything is that object's **sibling one level up**, so a per-field refinement on `stageOrder` cannot see it. The check is a named function chained on with `.superRefine(…)` — the idiom this file already uses for `GlobalFilterSchema`'s date-default rule, rather than a second shape invented for one key.

The refusal lands at `options.stageOrder` and names three things, because the defect was silence and a bare "unrecognized key" answers silence with a shrug: the key, the `type` this widget carries, and the one `type` that honours it — plus where ordering lives for everything else.

## FROM → TO

| you wrote | write instead |
| --- | --- |
| `{ type: 'horizontal-bar', options: { stageOrder: [...] } }` | `{ type: 'horizontal-bar', options: { sortBy: 'contract_count', sortOrder: 'desc' } }` |
| `{ type: 'funnel', options: { stageOrder: [...] } }` | unchanged — this is the one type that reads it |
| `{ options: { stageOrder: [...] } }` (no `type`) | `{ type: 'funnel', options: { stageOrder: [...] } }` if a funnel was meant |

⚠️ Deleting the key changes nothing about what renders — the widget was already ignoring it. `sortBy` / `sortOrder` are what change it, and unlike a category order they lower into the dataset query as `order: { <name>: 'asc' | 'desc' }` rather than re-sorting what it returned.

## What the gate does NOT cover

Stated so the change is not read as complete:

- ⚠️ **objectui's client-side authoring door.** This refusal is the **publish** door's, not the editor's. `@object-ui/types` builds its own `DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping every object-level check — measured here: `z.strictObject(DashboardWidgetSchema.shape)` accepts a `horizontal-bar` carrying `stageOrder` and reports zero checks, while `.extend({})` keeps the refusal. At the pinned `.objectui-sha` that package re-attaches none of this spec's exported checks, so until it imports and chains `checkDashboardWidgetStageOrder` the dashboard editor keeps accepting the key on a `bar`. That mirror also redeclares `type` as optional with no default, so a typeless widget would reach a re-attached check as `undefined` rather than as `metric`; the exported check defaults it itself for exactly that caller, so re-attaching is sufficient.
- **A widget whose `type` is outside `ChartTypeSchema`.** zod treats that `invalid_value` as aborting and skips object-level checks for the input, so `type: 'ziggurat'` plus a `stageOrder` reports the type refusal alone. The author fixes the type, re-parses, and meets this refusal then; the two are never seen together. Pinned.
- **A widget that declares no `type`.** `type` carries `.default('metric')` and zod applies defaults before object-level checks, so an omitted `type` is indistinguishable here from an authored `metric`. The verdict is right either way — `metric` reads the key no more than `horizontal-bar` does — and that one case carries an extra sentence pointing at the missing `type` rather than a wrong one.
- **The array's contents.** Still unconstrained `string | number | boolean` members, unmatched against the dimension's picklist. A `funnel` carrying a misspelled stage parses and renders that stage in the sentinel position; whether a stored value exists is a fact about the dataset, not about the widget.
- **Consumers that derive this schema with `.omit()` / `.pick()` / `.partial()`.** zod 4 throws on all three once an object carries a refinement, so this change converts those three from working to throwing. Latent rather than live — no consumer in either repo derives the widget schema that way today — and `.extend()` is unaffected.

## The siblings, measured and deliberately not touched

`stageOrder` was the only member of that bag with this shape. `dateGranularity`, `sortBy`, `sortOrder` and `limit` are read unconditionally at the top of `DatasetWidget` (lines 443–455, outside every type branch) and lower into the `DatasetSelection` the server compiles, so they act on every widget type.

## The other arm, deliberately not taken

The card offered either/or: gate the key, **or** teach the ordered marks (`bar` / `column` / `horizontal-bar` / `line` / `area`) to honour it. The second is a renderer change in `objectstack-ai/objectui` and not this repo's to make. The asymmetry also favours gating: a narrowing that is later relaxed costs an author nothing, while an accepted-and-inert key costs them a chart that silently says something they did not author.
4 changes: 2 additions & 2 deletions content/docs/references/ui/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ Dashboard header action
| **sortBy** | `string` | optional | Dimension/measure name to order by |
| **sortOrder** | `Enum<'asc' \| 'desc'>` | optional | Sort direction for sortBy |
| **limit** | `integer` | optional | Max rows (applied after ordering) |
| **stageOrder** | `(string \| number \| boolean)[]` | optional | Explicit stage order for a funnel widget, as the dimension's stored values. `funnel` is the only widget type that reads it: on any other type the key parses and is never consulted, so order those with sortBy/sortOrder instead. There is no `pyramid` widget type — write `funnel`. |
| **stageOrder** | `(string \| number \| boolean)[]` | optional | Explicit stage order for a funnel widget, as the dimension's stored values. `funnel` is the only widget type that reads it, and the schema refuses it on any other type rather than accepting an order nothing consults — order those with sortBy/sortOrder instead. There is no `pyramid` widget type — write `funnel`. |


---
Expand All @@ -263,7 +263,7 @@ Widget configuration — declared query keys + open renderer extras
| **sortBy** | `string` | optional | Dimension/measure name to order by |
| **sortOrder** | `Enum<'asc' \| 'desc'>` | optional | Sort direction for sortBy |
| **limit** | `integer` | optional | Max rows (applied after ordering) |
| **stageOrder** | `(string \| number \| boolean)[]` | optional | Explicit stage order for a funnel widget, as the dimension's stored values. `funnel` is the only widget type that reads it: on any other type the key parses and is never consulted, so order those with sortBy/sortOrder instead. There is no `pyramid` widget type — write `funnel`. |
| **stageOrder** | `(string \| number \| boolean)[]` | optional | Explicit stage order for a funnel widget, as the dimension's stored values. `funnel` is the only widget type that reads it, and the schema refuses it on any other type rather than accepting an order nothing consults — order those with sortBy/sortOrder instead. There is no `pyramid` widget type — write `funnel`. |


---
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@
"chartAggregateCategoryKey (function)",
"chartAggregateResultKeys (function)",
"chartAggregateValueKey (function)",
"checkDashboardWidgetStageOrder (function)",
"checkGlobalFilterDateDefaultValue (function)",
"checkListViewCalendarVisualization (function)",
"checkListViewPageMount (function)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@
"chartAggregateCategoryKey": "src/ui/chart-aggregate.ts#chartAggregateCategoryKey (function)",
"chartAggregateResultKeys": "src/ui/chart-aggregate.ts#chartAggregateResultKeys (function)",
"chartAggregateValueKey": "src/ui/chart-aggregate.ts#chartAggregateValueKey (function)",
"checkDashboardWidgetStageOrder": "src/ui/dashboard.zod.ts#checkDashboardWidgetStageOrder (function)",
"checkGlobalFilterDateDefaultValue": "src/ui/dashboard.zod.ts#checkGlobalFilterDateDefaultValue (function)",
"checkListViewCalendarVisualization": "src/ui/view.zod.ts#checkListViewCalendarVisualization (function)",
"checkListViewPageMount": "src/ui/view.zod.ts#checkListViewPageMount (function)",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

export const entry: SemanticMigration = {
id: 'dashboard-widget-stage-order-non-funnel-refused',
surface: 'dashboard widget stage order — `dashboard.widgets[].options.stageOrder` '
+ '(`DashboardWidgetOptionsSchema.stageOrder`) on a widget whose `type` is anything '
+ 'other than `funnel`, INCLUDING a widget that declares no `type` at all and so '
+ 'resolves to the `metric` default',
replacement: 'either `type: \'funnel\'` on the widget that meant to declare a stage '
+ 'order, or — for every other widget type — DELETE `stageOrder` and order the widget '
+ 'with `options.sortBy` + `options.sortOrder`, which lower into the dataset query as '
+ '`order: { <name>: \'asc\' | \'desc\' }` instead of re-sorting what it returned. '
+ 'There is no third spelling: no other widget type has ever read the key, so nothing '
+ 'is lost by removing it that was not already absent from what rendered. The refusal '
+ 'lands at `options.stageOrder` and names the type the widget carries, the one type '
+ 'that reads the key, and the two keys to reach for instead.',
reason:
'#17344 finding 1, ADR-0049 enforce-or-remove, and the enforce arm of a defect whose '
+ 'whole content was SILENCE. `options` is the open renderer-extras bag, so '
+ '`stageOrder` was an ungated member of it: a `horizontal-bar` (or `line`, `pie`, '
+ '`table`, `metric`) widget carrying an authored lifecycle order PARSED, booted, and '
+ 'forwarded the array to the renderer, which never consulted it. Measured at this '
+ 'repo\'s `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694`: the forwarded '
+ '`categoryOrder` prop has exactly one read in the charts plugin '
+ '(`buildCategoryRank(categoryOrder)`, `AdvancedChartImpl.tsx:1514`) and it sits '
+ 'inside the `chartType === \'funnel\'` guard opened at line 1473; the prop\'s other '
+ 'two occurrences in that file are its declaration and its destructure. The producer '
+ 'side has no gate either — `DatasetWidget.tsx:1468` builds the explicit order for '
+ 'ANY widget and forwards it whenever non-empty. So the authored order was accepted '
+ 'by the metadata layer, carried all the way to the chart, and dropped there, with '
+ 'nothing anywhere to say so: the widget rendered in whatever order the analytics '
+ 'query returned and looked deliberate. The reporter measured exactly that in a live '
+ 'app — a `horizontal-bar` carrying a seven-stage contract lifecycle rendered '
+ 'alphabetically by display label. The four SIBLING members of the same bag are not '
+ 'in this narrowing and were measured not to share the defect: `dateGranularity`, '
+ '`sortBy`, `sortOrder` and `limit` are read unconditionally at the top of '
+ '`DatasetWidget` (lines 443-455, outside every type branch) and lower into the '
+ '`DatasetSelection` the server compiles, so they act on every widget type. '
+ '`stageOrder` was the only member whose effect was confined to one branch. ⛔ NOT '
+ 'the other arm of the card ("or ordered marks honour it"): teaching `bar` / `line` / '
+ '`area` to sort by a category order is a renderer change in the objectui repo, and '
+ 'widening the set of types that read the key can be done later WITHOUT a second '
+ 'migration — a narrowing that is later relaxed costs an author nothing, while '
+ 'leaving the key accepted-and-inert costs them a chart that silently lies. Ships at '
+ 'once, no deprecation window: there is no window in which an inert key does '
+ 'anything.',
acceptanceCriteria:
'⚠️ WHICH DOOR: this refusal is the PUBLISH door\'s, not the editor\'s. Every stored '
+ 'dashboard whose widgets carry `options.stageOrder` on a non-`funnel` type is refused '
+ 'the next time it is parsed THROUGH `@objectstack/spec` — `os build` / `os lint`, the '
+ 'metadata publish path, and any server-side door that parses the spec schema — with one '
+ '`custom` issue at `widgets[N].options.stageOrder` naming the authored type. It is NOT '
+ 'refused by objectui\'s client-side authoring door: `@object-ui/types` builds its own '
+ '`DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, '
+ '…).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping every '
+ 'object-level check (measured: `z.strictObject(DashboardWidgetSchema.shape)` accepts the '
+ 'widget and reports zero checks, while `.extend({})` keeps the refusal). At the '
+ '`.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` that package re-attaches NONE '
+ 'of the spec\'s exported checks, so until it imports and chains '
+ '`checkDashboardWidgetStageOrder` the dashboard EDITOR still accepts the key on a `bar` '
+ 'and the author meets the refusal later, at publish. ⇒ Do not read a green editor as a '
+ 'clean dashboard; re-parse through the spec. Fix each by writing '
+ '`type: \'funnel\'` where a funnel was meant, and by deleting the key elsewhere — '
+ 'check the rendered order afterwards, because a widget that was silently ignoring '
+ 'the key renders EXACTLY as it did before once the key is gone, and `sortBy` / '
+ '`sortOrder` is what changes it. A `funnel` widget carrying `stageOrder` parses '
+ 'byte-identically to before, a non-`funnel` widget carrying the other four '
+ '`options` members is untouched, and a widget with no `options` at all is '
+ 'untouched. ⚠️ Three more shapes this does NOT reach, so do not read it as complete '
+ '(the objectui door above is the first): a '
+ 'widget whose `type` is outside `ChartTypeSchema` reports the TYPE refusal alone '
+ '(zod treats that as aborting and skips object-level checks), so the stage-order '
+ 'refusal arrives only on the next parse; and the array\'s CONTENTS are still '
+ 'unconstrained, so a `funnel` carrying a stage value the dimension never declares '
+ 'still parses and still renders that stage in the sentinel position; and a consumer '
+ 'that derives this schema with `.omit()` / `.pick()` / `.partial()` now gets a THROW '
+ 'from zod rather than a schema, because zod 4 refuses all three on an object carrying '
+ 'a refinement — latent rather than live (no consumer in either repo derives the widget '
+ 'schema that way today), and `.extend()` is unaffected. Repo census at '
+ 'the time of the change: zero authored widgets carry the key anywhere in the '
+ 'monorepo — 59 occurrences outside changelogs, all of them schema, tests, generated '
+ 'reference pages, the sdui-parser census and the gate that derives it.',
};
Loading
Loading