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
67 changes: 67 additions & 0 deletions .changeset/page-component-zero-reader-keys-removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
"@objectstack/spec": major
---

refactor(spec)!: retire three SDUI page-component props no renderer honours — `page:header.icon`, `page:card.actions`, `record:details.layout` (#6946)

Maintainer ruling 2026-08-09 (decision-inbox round, 「全部接受」): objectui#3829
route (c) retires `PageHeaderProps.icon` and `PageCardProps.actions` upstream;
objectui#3818 retires `RecordDetailsProps.layout`. All three are the ADR-0049
declared-but-unenforced shape on the platform contract — the spec advertised
configuration objectui drops on the floor, and the author got a success receipt
for it.

FROM → TO, per key:

- **`page:header.icon` → delete the key.** objectui resolves `icon` only per
header ACTION (`action.icon`); the header's own props bag is never asked for
one, and the component registration publishes no `icon` input. The header's
own identity is drawn by the record chrome (`recordChrome`, on by default),
and each action carries its own icon.
- **`page:card.actions` → author the buttons as components in `children` or
`footer`.** The card renderer builds its `<Card>` from `title`, `bordered`,
`children` and `footer`; there is no actions area in the markup and no
`actions` input in the registration. `element:button` and
`record:quick_actions` are what actually render.
- **`record:details.layout` → delete the key; `sections` already decides the
body.** This one WAS read — and only against `inline` | `compact`, two values
its `auto` | `custom` enum never permitted, so both legal values took the same
branch and the key selected nothing. Authoring `sections` gives the explicit
groups (the old `custom`); omitting it falls back to the object's
`highlightFields` (the old `auto`).

⚠️ Two live keys share these leaf names and are UNTOUCHED: `page:header.actions`
(read by the header renderer) and `record:highlights.layout` (`horizontal` |
`vertical`, honoured). Every strip here is scoped by component `type`, never by
key name.

The retirement kit:

- **Tombstones** (`retiredKey()`) on all three, so the removal is audible in the
two channels an upgrading author actually hits: the input type becomes `never`
and the parse raises the prescription itself, not a generic "unrecognized key".
- **ADR-0087 D2 conversions + D3 chain steps** —
`page-structure-inert-keys-removed` (objectui#3829's two keys) and
`record-details-layout-removed` (objectui#3818's). Both are pure lossless
strips: none of the three ever had an effect to lose, and none has a lossless
rewrite target. `os migrate meta --from 16` rewrites existing sources.
- **`RETIRED_KEYS_BY_MAJOR[17]`** carries all three by exact key
(`ui/PageHeaderProps:icon`, `ui/PageCardProps:actions`,
`ui/RecordDetailsProps:layout`).
- Four in-repo pages stop authoring `page:header.icon` — the showcase's
project workspace and the published `mcp` / `cloud-connection` platform pages.
None of them ever drew an icon.
- Baselines regenerated: `authorable-surface/ui.json` gains three `[RETIRED]`
marks. `api-surface/`, `api-surface-signatures.json` and
`json-schema.manifest/` are byte-identical, which is the correct reading for
the tombstone route — those ratchets record export and def EXISTENCE, and a
tombstone narrows a def's keys without deleting the def.

No runtime behaviour changes — that impossibility is the reason for the removal.

Sequencing: objectui#3829 (drop the two parity-gate exemptions) and
objectui#3818 (delete the `layout` input and the dead `inline|compact` branch)
are Blocked-by #6946 and proceed on the next `.objectui-sha` pin bump.

<!-- adr-0087: registered page-structure-inert-keys-removed, record-details-layout-removed -->

6 changes: 3 additions & 3 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ const result = AIChatWindowProps.parse(data);
| :--- | :--- | :--- | :--- |
| **title** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **bordered** | `boolean` | ✅ | |
| **actions** | `string[]` | optional | |
| **actions** | `never` | optional | [REMOVED] `page:card` property `actions` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — no renderer ever read it: objectui's card renderer builds its `<Card>` from `title`, `bordered`, `children` and `footer` only, has no actions area, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key and author the buttons as components in the card's `children` or `footer` (`element:button`, `record:quick_actions`), which is what actually renders. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
| **children** | `any[]` | optional | Card content components, in order (the card body slot) |
| **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 existing sources automatically. |
| **footer** | `any[]` | optional | Card footer components (slot) |
Expand Down Expand Up @@ -243,7 +243,7 @@ const result = AIChatWindowProps.parse(data);
| :--- | :--- | :--- | :--- |
| **title** | `string \| Record<string, string>` | ✅ | Page title |
| **subtitle** | `string \| Record<string, string>` | optional | Page subtitle |
| **icon** | `string` | optional | Icon name |
| **icon** | `never` | optional | [REMOVED] `page:header` property `icon` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — no renderer ever read it: objectui resolves `icon` only per header action (`action.icon`), never off the header's own props bag, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key. The header's own identity is drawn by the record chrome (`recordChrome`, on by default) and each action carries its own `icon`. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
| **breadcrumb** | `boolean` | ✅ | Show breadcrumb |
| **actions** | `string[]` | optional | Action IDs to show in header |
| **recordChrome** | `boolean` | ✅ | Render the record chrome — the title as a record chip with its follow star and copy-id button. Set false on a non-record page (dashboard, landing) to fall back to the bare heading layout. |
Expand Down Expand Up @@ -314,7 +314,7 @@ const result = AIChatWindowProps.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | ✅ | Number of columns for field layout (1-4) |
| **layout** | `Enum<'auto' \| 'custom'>` | ✅ | Layout mode: auto uses object highlightFields, custom uses explicit sections |
| **layout** | `never` | optional | [REMOVED] `record:details` property `layout` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — its declared `auto` \| `custom` semantics were never implemented: the renderer tests `layout` only against `inline` \| `compact`, two values the schema never permitted, so both legal values took the same branch and the key selected nothing. Delete the key — the body is already chosen by what you author: `sections` renders the explicit groups (the old `custom`), and omitting it falls back to the object's `highlightFields` (the old `auto`). Run `os migrate meta --from 16` to rewrite existing sources automatically. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; fields: string[] }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields }`. |
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
| **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) |
Expand Down
6 changes: 6 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,10 @@ Last, it reconciles the SDUI component-props surface with the renderers that ser

That count turned out to be incomplete, and #6776 finishes it: five more keys the renderers read were still undeclared. Four are plain additions with no behaviour change (`page:header` `recordChrome`/`showStar`/`showCopyId`, which select between the record-chip header and the bare heading a dashboard wants, and `page:accordion.variant`, which decides whether the accordion draws its own dividers or leaves the border to each panel). The fifth is a rename, and the only one in the family whose defect is structural rather than an oversight: the tab strip's visual style was declared as `page:tabs.type`, which collides with the page component's OWN dispatch key. objectui's `SchemaRenderer` refuses to hoist `properties.type` for exactly that reason, `sdui-parser`'s `BASE_PROPS` contains `type` and skips it before any validation runs, and in a flat or JSX carrier the node reads `{ type: 'page:tabs', … }` so the name is already taken. The key was therefore unauthorable in every carrier but the nested `properties` object, and unvalidated even there. It becomes `tabStyle` — the spelling objectui publishes and the renderer already reads first in the flat carriers — which is `displayField` → `labelField` again: converge on the spelling that works, not the one that declares well, and keep one spelling rather than two (Prime Directive #12).

#6946 closes that reconciliation from the other side, on three keys the two earlier passes left standing (maintainer ruling 2026-08-09, decision-inbox round: objectui#3829 route (c) and objectui#3818). Two are the plain B class — declared here, read NOWHERE. `page:header.icon` is resolved by objectui only per header ACTION (`action.icon`); the header's own props bag is never asked for one, and `@object-ui/layout`'s `<PageHeader>` takes an `icon` React prop from a host with no schema fallback beside the `schema?.actions ?? schema?.properties?.actions` fallback four lines away. `page:card.actions` has no actions area to render into at all: the card renderer builds its `<Card>` from `title`, `bordered`, `children` and `footer`, full stop. Both sat in objectui's own unpublished-exemption map as "spec declares it, NO renderer read point", which is what put the contract decision — wire it, publish it with a KNOWN GAP marker, or retire it — in front of the maintainer; the ruling retired it. Neither has a lossless rewrite target (a header has no second icon slot, and moving a card's action ids into `children` as components is a page rewrite, not a mechanical one), so both are pure strips. ⚠️ `page:header.actions` is LIVE and untouched — the strip is scoped by component type, never by key name.

The third, `record:details.layout`, is a sharper shape and the one worth reading twice: it IS read. The renderer computes `schema.layout === 'inline' || schema.layout === 'compact' ? 'horizontal' : 'vertical'`, while the declared enum is `auto | custom` — so neither legal value can match, both take the same branch, and a key that was accepted and read still selected nothing, under a `.describe()` promising "auto uses object highlightFields, custom uses explicit sections". The behaviour that prose describes is real, but the renderer keys it off whether `sections` was authored, never off this flag. Every gate stayed green because `check:react-declaration-parity` compares two DECLARATIONS and objectui declared the same `auto | custom` enum — perfect agreement over a key nothing honoured — while a THIRD spelling (`stacked | inline | compact`) sat in `@object-ui/types`' mirror. A pure strip for the same reason: `auto`, `custom` and omission were behaviourally identical, so there is no value to carry. ⚠️ `record:highlights.layout` is a different, live, honoured key and is untouched. objectui#3829 and objectui#3818 drop the exemptions, the input and the dead branch on the next pin bump.

Finally it narrows the aggregation vocabulary: `array_agg` and `string_agg` leave `AggregationFunction` (#6188, ADR-0049). The enum declared eight functions and the SQL family compiles five — `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` each lower `count`/`sum`/`avg`/`min`/`max` and route the rest to one refusal — so three were declared-but-unenforced against the backends this platform targets. What makes these two worse than an ordinary inert declaration is that another package had to carry a denylist for them: `service-analytics` subtracted `array_agg` and `string_agg` by name in `UNSUPPORTED_AGGREGATES`, because without that subtraction they reached the Cube strategy's `default` and returned `COUNT(*)` — a row count in place of the requested value, with no error and no log. The maintainer SPLIT the three rather than retiring them as a block (2026-08-07), and the split is the point: `count_distinct` STAYS and takes the enforce leg — one portable lowering (`COUNT(DISTINCT x)`), a dashboard staple, already lowered by `service-analytics` — with its SQL implementation following on its own card, so that declaration leads its implementation by decision rather than by drift. These two take the remove leg: display conveniences with no measured pull, and `string_agg` never had one shape to lower to (the delimiter is a second argument in PostgreSQL, a `SEPARATOR` clause in MySQL, a differently named function in SQL Server). This is an enum VALUE, not a key, so — as with `crypto.hash` above — there is no `retiredKey()` tombstone: the enum error map carries the prescription, keyed on the received value so only the two spellings that used to be legal are told they "were removed". Of the two authoring surfaces only one is stored metadata: the conversion rewrites `dataset.measures[].aggregate`, dropping the measure outright (a measure with neither `aggregate` nor `derived` fails the dataset's own refinement, so stripping just the key would emit an item that cannot parse) plus any derived measure the drop strands, with a notice each. Nothing is lost: `compileDataset` refused both by name already, so such a measure never produced a number. `QueryAST.aggregations[].function` is a request surface with no stored source — one semantic TODO below. The mongodb and in-memory backends that implemented these two are inside the #5499 freeze and are untouched; their code is simply no longer reachable through a spec-valid request.

The same aggregation node loses one more member, and it is the sharper class of the two: `aggregations[].distinct` is removed (#6815, ADR-0049, maintainer ruling 2026-08-09). The functions above were declared and UNLOWERED — a caller on a SQL datasource got a refusal. This flag was declared and lowered by exactly ONE of the six faces that read an aggregation: the engine's in-memory fallback deduplicated the values before applying the function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s `computeAggregate` and service-analytics' `AGGREGATE_SQL` all ignored it. So the same query answered a deduplicated `sum` on the fallback path and an ordinary `sum` on every SQL datasource, with the engine choosing between the two per query — by driver, by a non-UTC date bucket, by whether the driver aggregates natively at all. That is the divergence class #6203 and #5907 each closed on this axis, still open on this key, and it is worse to sit on because the wrong answer is a PLAUSIBLE NUMBER rather than a refusal: no error, no log, nothing for a dashboard author to notice. It survived the #4286 sweep of this very schema because that sweep asked which members no executor reads, and this one had a reader — the wrong question for a key whose defect is WHICH executor reads it. Remove rather than enforce, per the ruling: `count_distinct` (which just took the enforce leg above, and whose SQL lowering #6409 landed) already covers the only deduplicating spelling with measured demand, while `SUM(DISTINCT …)` / `AVG(DISTINCT …)` are near-universally a modelling mistake and would have to be lowered across five faces, two of them frozen under #5499, to buy it. The blast radius inside the fallback is narrower than the key suggests and was measured rather than assumed: only `sum` and `avg` ever changed answer — `count` returned from its own branch before reaching the dedupe, `count_distinct` fed a Set, and dedupe does not move `min`/`max`. `AggregationNodeSchema` is non-strict, so the key is `retiredKey()`-tombstoned rather than bare-deleted: a plain deletion would have made zod silently STRIP what callers still send, trading a divergent flag for an ignored one (#3733, ADR-0104). One tombstone covers every aggregation door, because `QuerySchema.aggregations` and `EngineAggregateOptionsSchema.aggregations` reuse that one schema by reference. No conversion: a request surface with no stored source — one semantic TODO below, the disposition every other `data.query.*` retirement in this major already takes.
Expand Down Expand Up @@ -286,6 +290,8 @@ The same descriptor loses a key in this step, and the pairing is the point (#674
| `page-card-body-to-children` | `page.component.page:card.body` | page:card component prop 'body' → 'children' (#5775 — one composition key across every container; the card renderer already reads both) | retired — `migrate meta` only |
| `inline-action-api-params-to-body-extra` | `page.component.element:button.action.params` | inline type:'api' action prop 'params' (object form) → 'bodyExtra' (#5777 — the payload gets its own key; `params` stays the ActionParam[] definition array) | live — protocol 17 loader accepts the old shape |
| `page-tabs-type-to-tab-style` | `page.component.page:tabs.type` | page:tabs component prop 'type' → 'tabStyle' (#6776 — a props key named `type` collides with the node's dispatch key and is unauthorable in flat/JSX carriers; `tabStyle` is the spelling the renderer reads in all of them) | retired — `migrate meta` only |
| `page-structure-inert-keys-removed` | `page.component.page:header.icon / page.component.page:card.actions` | page:header prop 'icon' and page:card prop 'actions' removed (#6946 — neither has a renderer read point in objectui; the header resolves icons per action and the card renders title/children/footer only) | retired — `migrate meta` only |
| `record-details-layout-removed` | `page.component.record:details.layout` | record:details component prop 'layout' removed (#6946 — the declared auto|custom modes were never implemented; the renderer branches only on inline|compact, values the schema never permitted, so both legal values selected nothing) | retired — `migrate meta` only |
| `app-hidden-to-unpublished` | `app.hidden` | stored app publish gate 'hidden' → '_unpublished' (#4829, ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched) | retired — `migrate meta` only |

### Semantic (delegated to you, with acceptance criteria)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const ProjectWorkspacePage = definePage({
title: 'New Project + Tasks',
subtitle:
'Master-detail entry — fill the project, add its tasks inline, and save them together.',
icon: 'folder-plus',
// `icon` removed here (#6946): `page:header` never drew one — retired from the spec.
},
},
],
Expand Down
Loading
Loading