refactor(spec)!: retire PageHeaderProps.icon, PageCardProps.actions, RecordDetailsProps.layout (#6946) - #7115
Conversation
…RecordDetailsProps.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 SDUI page-component contract. Re-measured against objectui at the `.objectui-sha` pin (09987b68) rather than inherited from the cards: - `page:header.icon` — objectui resolves `icon` only per header ACTION (`action.icon`); the header's own props bag is never asked for one, and the registration publishes no `icon` input. `@object-ui/layout`'s `<PageHeader>` draws an `icon` React prop from a host but gives it no schema fallback, four lines from the `schema?.actions ?? schema?.properties?.actions` fallback that does exist. - `page:card.actions` — the card renderer builds its `<Card>` from `title`, `bordered`, `children` and `footer`; no actions area, no `actions` input. - `record:details.layout` — read, but only against `inline`|`compact`, values its `auto`|`custom` enum never permitted, so both legal values took the same branch. Declared on both sides with the same enum, which is why the declaration-parity ratchet reported agreement over it. The kit: `retiredKey()` tombstones; two ADR-0087 D2 conversions (`page-structure-inert-keys-removed`, `record-details-layout-removed`) wired into the protocol-17 D3 chain step, both pure lossless strips; three exact-key `RETIRED_KEYS_BY_MAJOR[17]` entries; four in-repo pages stop authoring the header icon; pin tests in both directions; baselines, spec-changes and the upgrade guide regenerated. `page:header.actions` and `record:highlights.layout` are LIVE and untouched — every strip is scoped by component type, never by key name. Closes #6946 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SqC353TXUnqdj3VkoTb4N
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 112 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
Closes #6946
The ruling
Maintainer ruling 2026-08-09 (decision-inbox round), quoted verbatim and untranslated:
Applied to the two objectui cards this one dispatches: objectui#3829 route (c) — retire
PageHeaderProps.iconandPageCardProps.actionsupstream — and objectui#3818 — retireRecordDetailsProps.layout. All three are the ADR-0049 declared-but-unenforced shape on the SDUI page-component contract: the spec advertised configuration objectui drops on the floor, and the author got a success receipt for it.Zero-reader self-verification — re-run here, not inherited
The #6926 lesson (a retirement on this same schema family fell to STOP-and-escalate earlier today because an inherited "zero consumers" grep had missed five objectui reads) makes the measurement mine, not the card's. objectui was cloned read-only and checked out at the SHA
.objectui-shapins —09987b680d53801c79f67d969b14e9bb732b8a22(3 879 tracked files) — and the reverse lookup re-run there and in this repo, each scan carrying a control probe that must hit alongside the probe that must not (#4878 method).PageHeaderProps.icon— no read point/icon/insidePageHeaderRenderer(components/src/renderers/layout/containers.tsx:900-1596)page:headeraction.icon(:1322,:1338,:1366,:1386) plus one prose comment — zero read the header's own bag/recordChrome//showStar//subtitle/schema\??\.icon|properties\??\.iconpage:headerstatistic,form:button,action:icon,action:group,action:button,action:menu(2), and one builder setter — none onpage:header/icon/in@object-ui/layout'sPageHeader.tsxiconis a React prop from a host (:117, rendered:224-226) with no schema fallbackschema?.children(:182),schema?.actions(:194),schema?.properties?.actions(:195) — the fallbackiconconspicuously lacks, four lines awayinputsforpage:headercontainers.tsxregistrationicontitle, subtitle, actions, breadcrumb, recordChrome, showStar, showCopyIdobjectui's own
apps/console/src/__tests__/registry-inputs-spec-parity.test.tscarries this as a B-classUNPUBLISHED_EXEMPTIONSentry — an independent measurement of the same fact, taken in the other repo:PageCardProps.actions— no read pointPageCardRendererbody (containers.tsx:666-721)page:cardactionstitle,bordered,body ?? children,footer— noactions, no actions area in the markup/footer/,/bordered/schema\??\.actions|properties\??\.actionspage:cardnav:header-bar,page:header(containers.tsx:987,layout/PageHeader.tsx:194-195),action:group/bar/menu,DetailView,record:quick_actions, one doc comment. Zero onpage:cardinputsforpage:cardcontainers.tsxregistrationactionstitle, bordered, body, footerobjectui's exemption map again:
RecordDetailsProps.layout— read, and inert anywayThis one is not a zero-read-point key, and the report says so rather than rounding it to the family's shape.
plugin-detail/src/renderers/record-details.tsx:104does read it:The declared enum is
auto | custom. Neither legal value can match, so both take the sameverticalbranch — the key was accepted, read, and could not change anything, under a.describe()promising "auto uses object highlightFields, custom uses explicit sections". That behaviour is real, but the renderer keys it off whethersectionswas authored, never off this flag. That is exactly what objectui#3818 names ("delete thelayoutinput + the deadinline|compactbranch"), so the ruling is decided on this fact, not despite it — noliveverdict, no STOP condition.schema\??\.layout|properties\??\.layoutrecord:details, deadrecord-details.tsx:104(the dead branch),plugin-form×6,plugin-view×2, one builder setter — no otherrecord:detailsreadlayout === 'auto' | 'custom'layout === 'vertical' | 'horizontal'plugin-form/plugin-viewhonour their own livelayoutinputsforrecord:detailsplugin-detail/src/index.tsx:274layoutenum: ['auto','custom'], defaultValue: 'auto'— the same enum this spec declares, which is whycheck:react-declaration-parity(two DECLARATIONS, never a declaration vs an implementation) reported agreement over it@object-ui/types/src/record-components.ts:43layout?: 'stacked' | 'inline' | 'compact'— three declarations of one key, none of them the branch the renderer takesFramework side (this repo,
f5a9bc2)Zero semantic readers of all three. The
.iconhits acrosspackages/**are other domains entirely (nav, book, automation entries, connectors, field-group);i18n-resolverwalkspropertiesby key-name convention and passes non-translatable props through untouched (control: it namestitle/subtitle26×). What this repo does have is authors: four pages wrotepage:header.iconand none ever drew one — the showcase's project workspace, and the publishedmcp/cloud-connectionplatform pages. They are cleaned up here.Liveness ledger
No rows exist for these keys, and none should. The gate walks
PageSchema's own shape (liveness/page.json— 17 rows:name,label,icon(the page's nav icon, live and untouched),regions,slots, …);PageComponent.propertiesisz.record(z.string(), z.unknown()), so component props are below the walk and the ledger has never carried one — thepage:card.bodyprecedent (#3809/#5775) added none either. Nothing to add under the tombstone route, nothing orphaned.check:livenessPASS confirms both directions.Implementation
Tombstones (
retiredKey()) on all three, so the removal is audible in the two channels an upgrading author hits: the input type becomesnever, and the parse raises the prescription itself. Each prescription closes with the #6856 house sentence verbatim (Run `os migrate meta --from 16` to rewrite existing sources automatically.) — pinned class-wide byretired-key-migrate-sentence.test.ts, which passes.tscchannel does not reach the page carrier.PageComponent.propertiesis an openz.record(z.string(), z.unknown()), so the four in-repo author sites do not fail to compile — they were found by grep and by theComponentPropsMapparse thatpackages/lint'svalidate-component-propsrule runs (component-props-invalid, advisory). The playbook's "let the tombstone find the callers" leans ontsc; on this surface the lint rule is the channel, and the sweep was done by hand accordingly.Two ADR-0087 D2 conversions, mirroring the two rulings 1:1 rather than merged, because the rationales are genuinely different:
page-structure-inert-keys-removedpage:header.icon,page:card.actions(objectui#3829)childrenas components is a page rewrite, not a mechanical one)record-details-layout-removedrecord:details.layout(objectui#3818)auto,customand omission were behaviourally identical, so there is no value to carryBoth
retiredFromLoadPath: true, both wired intoMIGRATIONS_BY_MAJOR[17].conversionIdswith the step'srationaleextended,toMajormatching the step. Walker reach verified against the currentwalk.ts(reworked by #7034):mapPageComponentsvisitsregions[].components[],slots.NAME(both the single-component and array forms) and everypropertiescontainer nested to any depth — all three keys live oncomponent.propertiesin exactly those positions, and the fixtures exercise the region form, the nested-container form and the named-slot form.Both strips are scoped by component
type, never by key name —page:header.actionsandrecord:highlights.layoutare LIVE keys sharing these leaf names, and both fixtures carry them as in-fixture controls.Registries: three exact-key
RETIRED_KEYS_BY_MAJOR[17]entries (ui/PageCardProps:actions,ui/PageHeaderProps:icon,ui/RecordDetailsProps:layout) — the string gate (b) reads by set membership, which this family needs literally given the shared leaf names.No
DEFAULT_CHANGES_BY_MAJOR[17]row is required, and that is measured rather than assumed:authorable-defaults/ui.jsondropsui/RecordDetailsProps:layout = "auto"because the key became[RETIRED], and the #4666 ratchet resolved its baseline against upstream and reported it as no change —No D3
SemanticMigration. These are authored/stored page metadata with a D2 conversion that rewrites them, so theEnhancedApiError.fieldErrorsdisposition (semantic entry instead of a conversion, for response surfaces with no source to rewrite) does not apply.Ratchet answers — deliberate, per the route table
The brief warned to expect the
json-schema.manifestdeletion ratchet and theauthorable-surfacedeletion gate to fire. Neither did, and that is the correct reading for this route — the playbook's §2 table makes the expectation route-dependent, and reading it the other way is how a def that was never really deleted gets waved through:authorable-surface/ui.json… [RETIRED]; no line vanishes (aretiredKey()is still a property in the walked shape)PageCardProps:actions,PageHeaderProps:icon,RecordDetailsProps:layouteach+ [RETIRED]json-schema.manifest/ui.jsonapi-surface/,api-surface-signatures.jsonz.string()/z.array(z.string())/ an inlinez.enum)authorable-defaults/ui.jsonThe
[RETIRED]lines are not deleted here: gate (c)'s aging clock starts at the major theRETIRED_KEYS_BY_MAJORentry is written under, and 17 is that major.Reverse verification — direction predicted first, then measured
A. Authored key → parse REJECTED with the prescription
Predicted per key:
safeParsefails;issues[0].code = invalid_type;expected = never;path= the retired key, as a one-element array;messageis the prescription itself (not "unrecognized key"), naming the backticked key and closing with the house sentence. A schema verdict carries no envelopestatusfield — there is nothing of that shape to pin, so the probe reports its absence rather than inventing one.statuspage:header.iconfalse✅invalid_type✅never✅["icon"]✅page:card.actionsfalse✅invalid_type✅never✅["actions"]✅record:details.layout=customfalse✅invalid_type✅never✅["layout"]✅record:details.layout=auto(the old default)false✅invalid_type✅never✅["layout"]✅Full prescription text as an author receives it, e.g.:
B. Stored old-major row → conversion → converted shape
Predicted: exactly 4 strip notices across a stored stack carrying all three keys in the region form, the nested-container form and the named-slot form;
page:header.actions,record:highlights.layoutandelement:button.iconsurvive untouched; no other protocol-17 conversion fires on the fixture.Measured — replaying every
toMajor: 17conversion over the stored row:page:headerkeepsactions: ["run"];record:highlightskeepslayout: "horizontal";element:buttonkeepsicon: "external-link". Fixture disjointness holds in both required directions — every fixture replays through the whole table to exactly its ownafter, and theretiredFromLoadPathfixtures pass the default load path with zero notices (src/conversionssuite, 359 tests green).C. Untouched sibling props → byte-identical
page:header—title,subtitle,breadcrumb,actions,recordChrome,showStar,showCopyIdpage:card—title,bordered,children,footerrecord:details—columns,sections,fields,hideFieldsrecord:highlights—layout: 'horizontal'(the live same-name sibling)Gates, one by one
check:livenesscheck:empty-statecheck:authorable-surface(incl. the #4666 defaults ratchet)check:docscheck:api-surfacecheck:spec-changescheck:upgrade-guidecheck:skill-refs/check:skill-docs/check:skill-examplescheck:variant-docs/check:exported-any/check:dual-source-exportscheck:export-origins(new in the Type Check job today)check-adr-0087-registration --base origin/mainregistered page-structure-inert-keys-removed, record-details-layout-removed)pnpm check:i18n*.form.tsdeclares component props, so no bundle key moved.)pnpm typecheckpnpm lintpnpm test@objectstack/dogfoodtargeted suitecheck:react-declaration-paritysdui.manifest.json, produced only bypnpm sdui:manifestdriving Playwright chromium over a built objectui at the pin. It is an on-demand gate whose trigger is the pin bump (#5960), and this PR does not move.objectui-sha. It is also the gate this retirement makes less meaningful, not more — see therecord:details.layoutrow aboveThe one red, and why it is not mine:
@objectstack/typessrc/node.test.ts > falls back to the importing package's own resolution when the host does not declaretimed out at 5 000 ms in the full parallel run. It is a package-resolution test in a package this diff does not touch, and its siblings in the same file take 3 021 ms and 1 672 ms, so it sits at the edge of that timeout under load. Re-run in isolation: 230/230 pass, 3.32 s. Recorded rather than silently re-run.CI on this PR: all 26 check runs completed green (one
skipped— Console Pin Gate, correct since.objectui-shadoes not move here), including both required gates, ESLint and TypeScript Type Check.Base-branch check (17:49Z)
mainmoved f5a9bc2 → 9136327 (5 commits) after this branch was pushed. The merge is clean, and becausepackages/specmoved on both sides the AGENTS.md §10 scoped re-check was run on the merged tree rather than assumed:pnpm --filter @objectstack/spec build && check:generated→ all 11 generated artifacts up to date, so no jointly-wrong artifact.main's spec commits touchautomation/io-node-config.zod.tsandui/view.zod.ts(.describe()alignment), disjoint from this diff.The merge was therefore not pushed: it would invalidate a fully green CI run for no measured benefit, and the merge queue rebuilds the PR as merged onto current
mainand re-runs the subscribing workflows anyway (§7). Verified rather than skipped.docs/adr statement
This diff touches no
docs/adr/**path. The ADR-0087 work here is registry code —packages/spec/src/conversions/registry.tsandpackages/spec/src/migrations/registry.ts— with no ADR document edited or added. Full file list:.changeset/,content/docs/references/ui/component.mdx(generated),docs/protocol-upgrade-guide.md(generated),examples/app-showcase/,packages/cli/test/,packages/cloud-connection/src/,packages/mcp/src/,packages/rest/src/,packages/spec/(src + generated baselines),skills/objectstack-ui/SKILL.md. Per the PM's dispatch note this is the registry-code-only case; the landing mode is the PM's call, and no auto-merge is armed and no queue entry made from here.content/docs/releases/is untouched, as a code PR requires.Sequencing
objectui#3829 (drop the two parity-gate exemptions) and objectui#3818 (delete the
layoutinput and the deadinline|compactbranch) are Blocked-by #6946 and proceed on the next.objectui-shapin bump after this lands. Note for whoever moves that pin: dropping the twoUNPUBLISHED_EXEMPTIONSentries is not automatic on the bump — aretiredKey()keeps the key inObject.keys(shape), so those entries stay live until objectui's tombstone recognition narrowsspecTopLevelKeys(the same trap objectui#3808 hit with the record-picker three, written out in that file's own comment).Generated by Claude Code