Skip to content

Commit 1f0b565

Browse files
os-billclaude
andauthored
fix(spec)!: refuse dashboard.widgets[].options.stageOrder on every widget type that does not read it (#17616)
* fix(spec)!: gate `dashboard.widgets[].options.stageOrder` to the one widget type that reads it WIP checkpoint before the heavy verification run. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> * fix(spec)!: register the stageOrder narrowing and regenerate the reference page The ADR-0087 protocol-18 semantic entry, the regenerated migration registry, the `gen:schema` -> `gen:docs` projection of the changed `.describe()`, and the `minor` changeset carrying the BREAKING banner and the disposition marker. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> * fix(spec)!: backtick the pinned objectui sha, regenerate the export snapshots `check:objectui-pin-citations` refuses a sha a reader cannot find mechanically; `check:api-surface` / `check:export-origins` record the one added export, `checkDashboardWidgetStageOrder`, with nothing removed or narrowed. Two more pins: the door runs the EXPORT (declared once, attached once, by identifier) and `@objectstack/spec/ui` ships that same function object. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> * fix(spec)!: name objectui's `.shape` mirror as a fourth non-coverage, and make the exported check equal the door Contract review, must-fix. The PR claimed that attaching by identifier means "a `.shape` mirror re-attaches the rule rather than a copy". Probed, and it is false as a mechanism: `z.strictObject(DashboardWidgetSchema.shape)` ACCEPTS a `horizontal-bar` carrying `stageOrder` and holds zero object-level checks, while `.extend({})` keeps the refusal — a lit control (`type: 'ziggurat'`) is refused by both, so the mirror carries the fields and it is precisely the check that is dropped. Identifier attachment only makes re-attachment POSSIBLE. That matters because objectui's authoring door IS that mirror (`packages/types/src/zod/complex.zod.ts:627` at the pin) and re-attaches none of the spec's exported checks, so its client-side door keeps accepting the key on a `bar`. Named at all three sites that presented the list as complete: the check's docblock, the changeset, and the migration entry's `acceptanceCriteria`, whose "refused on its next authoring-path save" overstated the door coverage. Two more things the rework surfaced rather than assumed: - Adding the check to `object-refinement-check-exports.test.ts` (the declared increment — the catalogue's population is "every mirrored spec object that carries an object-level check", and this schema is measurably mirrored) turned its parity leg RED: called directly with a raw fixture the export returned early on an omitted `type`, while the door refuses, because the default fires before object-level checks. Repaired at the producer — the check defaults `type` itself — rather than by dropping the fixture. The accept set is unmoved: `parse({...w})` and `parse({...w, type: undefined})` both yield `type: 'metric'`, so the coalesce is unreachable through this door. - zod 4 throws on `.omit()` / `.pick()` / `.partial()` of an object carrying a refinement, so this change converts those three from working to throwing. Latent, not live, and named as a fifth non-coverage. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> * fix(spec): spell the new objectui pin citations in the form the gate reads `check:objectui-pin-citations` refuses a sha in neither recognised spelling: these are historical measurements, so they take ``.objectui-sha` pin `<sha>``, not "the pinned `.objectui-sha` `<sha>`". Three sites, plus the regenerated registry. Gate now reports 12 asserting and 23 historical citations, exit 0. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 155b875 commit 1f0b565

9 files changed

Lines changed: 649 additions & 21 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec)!: `dashboard.widgets[].options.stageOrder` is refused on every widget type that does not read it (#17344, finding 1)
6+
7+
<!-- adr-0087: registered dashboard-widget-stage-order-non-funnel-refused -->
8+
9+
**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`.
10+
11+
## What was wrong
12+
13+
The key never failed. It failed to *order*.
14+
15+
`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.
16+
17+
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.
18+
19+
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.
20+
21+
## What it does now
22+
23+
`DashboardWidgetSchema` carries an object-level check that refuses `stageOrder` unless the widget's `type` is `funnel`.
24+
25+
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.
26+
27+
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.
28+
29+
## FROM → TO
30+
31+
| you wrote | write instead |
32+
| --- | --- |
33+
| `{ type: 'horizontal-bar', options: { stageOrder: [...] } }` | `{ type: 'horizontal-bar', options: { sortBy: 'contract_count', sortOrder: 'desc' } }` |
34+
| `{ type: 'funnel', options: { stageOrder: [...] } }` | unchanged — this is the one type that reads it |
35+
| `{ options: { stageOrder: [...] } }` (no `type`) | `{ type: 'funnel', options: { stageOrder: [...] } }` if a funnel was meant |
36+
37+
⚠️ 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.
38+
39+
## What the gate does NOT cover
40+
41+
Stated so the change is not read as complete:
42+
43+
- ⚠️ **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.
44+
- **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.
45+
- **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.
46+
- **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.
47+
- **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.
48+
49+
## The siblings, measured and deliberately not touched
50+
51+
`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.
52+
53+
## The other arm, deliberately not taken
54+
55+
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.

‎content/docs/references/ui/dashboard.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ Dashboard header action
246246
| **sortBy** | `string` | optional | Dimension/measure name to order by |
247247
| **sortOrder** | `Enum<'asc' \| 'desc'>` | optional | Sort direction for sortBy |
248248
| **limit** | `integer` | optional | Max rows (applied after ordering) |
249-
| **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`. |
249+
| **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`. |
250250

251251

252252
---
@@ -263,7 +263,7 @@ Widget configuration — declared query keys + open renderer extras
263263
| **sortBy** | `string` | optional | Dimension/measure name to order by |
264264
| **sortOrder** | `Enum<'asc' \| 'desc'>` | optional | Sort direction for sortBy |
265265
| **limit** | `integer` | optional | Max rows (applied after ordering) |
266-
| **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`. |
266+
| **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`. |
267267

268268

269269
---

‎packages/spec/api-surface/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,7 @@
434434
"chartAggregateCategoryKey (function)",
435435
"chartAggregateResultKeys (function)",
436436
"chartAggregateValueKey (function)",
437+
"checkDashboardWidgetStageOrder (function)",
437438
"checkGlobalFilterDateDefaultValue (function)",
438439
"checkListViewCalendarVisualization (function)",
439440
"checkPageSourceCompleteness (function)",

‎packages/spec/export-origins/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,7 @@
420420
"chartAggregateCategoryKey": "src/ui/chart-aggregate.ts#chartAggregateCategoryKey (function)",
421421
"chartAggregateResultKeys": "src/ui/chart-aggregate.ts#chartAggregateResultKeys (function)",
422422
"chartAggregateValueKey": "src/ui/chart-aggregate.ts#chartAggregateValueKey (function)",
423+
"checkDashboardWidgetStageOrder": "src/ui/dashboard.zod.ts#checkDashboardWidgetStageOrder (function)",
423424
"checkGlobalFilterDateDefaultValue": "src/ui/dashboard.zod.ts#checkGlobalFilterDateDefaultValue (function)",
424425
"checkListViewCalendarVisualization": "src/ui/view.zod.ts#checkListViewCalendarVisualization (function)",
425426
"checkPageSourceCompleteness": "src/ui/page.zod.ts#checkPageSourceCompleteness (function)",
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'dashboard-widget-stage-order-non-funnel-refused',
7+
surface: 'dashboard widget stage order — `dashboard.widgets[].options.stageOrder` '
8+
+ '(`DashboardWidgetOptionsSchema.stageOrder`) on a widget whose `type` is anything '
9+
+ 'other than `funnel`, INCLUDING a widget that declares no `type` at all and so '
10+
+ 'resolves to the `metric` default',
11+
replacement: 'either `type: \'funnel\'` on the widget that meant to declare a stage '
12+
+ 'order, or — for every other widget type — DELETE `stageOrder` and order the widget '
13+
+ 'with `options.sortBy` + `options.sortOrder`, which lower into the dataset query as '
14+
+ '`order: { <name>: \'asc\' | \'desc\' }` instead of re-sorting what it returned. '
15+
+ 'There is no third spelling: no other widget type has ever read the key, so nothing '
16+
+ 'is lost by removing it that was not already absent from what rendered. The refusal '
17+
+ 'lands at `options.stageOrder` and names the type the widget carries, the one type '
18+
+ 'that reads the key, and the two keys to reach for instead.',
19+
reason:
20+
'#17344 finding 1, ADR-0049 enforce-or-remove, and the enforce arm of a defect whose '
21+
+ 'whole content was SILENCE. `options` is the open renderer-extras bag, so '
22+
+ '`stageOrder` was an ungated member of it: a `horizontal-bar` (or `line`, `pie`, '
23+
+ '`table`, `metric`) widget carrying an authored lifecycle order PARSED, booted, and '
24+
+ 'forwarded the array to the renderer, which never consulted it. Measured at this '
25+
+ 'repo\'s `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694`: the forwarded '
26+
+ '`categoryOrder` prop has exactly one read in the charts plugin '
27+
+ '(`buildCategoryRank(categoryOrder)`, `AdvancedChartImpl.tsx:1514`) and it sits '
28+
+ 'inside the `chartType === \'funnel\'` guard opened at line 1473; the prop\'s other '
29+
+ 'two occurrences in that file are its declaration and its destructure. The producer '
30+
+ 'side has no gate either — `DatasetWidget.tsx:1468` builds the explicit order for '
31+
+ 'ANY widget and forwards it whenever non-empty. So the authored order was accepted '
32+
+ 'by the metadata layer, carried all the way to the chart, and dropped there, with '
33+
+ 'nothing anywhere to say so: the widget rendered in whatever order the analytics '
34+
+ 'query returned and looked deliberate. The reporter measured exactly that in a live '
35+
+ 'app — a `horizontal-bar` carrying a seven-stage contract lifecycle rendered '
36+
+ 'alphabetically by display label. The four SIBLING members of the same bag are not '
37+
+ 'in this narrowing and were measured not to share the defect: `dateGranularity`, '
38+
+ '`sortBy`, `sortOrder` and `limit` are read unconditionally at the top of '
39+
+ '`DatasetWidget` (lines 443-455, outside every type branch) and lower into the '
40+
+ '`DatasetSelection` the server compiles, so they act on every widget type. '
41+
+ '`stageOrder` was the only member whose effect was confined to one branch. ⛔ NOT '
42+
+ 'the other arm of the card ("or ordered marks honour it"): teaching `bar` / `line` / '
43+
+ '`area` to sort by a category order is a renderer change in the objectui repo, and '
44+
+ 'widening the set of types that read the key can be done later WITHOUT a second '
45+
+ 'migration — a narrowing that is later relaxed costs an author nothing, while '
46+
+ 'leaving the key accepted-and-inert costs them a chart that silently lies. Ships at '
47+
+ 'once, no deprecation window: there is no window in which an inert key does '
48+
+ 'anything.',
49+
acceptanceCriteria:
50+
'⚠️ WHICH DOOR: this refusal is the PUBLISH door\'s, not the editor\'s. Every stored '
51+
+ 'dashboard whose widgets carry `options.stageOrder` on a non-`funnel` type is refused '
52+
+ 'the next time it is parsed THROUGH `@objectstack/spec` — `os build` / `os lint`, the '
53+
+ 'metadata publish path, and any server-side door that parses the spec schema — with one '
54+
+ '`custom` issue at `widgets[N].options.stageOrder` naming the authored type. It is NOT '
55+
+ 'refused by objectui\'s client-side authoring door: `@object-ui/types` builds its own '
56+
+ '`DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, '
57+
+ '…).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping every '
58+
+ 'object-level check (measured: `z.strictObject(DashboardWidgetSchema.shape)` accepts the '
59+
+ 'widget and reports zero checks, while `.extend({})` keeps the refusal). At the '
60+
+ '`.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` that package re-attaches NONE '
61+
+ 'of the spec\'s exported checks, so until it imports and chains '
62+
+ '`checkDashboardWidgetStageOrder` the dashboard EDITOR still accepts the key on a `bar` '
63+
+ 'and the author meets the refusal later, at publish. ⇒ Do not read a green editor as a '
64+
+ 'clean dashboard; re-parse through the spec. Fix each by writing '
65+
+ '`type: \'funnel\'` where a funnel was meant, and by deleting the key elsewhere — '
66+
+ 'check the rendered order afterwards, because a widget that was silently ignoring '
67+
+ 'the key renders EXACTLY as it did before once the key is gone, and `sortBy` / '
68+
+ '`sortOrder` is what changes it. A `funnel` widget carrying `stageOrder` parses '
69+
+ 'byte-identically to before, a non-`funnel` widget carrying the other four '
70+
+ '`options` members is untouched, and a widget with no `options` at all is '
71+
+ 'untouched. ⚠️ Three more shapes this does NOT reach, so do not read it as complete '
72+
+ '(the objectui door above is the first): a '
73+
+ 'widget whose `type` is outside `ChartTypeSchema` reports the TYPE refusal alone '
74+
+ '(zod treats that as aborting and skips object-level checks), so the stage-order '
75+
+ 'refusal arrives only on the next parse; and the array\'s CONTENTS are still '
76+
+ 'unconstrained, so a `funnel` carrying a stage value the dimension never declares '
77+
+ 'still parses and still renders that stage in the sentinel position; and a consumer '
78+
+ 'that derives this schema with `.omit()` / `.pick()` / `.partial()` now gets a THROW '
79+
+ 'from zod rather than a schema, because zod 4 refuses all three on an object carrying '
80+
+ 'a refinement — latent rather than live (no consumer in either repo derives the widget '
81+
+ 'schema that way today), and `.extend()` is unaffected. Repo census at '
82+
+ 'the time of the change: zero authored widgets carry the key anywhere in the '
83+
+ 'monorepo — 59 occurrences outside changelogs, all of them schema, tests, generated '
84+
+ 'reference pages, the sdui-parser census and the gate that derives it.',
85+
};

0 commit comments

Comments
 (0)