Skip to content

Commit 85ec26d

Browse files
qq9340100claude
andauthored
spec: SDUI props — enforce or remove (#5775) (#6281)
* refactor(spec)!: reconcile SDUI component props with their renderers — 4 retired, 9 declared (#5775) #5068 wired the first parse `ComponentPropsMap` ever had, and the corpus it landed on diverged in BOTH directions: keys objectui's renderers honour that the schema never declared, and keys the schema declared — one of them REQUIRED — that no renderer has ever read. Maintainer ruling (2026-08-06), direction A: the #5611 rule again, the delivered and authorized shape is the contract. The sharpest case is the record picker. It required `displayField`, which appears in no renderer; `record-picker.tsx` resolves `props.labelField ?? 'name'` and renders `row[labelField]`. An author who followed the schema and wrote `displayField: 'title'` got a dropdown listing `name`, with a success receipt and no diagnostic anywhere — ADR-0078 exactly. Retired (tombstone + ADR-0087 D2 conversion + RETIRED_KEYS_BY_MAJOR[17]): - `element:record_picker.displayField` → `labelField` (synonym rename) - `page:card.body` → `children` (one composition key, not two) - `element:record_picker.searchFields` (ADR-0049 — no search input exists) - `element:record_picker.multiple` (ADR-0049 — the control is single-select) Declared, because the renderers already honour them — no behaviour change, the schema catching up: `element:record_picker` labelField/valueField/label/ emptyText; `record:path` stages[].terminal; `page:tabs` items[].value and items[].count; `page:card` children; and `children` on page:section / page:footer / page:sidebar, which were declared `EmptyProps` ("zero props") while all three renderers render a child list. Conversions are region-level and `retiredFromLoadPath`, matching `page-header-subtitle-alias`: `PageComponentSchema` declares no children key, so a component nested inside another's `properties` is covered by the tombstone (tsc + the parse) rather than by the walk. Not in scope, deliberately: `page:card.visible` is a component-level visibility predicate written into `properties` and hoisted by the renderer — a page to rewrite onto the ADR-0089 `visibleWhen`, not a key to declare. Every A/B-table premise re-measured against objectui HEAD (7dfbeb7) before acting; all still held. Reverse-verified: restoring `displayField` as live turns `check:authorable-surface` gate (b2) red by name and fails the retirement pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwTBGD3gTnPKWmJLYU9R7T * test(spec): pin `PageContainerProps` as isomorphic for the ADR-0122 phase-2 flip (#5775) `check:spec-parsed-alias` (the phase-1 gate from #6072) refuses a bare `X = z.infer<typeof XSchema>` alias that is neither paired with an `XParsed` nor pinned as isomorphic. `PageContainerProps` is a lone optional array with no default, transform, catch or pipe anywhere in its tree, so `z.input` and `z.infer` coincide and the phase-2 flip of the bare name changes nothing observable — which is the pin route, not the `XParsed` route (a permanent synonym is a name an author can only pick wrongly). `component.zod.ts` had no bare alias until now, so this adds the file's first module import to the registry. It takes the next free M-index rather than the alphabetical slot: those indices are positional identifiers the 720 pin lines reference by number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwTBGD3gTnPKWmJLYU9R7T --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e8f435c commit 85ec26d

19 files changed

Lines changed: 910 additions & 57 deletions
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
refactor(spec)!: reconcile the SDUI component props with the renderers that serve them — 4 keys retired, 9 declared (#5775)
6+
7+
#5068 wired the first parse `ComponentPropsMap` ever had, and the corpus it
8+
landed on diverged in **both** directions: keys objectui's renderers honour that
9+
the schema never declared, and keys the schema declared — one of them
10+
**required** — that no renderer has ever read. Maintainer ruling (2026-08-06),
11+
direction A: the #5611 rule again, *the delivered and authorized shape is the
12+
contract*.
13+
14+
The sharpest case is the record picker. It required `displayField`, which
15+
appears in no renderer; `record-picker.tsx` resolves `props.labelField ?? 'name'`
16+
and renders `row[labelField]`. So an author who followed the schema and wrote
17+
`displayField: 'title'` got a dropdown listing `name`, with a success receipt and
18+
no diagnostic anywhere — ADR-0078 exactly. Two spellings of one concept, of which
19+
only the undeclared one was ever read.
20+
21+
**FROM → TO**
22+
23+
| was | now | fix |
24+
|---|---|---|
25+
| `element:record_picker` `displayField: string` (**required**) | `labelField?: string` | Rename the key; the value (a field name) is unchanged. Optional now — the renderer defaults to `name`. |
26+
| `element:record_picker` `searchFields?: string[]` | *(removed)* | Delete the key. Use `filter` / `dataSource.filter` to restrict what the picker offers. |
27+
| `element:record_picker` `multiple?: boolean` | *(removed)* | Delete the key. Multi-record selection is not implemented on this element. |
28+
| `page:card` `body?: Component[]` | `children?: Component[]` | Rename the key; the value is unchanged. `footer` is a distinct slot and is untouched. |
29+
30+
`searchFields` and `multiple` go under ADR-0049 enforce-or-remove: the control is
31+
a single-select `Select` with no search input, binding **one** record id into a
32+
page variable — so `searchFields` narrowed nothing and `multiple: true` selected
33+
nothing extra while reporting success. Either returns the day the capability is
34+
implemented (#5021 / #4988 precedent); a declaration is not a roadmap.
35+
36+
Newly **declared**, because the renderers already honour them (nine keys, no
37+
behaviour change — this is the schema catching up):
38+
`element:record_picker` `labelField` / `valueField` / `label` / `emptyText`;
39+
`record:path` `stages[].terminal` (`'won' | 'lost'`, honoured ahead of the
40+
renderer's value/label token heuristic); `page:tabs` `items[].value` (the stable
41+
`?tab=` URL token) and `items[].count`; `page:card` `children`; and `children` on
42+
`page:section` / `page:footer` / `page:sidebar`, which were declared `EmptyProps`
43+
— "zero props" — while all three renderers render a child list.
44+
45+
The retirement kit:
46+
47+
- Four `retiredKey()` tombstones in `ui/component.zod.ts`, each carrying its own
48+
prescription. `ComponentPropsMap`'s entries STRIP, so a bare deletion would
49+
have replaced one silent no-op with another; a tombstone types the key `never`
50+
(tsc at the authoring site) and raises the prescription at parse time.
51+
- **ADR-0087 D2 conversions + D3 chain step**
52+
`record-picker-display-field-to-label-field`, `record-picker-inert-keys-removed`,
53+
`page-card-body-to-children`, all `retiredFromLoadPath`, so `os migrate meta`
54+
rewrites sources and the loader stays loud. Region level is the reach, as for
55+
`page-header-subtitle-alias`: `PageComponentSchema` declares no children key, so
56+
a component nested inside another's `properties` is covered by the tombstone
57+
rather than the walk.
58+
- `RETIRED_KEYS_BY_MAJOR[17]` entries for all four keys; baselines
59+
(`authorable-surface/ui.json`, `json-schema.manifest/ui.json`, `api-surface/ui.json`)
60+
and reference docs regenerated.
61+
- Pins both ways: the prescription is asserted per key, and a clean parse is
62+
asserted not to materialize any of them.
63+
64+
Not in scope, deliberately: `page:card.visible` is a component-level visibility
65+
predicate written into `properties` and hoisted by the renderer — a page to
66+
rewrite onto the ADR-0089 `visibleWhen`, not a key to declare.
67+
68+
No runtime behaviour changes. The renderers already read the declared spelling of
69+
every key, and the four retired ones never had an effect to lose.

content/docs/references/index.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Protocol Reference
3-
description: Every schema published by @objectstack/spec — 1610 schemas across 14 protocol modules
3+
description: Every schema published by @objectstack/spec — 1611 schemas across 14 protocol modules
44
---
55

66
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -32,8 +32,8 @@ counts are sums of the rows they head. Regenerate with
3232
| [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
3333
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
3434
| [System Protocol](/docs/references/system) | 37 | 295 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
35-
| [UI Protocol](/docs/references/ui) | 17 | 155 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
36-
| **Total** | **201** | **1610** | 14 protocol modules |
35+
| [UI Protocol](/docs/references/ui) | 17 | 156 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
36+
| **Total** | **201** | **1611** | 14 protocol modules |
3737

3838
---
3939

@@ -366,7 +366,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a
366366

367367
## UI Protocol
368368

369-
**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **17 pages, 155 schemas**
369+
**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **17 pages, 156 schemas**
370370

371371
Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer.
372372

@@ -377,7 +377,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay
377377
| [`app.zod.ts`](/docs/references/ui/app) | `ActionNavItem`, `App`, `AppBranding`, `AppContextSelector`, `ComponentNavItem`, `DashboardNavItem`, `GroupNavItem`, `NavigationArea`, `NavigationContribution`, `NavigationItem`, `ObjectNavItem`, `PageNavItem`, `ReportNavItem`, `UrlNavItem` |
378378
| [`bulk-action.zod.ts`](/docs/references/ui/bulk-action) | `BulkActionDef`, `BulkActionExecution`, `BulkActionOperation`, `BulkActionParam` |
379379
| [`chart.zod.ts`](/docs/references/ui/chart) | `ChartAggregate`, `ChartAggregateFunction`, `ChartAnnotation`, `ChartAxis`, `ChartConfig`, `ChartDrillDown`, `ChartGroupBy`, `ChartInteraction`, `ChartSeries`, `ChartType` |
380-
| [`component.zod.ts`](/docs/references/ui/component) | `AIChatWindowProps`, `ElementButtonProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementTextInputProps`, `ElementTextProps`, `PageAccordionProps`, `PageCardProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordPathProps`, `RecordRelatedListProps` |
380+
| [`component.zod.ts`](/docs/references/ui/component) | `AIChatWindowProps`, `ElementButtonProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementTextInputProps`, `ElementTextProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordPathProps`, `RecordRelatedListProps` |
381381
| [`dashboard.zod.ts`](/docs/references/ui/dashboard) | `Dashboard`, `DashboardHeader`, `DashboardHeaderAction`, `DashboardWidget`, `DashboardWidgetOptions`, `GlobalFilter`, `GlobalFilterOptionsFrom`, `WidgetActionType`, `WidgetColorVariant` |
382382
| [`dataset.zod.ts`](/docs/references/ui/dataset) | `Dataset`, `DatasetDimension`, `DatasetMeasure`, `DerivedMeasureOp` |
383383
| [`i18n.zod.ts`](/docs/references/ui/i18n) | `AriaProps`, `DateFormat`, `I18nLabel`, `I18nObject`, `LocaleConfig`, `NumberFormat`, `PluralRule` |

content/docs/references/ui/component.mdx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ Empty Properties Schema
1414
## TypeScript Usage
1515

1616
```typescript
17-
import { AIChatWindowProps, ElementButtonPropsSchema, ElementFilterPropsSchema, ElementFormPropsSchema, ElementImagePropsSchema, ElementMetadataViewerPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementTextInputPropsSchema, ElementTextPropsSchema, PageAccordionProps, PageCardProps, PageHeaderProps, PageTabsProps, RecordActivityProps, RecordChatterProps, RecordDetailsProps, RecordHighlightsField, RecordHighlightsProps, RecordPathProps, RecordRelatedListProps } from '@objectstack/spec/ui';
17+
import { AIChatWindowProps, ElementButtonPropsSchema, ElementFilterPropsSchema, ElementFormPropsSchema, ElementImagePropsSchema, ElementMetadataViewerPropsSchema, ElementNumberPropsSchema, ElementRecordPickerPropsSchema, ElementTextInputPropsSchema, ElementTextPropsSchema, PageAccordionProps, PageCardProps, PageContainerProps, PageHeaderProps, PageTabsProps, RecordActivityProps, RecordChatterProps, RecordDetailsProps, RecordHighlightsField, RecordHighlightsProps, RecordPathProps, RecordRelatedListProps } from '@objectstack/spec/ui';
18+
import type { PageContainerProps } from '@objectstack/spec/ui';
1819

1920
// Validate data
2021
const result = AIChatWindowProps.parse(data);
@@ -142,12 +143,16 @@ const result = AIChatWindowProps.parse(data);
142143
| Property | Type | Required | Description |
143144
| :--- | :--- | :--- | :--- |
144145
| **object** | `string` || Object to pick records from |
145-
| **displayField** | `string` || Field to display as the record label |
146-
| **searchFields** | `string[]` | optional | Fields to search against |
146+
| **labelField** | `string` | optional | Field rendered as each row's text (default `name`) |
147+
| **valueField** | `string` | optional | Field whose value is written into the bound page variable (default `id`) |
148+
| **label** | `string` | optional | Control label rendered above the select |
147149
| **filter** | `any` | optional | Filter criteria for available records |
148-
| **multiple** | `boolean` || Allow multiple record selection |
149150
| **targetVariable** | `string` | optional | Page variable to bind selected record ID(s) |
150151
| **placeholder** | `string` | optional | Placeholder text |
152+
| **emptyText** | `string` | optional | Text shown when the query returns no records (default "No records") |
153+
| **displayField** | `never` | optional | [REMOVED] `element:record_picker` property `displayField` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0087 D2) — it was a required declaration no renderer ever read, while the renderer honoured `labelField` for the same thing and defaulted to `name`. Rename the key to `labelField`; the value (a field name) is unchanged. Run `os migrate meta --from 16` to rewrite it automatically. |
154+
| **searchFields** | `never` | optional | [REMOVED] `element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0049) — the picker renders a plain single-select with no search input, so no renderer ever read it and it narrowed nothing. Delete the key. To restrict which records the picker offers, use `filter` (or the component-level `dataSource.filter`), which the query path does apply. Run `os migrate meta --from 16` to remove it automatically. |
155+
| **multiple** | `never` | optional | [REMOVED] `element:record_picker` property `multiple` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0049) — the picker is a single-select `Select` and the bound page variable holds one record id, so `multiple: true` selected nothing extra and reported success. Delete the key; multi-record selection is not implemented on this element. Run `os migrate meta --from 16` to remove it automatically. |
151156
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
152157

153158

@@ -208,11 +213,23 @@ const result = AIChatWindowProps.parse(data);
208213
| **title** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) |
209214
| **bordered** | `boolean` || |
210215
| **actions** | `string[]` | optional | |
211-
| **body** | `any[]` | optional | Card content components (slot) |
216+
| **children** | `any[]` | optional | Card content components, in order (the card body slot) |
217+
| **body** | `never` | optional | [REMOVED] `page:card` property `body` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0087 D2) — it was a second spelling of the composition slot every other container calls `children`, and the renderer reads both. Rename the key to `children`; the value (an array of child components) is unchanged. Run `os migrate meta --from 16` to rewrite it automatically. |
212218
| **footer** | `any[]` | optional | Card footer components (slot) |
213219
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
214220

215221

222+
---
223+
224+
## PageContainerProps
225+
226+
### Properties
227+
228+
| Property | Type | Required | Description |
229+
| :--- | :--- | :--- | :--- |
230+
| **children** | `any[]` | optional | Child components rendered inside this container, in order |
231+
232+
216233
---
217234

218235
## PageHeaderProps
@@ -239,7 +256,7 @@ const result = AIChatWindowProps.parse(data);
239256
| :--- | :--- | :--- | :--- |
240257
| **type** | `Enum<'line' \| 'card' \| 'pill'>` | optional | |
241258
| **position** | `Enum<'top' \| 'left'>` | optional | |
242-
| **items** | `{ label: string; icon?: string; visibleWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; children: any[] }[]` || |
259+
| **items** | `{ label: string; icon?: string; visibleWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; value?: string; … }[]` || |
243260
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
244261

245262

@@ -350,7 +367,7 @@ Type: `string`
350367
| Property | Type | Required | Description |
351368
| :--- | :--- | :--- | :--- |
352369
| **statusField** | `string` || Field name representing the current status/stage |
353-
| **stages** | `{ value: string; label: string }[]` | optional | Explicit stage definitions (if not using field metadata) |
370+
| **stages** | `{ value: string; label: string; terminal?: Enum<'won' \| 'lost'> }[]` | optional | Explicit stage definitions (if not using field metadata) |
354371
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
355372

356373

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ regenerate.
2121
| Measure | Value |
2222
|---|---|
2323
| Triaged directories | 5 |
24-
| Object sites in them | 456 |
25-
| Still-open (strip) sites | 197 |
24+
| Object sites in them | 457 |
25+
| Still-open (strip) sites | 198 |
2626
| Files carrying at least one | 30 |
2727

2828
Remaining strip sites by class:
2929

3030
| Bucket | Sites |
3131
|---|---|
32-
| authorable — the ruling's forced scope | 42 |
32+
| authorable — the ruling's forced scope | 43 |
3333
| unresolved — needs a per-schema verdict | 33 |
3434
| wire / open — out of forced scope | 107 |
3535
| no door — no carrier, ADR-0049 territory | 14 |
@@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th
4444

4545
| Dir | Sites | strict | passthrough | catchall | strip |
4646
|---|---|---|---|---|---|
47-
| `ui/` | 172 | 116 | 5 | 0 | 51 |
47+
| `ui/` | 173 | 116 | 5 | 0 | 52 |
4848
| `data/` | 162 | 54 | 1 | 0 | 107 |
4949
| `automation/` | 75 | 49 | 0 | 0 | 26 |
5050
| `security/` | 20 | 7 | 0 | 0 | 13 |
5151
| `studio/` | 27 | 27 | 0 | 0 | 0 |
52-
| **total** | **456** | **253** | **6** | **0** | **197** |
52+
| **total** | **457** | **253** | **6** | **0** | **198** |
5353

5454
## File-level triage — site counts
5555

@@ -66,7 +66,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
6666
| `app.zod.ts` | 18 |
6767
| `bulk-action.zod.ts` | 3 |
6868
| `chart.zod.ts` | 8 |
69-
| `component.zod.ts` | 30 |
69+
| `component.zod.ts` | 31 |
7070
| `dashboard.zod.ts` | 11 |
7171
| `dataset.zod.ts` | 4 |
7272
| `i18n.zod.ts` | 6 |
@@ -77,7 +77,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
7777
| `theme.zod.ts` | 6 |
7878
| `view.zod.ts` | 53 |
7979
| `widget.zod.ts` | 9 |
80-
| **total** | **172** |
80+
| **total** | **173** |
8181

8282
### `data/` — sites
8383

@@ -157,22 +157,22 @@ over it is here.
157157

158158
### `ui/` — open
159159

160-
**51 strip of 172**, in 7 file(s).
160+
**52 strip of 173**, in 7 file(s).
161161

162162
| File | Strip | Sites |
163163
|---|---|---|
164164
| `action-params.zod.ts` | 1 | 1 |
165165
| `app.zod.ts` | 1 | 18 |
166166
| `chart.zod.ts` | 2 | 8 |
167-
| `component.zod.ts` | 30 | 30 |
167+
| `component.zod.ts` | 31 | 31 |
168168
| `i18n.zod.ts` | 5 | 6 |
169169
| `view.zod.ts` | 3 | 53 |
170170
| `widget.zod.ts` | 9 | 9 |
171-
| **total** | **51** | **172** |
171+
| **total** | **52** | **173** |
172172

173173
| Bucket | Sites |
174174
|---|---|
175-
| authorable — the ruling's forced scope | 33 |
175+
| authorable — the ruling's forced scope | 34 |
176176
| unresolved — needs a per-schema verdict | 0 |
177177
| wire / open — out of forced scope | 3 |
178178
| no door — no carrier, ADR-0049 territory | 14 |

0 commit comments

Comments
 (0)