…solved under
`RetiredFieldTombstone` re-derived its spelling from the `field` it was
handed. When the retired spelling arrives through an authored `widget`
(`{ type: 'user', widget: 'owner' }`), both form hosts resolve the widget
key first, so the tombstone was chosen for `owner` but named the row's LIVE
type ("Field type `user` was retired"), lost the migration prescription,
and logged nothing.
The two resolvers that answer a retired key with the tombstone,
`getLazyFieldWidget` and the `field:` registrations of `registerAllFields()`,
now hand out a tombstone bound to that key (one cached component per
spelling). The tombstone reads the new `retiredFieldType` prop first and
falls back to the field only when a host renders it directly. No change to
`RETIRED_FIELD_TYPES` and no host edits.
Pins: `FormPage` on both routes, and the record form (`form.tsx`), each
with the card's two reproductions and a `type: 'owner'` control.
Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
Fixes #10471
Clause-②: no
Summary
RetiredFieldTombstonetook the spelling it names from thefieldit was handed:field.type, thenschema.type, thentype. When a retired spelling arrives through an authoredwidget, both form hosts resolve the widget first. So theownertombstone was chosen, but it was handed a field whosetypeis a live type. It then said "Field typeuserwas retired", putuserindata-retired-field-type, lost the prescription forowner, and logged nothing, becausereportRetiredFieldType('user')does nothing for a live type.The change is in
@object-ui/fieldsonly:RetiredFieldTombstonereads a new optional prop,retiredFieldType, first. The old derivation from the field is used only when the prop is absent.retiredFieldTombstoneFor(spelling)returns the tombstone with that prop bound by a closure. It is cached, so each spelling has one stable component.getLazyFieldWidget(whichFormPage,ActionParamDialogand the bulk dialog render through) and thefield:registrations made byregisterAllFields()(the record form's lookup).RETIRED_FIELD_TYPESis unchanged. There is no second table and no second prescription: the tombstone still reads the prescription from the table, by the spelling.reportRetiredFieldTypestill fires once per spelling. It dedupes on the spelling, and the spelling is now the right one.Which hosts can receive a retired key through a key other than
field.typeRead and measured at base
c5d715fc1, before any edit.field.type?FormPage(apps/console)resolveFieldWidgetKeyreadsfield.widgetfirst, then callsgetLazyFieldWidget(key)getLazyFieldWidgetform.tsxin@object-ui/components)f.widget, elsef.field.widget, elsef.type, then thefield:KEYregistry entryInlineFieldInput(@object-ui/plugin-detail)editType = field.type, then rendersRetiredFieldTombstonedirectly withfieldfield.typeActionParamDialog,BulkActionDialoggetLazyFieldWidget(field.type)Reproduction, before the change (base
c5d715fc1, pins added, the change not yet made)FormPage, real widgets, both/forms/:nameand/f/:slug: 4 failed, 3 passed.Record form (
form.tsx, real renderer,registerAllFields()): 7 failed, 5 passed. TheObjectFormshape (type: 'field:user',widget: 'owner', raw metadata asfield) nameduser. A standalone{ type: 'text', widget: 'owner' }namedtext.widget: 'field:owner'over auserfield nameduser. The console prescription forownerwas written 0 times, not once. Thetype: 'owner'control stayed green.The pins
apps/console/src/components/FormPage.retiredWidget-10471.test.tsx: both routes. For each retired spelling it checks the card's two reproductions, plus a control row whosetypeis the retired spelling. Every row must render a tombstone withrole="alert",data-retired-field-typeequal to the spelling, and text equal toRETIRED_FIELD_TYPES[spelling]. The spellings and prescriptions are read from the table at runtime, never restated. It sits beside the objectui#10176 pin, which covers thetypepath.packages/fields/src/__tests__/retired-widget-spelling-10471.test.tsx: the same facts at the resolver level.getLazyFieldWidget(spelling)and thefield:registry entry are each rendered with a live-typed field. The test checks that both return one stable component per spelling, and that the prescription is written to the console exactly once for two rendered rows. The explicit prop wins over the field, and the fallback still readsfield.type. Record-form reproductions: theObjectFormshape, a standalone text field, andwidget: 'field:SPELLING', plus thetypecontrol.packages/fields/src/__tests__/owner-retired.test.tsx: two assertions compared the registry entry by identity withRetiredFieldTombstone. The entry is now the bound tombstone. One assertion now renders the registry entry and checks the spelling it names. The survivor assertion now comparesfield:userwith thefield:ownerentry, which keeps it meaningful.Ablation (committed state
1fcca8f96, restored by blob)This removes the key hand-off. The mutation ran through
node ../objectstack/scripts/ablation-replace.mjsin wrap mode, under a restore trap:RetiredFieldTombstone {...props} retiredFieldType={spelling}inside the bound component's JSX, replaced byRetiredFieldTombstone {...props}022261ef6513to62e3f6f6de25. All three were read again from disk inside the wrapped command before vitest ran.owner-retired.test.tsxFormPagereproductions, which nameduserortext; the 3 record-form reproductions;getLazyFieldWidgetand the registry entry, which nameduserandtext; the console-once check, which counted 0 instead of 1FormPagetype: 'owner'controls, the record-formtypecontrol, the explicit-prop and fallback tests, the stable-identity test, all of objectui#10176, and all ofowner-retired.test.tsx022261ef6513again, equal to theHEADblob.git diff HEADis empty andgit status --porcelainis empty.0c611ec25, before a types-only cleanup commit, gave the same 10 failed and 27 passed.Every
@object-ui/*specifier resolves tosrc/through the rootvitest.config.mtsand the consolevite.config.tsaliases, so no build sits between the mutation and the tests.Gates (at
1fcca8f96unless noted)pnpm exec vitest run packages/fields/from the repo root: 195 files passed, 1 skipped, 3231 tests passed, 7 skipped.pnpm exec vitest run apps/console/src/components/: 31 of 31 files and 410 of 410 tests passed.0c611ec25: 47 of 47 passed. This includespackages/plugin-detail/src/__tests__/InlineFieldInput.retiredFieldType.test.tsx, run as a check on the unchanged direct-render host.pnpm --filter @object-ui/fields type-checkafter building@object-ui/fields^...: exit 0.tsconfig.test.jsonlists both new or edited pin files (--listFilesOnly).pnpm --filter @object-ui/console type-checkafter building@object-ui/console^...: exit 0. It was re-run at1fcca8f96after rebuilding@object-ui/fields, and again exited 0.tsc --listFilesOnlylists the newFormPagepin.--no-inline-config: 0 errors. Per-rule counts onpackages/fields/src/index.tsxequal the base's (react-refresh/only-export-components93,no-explicit-any44,no-unused-vars1).owner-retired.test.tsxhas 0 at base and head, and the two new files have 0. No type-aware linting is configured (noparserOptions.projectineslint.config.js), so this diff cannot change the verdict on any untouched file.node scripts/check-changeset-presence.mjs: exit 0 (4 source files in 2 released packages, 1 changeset).pnpm check:changeset-claims: exit 0 (report-only). It named 4 pending changesets that mentionpackages/fields/src/index.tsx:6625-retire-fieldmeta-decimals,6694-dashboard-lookup-reference-meta,6837-reference-to-arm-deletionand7166-retire-inert-fieldmeta-copies. I read each paragraph. They describe cell renderers and lookup metadata, which this diff does not touch, so none of them became false. The self-contradiction reading passed.pnpm check:control-bytes: OK.pnpm check:new-line-citations: 0 new citations.check:registry-bare-names,check:vi-mock-specifiers,check:vi-mock-inherit,check:vi-mock-override-shape,check:pending-changeset-literals,check:test-path-roots.check:sdui-registration-pins. It needs a builtapps/console/distand answered exit 2 (prerequisite not met), so it is left to CI.Changeset
.changeset/10471-tombstone-names-resolved-spelling.md:'@object-ui/fields': patch.apps/consolechanges only by one test file. It is in thefixedrelease group and is notprivate, and the one declaration satisfies the presence gate.Fences held
packages/fields/src/index.tsxis untouched:RepeaterCellRenderer,buildStandardCellRendererMapand the export near the top. The hunks sit ingetLazyFieldWidget, the tombstone, and the retired loop ofregisterAllFields.FormPage's fallback for an unregisteredwidgetis untouched.widget: 'field:owner'on aFormPagerow still renders a text input: that is objectui#10472's case, and objectui#10472 remains open.Acceptance notes
InlineFieldInputroutes byfield.typeand reads nowidgethint exceptpermission-facet-link. So{ type: 'user', widget: 'owner' }would inline-edit as a person picker, while the record form refuses it. This comes from reading the source only; it was not run. Whether inline edit should honourwidgethints at all is a separate question.field:ownerregistry entry is no longerRetiredFieldTombstoneitself but the bound component in the same family (itsdisplayNameisRetiredFieldTombstone(owner)). A search found no production code that compares it by identity.Generated by Claude Code