diff --git a/.changeset/retire-owner-widget-alias.md b/.changeset/retire-owner-widget-alias.md
new file mode 100644
index 0000000000..48a5a96ce9
--- /dev/null
+++ b/.changeset/retire-owner-widget-alias.md
@@ -0,0 +1,35 @@
+---
+"@object-ui/fields": patch
+"@object-ui/plugin-grid": patch
+"@object-ui/app-shell": patch
+---
+
+fix(fields): retire the `owner` field-type alias with a loud tombstone
+
+`owner` was a synonym for `user` with zero behavioral delta — both resolved to
+the same `UserField` widget — and it is not a member of `@objectstack/spec`'s
+closed `FieldType`, so no object schema could ever declare it. It was reachable
+only through hand-written SDUI, and the three code faces that read it had
+already drifted apart on the word: the form's data-source rule excluded it,
+while plugin-grid's bulk-action dialog and app-shell's `paramToField` included
+it.
+
+The retired spelling now fails **loudly**. Deleting the alias on its own would
+have been absorbed by two silent tails (`mapFieldTypeToFormType`'s
+`|| 'field:text'` and `resolveFormWidgetType`'s `: 'text'`), each handing back a
+working plain text input with no check turning red — so anyone who had written
+`type: 'owner'`, including an AI author copying it out of a doc, would have
+shipped a text box believing they shipped a person picker. Instead:
+
+- `type: 'owner'` and `widget: 'field:owner'` both resolve to a registered
+ tombstone widget that renders a visible refusal naming the migration;
+- the same prescription is written to the console once per spelling;
+- the read/cell path degrades to the text cell deliberately and says so.
+
+Migration: write the record-owner field as `{ type: 'user', name: 'owner' }` —
+the field NAME carries the ownership meaning, the type carries the widget.
+`UserField` and `UserCellRenderer` are unchanged; only the synonym is gone.
+
+Also corrects the `dataSource` TSDoc in `@object-ui/fields`, which listed `grid`
+among the widgets the form renderer wires a DataSource to. `GridField` never
+read `dataSource` and no data-source table ever contained the key.
diff --git a/content/docs/components/complex/filter-builder.mdx b/content/docs/components/complex/filter-builder.mdx
index 2a9c2c0655..1abbf95027 100644
--- a/content/docs/components/complex/filter-builder.mdx
+++ b/content/docs/components/complex/filter-builder.mdx
@@ -24,7 +24,7 @@ interface FilterField {
| 'date' | 'datetime' | 'time'
| 'boolean'
| 'select' | 'status'
- | 'lookup' | 'master_detail' | 'user' | 'owner'; // Field type
+ | 'lookup' | 'master_detail' | 'user'; // Field type
options?: Array<{ value: string; label: string }>; // Static options (select-like)
// Lookup-like fields without `options` render a remote-search picker that
// queries the configured DataSource. The metadata below describes how to
diff --git a/content/docs/core/report-schema.mdx b/content/docs/core/report-schema.mdx
index 8bc7bf4071..d637802d72 100644
--- a/content/docs/core/report-schema.mdx
+++ b/content/docs/core/report-schema.mdx
@@ -96,7 +96,7 @@ interface ReportField {
| 'select' | 'multi_select' | 'status'
| 'lookup' | 'reference' | 'master_detail'
| 'email' | 'url' | 'phone' | 'currency' | 'percent'
- | 'image' | 'file' | 'user' | 'owner'
+ | 'image' | 'file' | 'user'
| 'richtext' | 'html' | 'markdown' | 'json' | 'tags';
// Used when type is select / multi_select / status.
diff --git a/content/docs/fields/user.mdx b/content/docs/fields/user.mdx
index f173a0f6c6..fe9cce21ed 100644
--- a/content/docs/fields/user.mdx
+++ b/content/docs/fields/user.mdx
@@ -13,7 +13,11 @@ The User Field component provides a user selector for assigning users or owners
-## Owner Field
+## Read-Only Record Owner
+
+A record-owner field is a plain `user` field whose NAME carries the ownership
+meaning — there is no separate owner type. Marking it `readonly` is what makes
+it display-only.
@@ -21,7 +25,7 @@ The User Field component provides a user selector for assigning users or owners
```plaintext
interface UserFieldSchema {
- type: 'user' | 'owner';
+ type: 'user';
name: string; // Field name/ID
label?: string; // Field label
value?: User | User[]; // Selected user(s)
@@ -45,8 +49,18 @@ interface User {
## User vs Owner
-- **User Field**: Selectable user field for assignments, team members, etc.
-- **Owner Field**: Typically read-only, automatically set to the record creator
+Both are the same field **type**. What differs is the field's name and whether
+it is writable:
+
+- **Assignment field**: a selectable `user` field for assignees, team members, etc.
+- **Owner field**: a `user` field named `owner`, typically `readonly` and
+ defaulted to the record creator.
+
+There is no `owner` field type. It existed as a synonym until objectui#4814
+retired it (it resolved to the very same widget, and it was never a member of
+`@objectstack/spec`'s `FieldType`). Authoring `type: 'owner'` now renders a
+visible refusal naming this migration rather than silently falling back to a
+text input. Write `{ type: 'user', name: 'owner' }` instead.
## Display Features
@@ -68,7 +82,7 @@ import { UserCellRenderer } from '@object-ui/fields';
## How it works
-`user` / `owner` fields are a **lookup specialized to the framework's `sys_user`
+`user` fields are a **lookup specialized to the framework's `sys_user`
object** — there is no custom user API to wire up. The `UserField` widget
delegates to the shared lookup picker with the reference fixed to `sys_user`,
reusing the same debounced search, record-picker dialog and id resolution as any
@@ -88,9 +102,9 @@ No custom user-management integration is required when a `dataSource` is present
Common permission configurations:
```plaintext
-// Record owner only
+// Record owner only — a `user` field whose NAME carries the ownership meaning
{
- type: 'owner',
+ type: 'user',
name: 'owner',
label: 'Owner',
readonly: true,
diff --git a/examples/schema-catalog/src/schemas/fields-user/record-owner-read-only.json b/examples/schema-catalog/src/schemas/fields-user/record-owner-read-only.json
index a9b9de2ce9..fa265486e9 100644
--- a/examples/schema-catalog/src/schemas/fields-user/record-owner-read-only.json
+++ b/examples/schema-catalog/src/schemas/fields-user/record-owner-read-only.json
@@ -6,7 +6,7 @@
{
"name": "record_owner",
"label": "Owner",
- "type": "owner",
+ "type": "user",
"readonly": true
}
]
diff --git a/packages/app-shell/src/utils/paramToField.ts b/packages/app-shell/src/utils/paramToField.ts
index 2bc53f768d..aad13fcd4b 100644
--- a/packages/app-shell/src/utils/paramToField.ts
+++ b/packages/app-shell/src/utils/paramToField.ts
@@ -91,7 +91,10 @@ export function paramToField(param: ActionParamDef): Record {
field.widget = 'checkbox';
}
- if (LOOKUP_WIDGET_TYPES.has(type) || type === 'user' || type === 'owner') {
+ // `|| type === 'owner'` stood here until objectui#4814 retired that spelling
+ // (ruling A′). It moves in lockstep with plugin-grid's `bulkParamToField`
+ // twin — the two param faces are never split.
+ if (LOOKUP_WIDGET_TYPES.has(type) || type === 'user') {
Object.assign(field, {
reference_to: param.referenceTo,
display_field: param.displayField,
diff --git a/packages/app-shell/src/utils/paramValueShape.ts b/packages/app-shell/src/utils/paramValueShape.ts
index 1a85baa59e..7787bdd440 100644
--- a/packages/app-shell/src/utils/paramValueShape.ts
+++ b/packages/app-shell/src/utils/paramValueShape.ts
@@ -150,7 +150,10 @@ export const PARAM_VALUE_SHAPES: Readonly> =
lookup: { base: 'string', cardinality: 'scalar|array', note: 'Referenced record id; multiple → id[]. (No referenceTo → falls back to a text string.)' },
master_detail: { base: 'string', cardinality: 'scalar|array', note: 'Parent record id — renders the single-value LookupField, not a child list.' },
user: { base: 'string', cardinality: 'scalar|array', note: 'sys_user id; multiple → id[].' },
- owner: { base: 'string', cardinality: 'scalar|array', note: 'Owner (sys_user) id; multiple → id[].' },
+ // `owner` had an entry here until objectui#4814 retired the spelling. Removed
+ // on this card's own drift guard's instruction ("no stale contract entries
+ // pointing at removed widget types"): a shape declared for a type no form can
+ // render is a contract for a param nobody can author.
// Uploads → fileId string(s) after serializeParamValues (#2698/#2710)
file: { base: 'string', cardinality: 'scalar|array', note: 'fileId string after serialize; multiple → fileId[]. Widget state holds a { file_id, name, url, … } descriptor pre-serialize.' },
diff --git a/packages/fields/src/__tests__/capability-multiselect-retired.test.ts b/packages/fields/src/__tests__/capability-multiselect-retired.test.ts
index 8ebf0f6a64..cd3b74c297 100644
--- a/packages/fields/src/__tests__/capability-multiselect-retired.test.ts
+++ b/packages/fields/src/__tests__/capability-multiselect-retired.test.ts
@@ -65,7 +65,11 @@ const RETAINED_FIELD_KEYS = [
'currency', 'percent', 'password', 'markdown', 'html', 'lookup', 'master_detail',
'file', 'image', 'location',
'formula', 'summary', 'auto_number',
- 'user', 'owner',
+ // `owner` sat beside `user` here until objectui#4814 retired that spelling
+ // too. It is NOT simply dropped from the floor: `owner-retired.test.tsx`
+ // asserts `field:owner` now resolves to the tombstone widget, so the key's
+ // disposition is still pinned — by the card that owns it.
+ 'user',
'object', 'vector', 'grid',
'color', 'slider', 'rating', 'code', 'avatar', 'address', 'geolocation',
'signature', 'qrcode',
@@ -90,7 +94,7 @@ describe('capability-multiselect widget retirement (objectui#3308)', () => {
`field:${key} must survive the retirement`,
).toBeTruthy();
}
- expect(RETAINED_FIELD_KEYS).toHaveLength(38);
+ expect(RETAINED_FIELD_KEYS).toHaveLength(37);
});
it('has no second registration path left to shadow the live one (objectui#3910)', async () => {
diff --git a/packages/fields/src/__tests__/owner-retired.test.tsx b/packages/fields/src/__tests__/owner-retired.test.tsx
new file mode 100644
index 0000000000..e8d5da9b7d
--- /dev/null
+++ b/packages/fields/src/__tests__/owner-retired.test.tsx
@@ -0,0 +1,181 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * Retirement pin — the field type `owner` and the widget key `field:owner`
+ * (objectui#4814, ruling A′, ADR-0049 enforce-or-remove).
+ *
+ * What makes this retirement different from `capability-multiselect`'s (whose
+ * pin lives next door) is WHERE the retired name used to be reachable from.
+ * `capability-multiselect` was a `widget:` hint that had never been registered
+ * on the live path, so its retirement is proved by absence: the registry does
+ * not answer, and nothing else could have.
+ *
+ * `owner` was a real, live field TYPE. Deleting it alone would have been
+ * answered by two silent tails — `mapFieldTypeToFormType`'s `|| 'field:text'`
+ * and `resolveFormWidgetType`'s `: 'text'` — each of which hands back a working
+ * plain text input. No gate in this repo turns red on that: `FORM_WIDGET_TYPES`
+ * in `field-type-coverage.test.ts` never listed `owner` (or `user`), and the
+ * catalog's hosted test only asserts that a form and a `[data-field]` exist,
+ * which a TextField satisfies. So an author — or an AI author copying
+ * `type: 'owner'` out of a doc — would have shipped a text box believing they
+ * had shipped a person picker, with every check green.
+ *
+ * Therefore these assertions are about LOUDNESS, not absence, and they are
+ * written against the refusal's SHAPE rather than against "it is not a
+ * UserField": a test that only asserted the latter would pass just as happily
+ * against the silent text-box regression this card exists to prevent.
+ */
+
+import React from 'react';
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import { ComponentRegistry } from '@object-ui/core';
+
+import {
+ registerAllFields,
+ FORM_FIELD_TYPES,
+ RetiredFieldTombstone,
+ RETIRED_FIELD_TYPES,
+ resetRetiredFieldTypeReports,
+ mapFieldTypeToFormType,
+ resolveFormWidgetType,
+ getCellRenderer,
+ TextCellRenderer,
+} from '../index';
+
+const RETIRED = 'owner';
+const SURVIVOR = 'user';
+
+beforeEach(() => {
+ resetRetiredFieldTypeReports();
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe('`owner` field-type retirement (objectui#4814)', () => {
+ describe('the form path refuses loudly instead of degrading to a text box', () => {
+ it('does NOT resolve the retired type to the field:text fallback', () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ // The whole point of the tombstone: `field:text` here is the regression.
+ expect(mapFieldTypeToFormType(RETIRED)).not.toBe('field:text');
+ expect(mapFieldTypeToFormType(RETIRED)).toBe(`field:${RETIRED}`);
+ });
+
+ it('does NOT resolve the retired widget key to the `text` widget', () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ expect(resolveFormWidgetType(RETIRED)).not.toBe('text');
+ expect(resolveFormWidgetType(RETIRED)).toBe(RETIRED);
+ });
+
+ it('logs a prescription naming the migration, not a bare "unknown type"', () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
+ mapFieldTypeToFormType(RETIRED);
+
+ const said = error.mock.calls.map((c) => String(c[0])).join('\n');
+ // The message must be actionable on its own — this is the text an agent
+ // or a developer will follow, so its content is the contract.
+ expect(said).toContain('`owner`');
+ expect(said).toContain('RETIRED');
+ expect(said).toContain("{ type: 'user', name: 'owner' }");
+ expect(said).toContain('objectui#4814');
+ });
+
+ it('reports once per spelling, so a rendered list cannot bury the message', () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
+ for (let i = 0; i < 50; i++) mapFieldTypeToFormType(RETIRED);
+
+ const retiredMessages = error.mock.calls
+ .map((c) => String(c[0]))
+ .filter((m) => m.includes('objectui#4814'));
+ expect(retiredMessages).toHaveLength(1);
+ });
+
+ it('is not a renderable field type any more', () => {
+ expect(FORM_FIELD_TYPES).not.toContain(RETIRED);
+ // …and the survivor is untouched, so this is a subtraction of exactly one.
+ expect(FORM_FIELD_TYPES).toContain(SURVIVOR);
+ });
+ });
+
+ describe('both authored spellings land on the visible refusal', () => {
+ it('registers the tombstone under `field:owner`, so `widget: "field:owner"` reaches it', () => {
+ registerAllFields();
+ // This is the exact lookup `form.tsx`'s `renderFieldComponent` performs
+ // for BOTH `widget: 'field:owner'` and a hand-written `type: 'owner'`.
+ expect(ComponentRegistry.get(`field:${RETIRED}`)).toBe(RetiredFieldTombstone);
+ });
+
+ it('does not claim the bare `owner` global name', () => {
+ registerAllFields();
+ expect(ComponentRegistry.get(RETIRED)).toBeUndefined();
+ });
+
+ it('renders an alert carrying the prescription, not an input', () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ render();
+
+ const alert = screen.getByTestId('field-retired-tombstone');
+ expect(alert.getAttribute('role')).toBe('alert');
+ expect(alert.textContent).toContain("{ type: 'user', name: 'owner' }");
+ // A refusal, not a control the author could mistake for a working field.
+ expect(document.body.querySelector('input')).toBeNull();
+ });
+
+ it('names the offending spelling on the element, for a host that inspects it', () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ render();
+ expect(
+ screen.getByTestId('field-retired-tombstone').getAttribute('data-retired-field-type'),
+ ).toBe(RETIRED);
+ });
+ });
+
+ describe('the read/cell path', () => {
+ it('degrades to the text cell but says so', () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const renderer = getCellRenderer(RETIRED);
+
+ expect(renderer).toBe(TextCellRenderer);
+ const said = error.mock.calls.map((c) => String(c[0])).join('\n');
+ expect(said).toContain('objectui#4814');
+ });
+ });
+
+ describe('the `user` path is untouched', () => {
+ it('still maps, resolves and renders as the person picker', () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
+ registerAllFields();
+
+ expect(mapFieldTypeToFormType(SURVIVOR)).toBe(`field:${SURVIVOR}`);
+ expect(resolveFormWidgetType(SURVIVOR)).toBe(SURVIVOR);
+ expect(ComponentRegistry.get(`field:${SURVIVOR}`)).toBeTruthy();
+ expect(ComponentRegistry.get(`field:${SURVIVOR}`)).not.toBe(RetiredFieldTombstone);
+ expect(getCellRenderer(SURVIVOR)).not.toBe(TextCellRenderer);
+
+ // Nothing about a live type may reach the retirement machinery.
+ expect(error.mock.calls.map((c) => String(c[0])).join('\n')).not.toContain('objectui#4814');
+ });
+
+ it('leaves a genuinely unknown type on the quiet text fallback', () => {
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
+ // Retirement is a NAMED disposition, not a new blanket noise source: an
+ // unknown type keeps the pre-existing silent fallback.
+ expect(mapFieldTypeToFormType('something-unknown')).toBe('field:text');
+ expect(error).not.toHaveBeenCalled();
+ });
+ });
+
+ it('carries exactly the retired vocabulary it claims to', () => {
+ // Guards the table itself: growing it is a decision, never a side effect.
+ expect(Object.keys(RETIRED_FIELD_TYPES)).toEqual([RETIRED]);
+ });
+});
diff --git a/packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx b/packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx
index 3ca8e8b960..538a2d9596 100644
--- a/packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx
+++ b/packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx
@@ -134,7 +134,9 @@ const WIDGETS: Record> = {
summary: SummaryField,
auto_number: AutoNumberField,
user: UserField,
- owner: UserField,
+ // `owner: UserField` sat here until objectui#4814 retired the spelling. The
+ // tombstone that replaced it is not a field widget and renders no control, so
+ // it has no `aria-invalid` surface to scan.
object: ObjectField,
vector: VectorField,
grid: GridField,
diff --git a/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx b/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx
index d9de0f4b17..d6a0393235 100644
--- a/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx
+++ b/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx
@@ -186,7 +186,9 @@ const WIDGETS: Record> = {
summary: SummaryField,
auto_number: AutoNumberField,
user: UserField,
- owner: UserField,
+ // `owner: UserField` sat here until objectui#4814 retired the spelling. The
+ // tombstone that replaced it spreads no author props onto the DOM, so it has
+ // no leak surface to scan.
object: ObjectField,
vector: VectorField,
grid: GridField,
diff --git a/packages/fields/src/field-type-alias.multiple.test.ts b/packages/fields/src/field-type-alias.multiple.test.ts
index cdddc55dcb..c37710b31a 100644
--- a/packages/fields/src/field-type-alias.multiple.test.ts
+++ b/packages/fields/src/field-type-alias.multiple.test.ts
@@ -67,7 +67,11 @@ describe('select carries its arity in the widget id (objectui#3986)', () => {
* their widget id, and with it their `labelling` declaration, is correct for
* either arity. Moving them would point at widgets that do not exist.
*/
- const UNMOVED_MULTI_CAPABLE = ['lookup', 'master_detail', 'user', 'owner', 'file', 'image', 'radio'] as const;
+ // `owner` was listed here beside `user` until objectui#4814 retired the
+ // spelling. It is dropped rather than kept-and-passing: the assertion would
+ // still be green (both arities reach the tombstone), but it would be pinning
+ // the arity behaviour of a REFUSAL, which says nothing about this override.
+ const UNMOVED_MULTI_CAPABLE = ['lookup', 'master_detail', 'user', 'file', 'image', 'radio'] as const;
it.each(UNMOVED_MULTI_CAPABLE)('%s resolves identically with and without multiple', (type) => {
expect(mapFieldTypeToFormType(type, { multiple: true })).toBe(mapFieldTypeToFormType(type));
diff --git a/packages/fields/src/field-type-alias.ts b/packages/fields/src/field-type-alias.ts
index 2387660637..0b6171a677 100644
--- a/packages/fields/src/field-type-alias.ts
+++ b/packages/fields/src/field-type-alias.ts
@@ -47,6 +47,72 @@ const MULTI_VALUE_FORM_TYPES: Record = {
select: 'field:multiselect',
};
+/**
+ * TOMBSTONE table — field-type spellings this renderer has RETIRED, mapped to
+ * the prescription an author must follow instead (ADR-0049 enforce-or-remove).
+ *
+ * A retired spelling is not merely absent: absence here means
+ * {@link mapFieldTypeToFormType}'s `|| 'field:text'` tail would hand back a
+ * working plain text input, which is the failure mode this table exists to
+ * prevent. An author who writes a retired name — or an AI author who copies one
+ * out of a stale doc — must be TOLD, not quietly given a text box that looks
+ * like it worked. So each entry resolves to {@link RETIRED_WIDGET_KEY_PREFIX}
+ * plus the retired name: a registered tombstone widget that renders a visible
+ * refusal naming the migration (`packages/fields/src/index.tsx`), while
+ * {@link reportRetiredFieldType} writes the same prescription to the console.
+ *
+ * `owner` (objectui#4814, ruling A′): a synonym for `user` with zero behavioral
+ * delta — both resolved to the SAME `UserField` widget — and absent from the
+ * spec's closed 48-member `FieldType`, so no object schema could ever declare
+ * it; it was reachable only through hand-written SDUI. Three code faces had
+ * already drifted apart on this one word (the form's data-source rule excluded
+ * it while plugin-grid's bulk dialog included it), which is the standing
+ * evidence that a second spelling for one concept is a drift channel, not a
+ * convenience. The idiom survives verbatim as `{ type: 'user', name: 'owner' }`.
+ */
+export const RETIRED_FIELD_TYPES: Readonly> = Object.freeze({
+ owner:
+ "[object-ui] Field type `owner` was RETIRED (objectui#4814). It was a synonym " +
+ "for `user` with no behavioral difference, and it is not a member of " +
+ "`@objectstack/spec`'s FieldType. Write the record-owner field as " +
+ "`{ type: 'user', name: 'owner' }` — the field NAME carries the ownership " +
+ "meaning, the type carries the widget. The `widget: 'field:owner'` spelling " +
+ "is retired with it.",
+});
+
+/** Namespace prefix the retired spellings resolve into. */
+const RETIRED_WIDGET_KEY_PREFIX = 'field:';
+
+/**
+ * Spellings already reported this session, so a retired type inside a rendered
+ * list logs its prescription ONCE instead of once per row. The message is a
+ * fix instruction for an author, not a per-render event — a 1000-row grid
+ * repeating it 1000 times buries the very thing it is trying to surface.
+ */
+const reportedRetiredTypes = new Set();
+
+/**
+ * Report a retired field-type spelling loudly, once per spelling.
+ *
+ * @param fieldType - The spelling the author wrote.
+ * @returns `true` when `fieldType` is retired (whether or not this call was the
+ * one that logged), so callers can branch on it without a second table lookup.
+ */
+export function reportRetiredFieldType(fieldType: string): boolean {
+ const prescription = RETIRED_FIELD_TYPES[fieldType];
+ if (!prescription) return false;
+ if (!reportedRetiredTypes.has(fieldType)) {
+ reportedRetiredTypes.add(fieldType);
+ console.error(prescription);
+ }
+ return true;
+}
+
+/** Test seam — forget which spellings have been reported. */
+export function resetRetiredFieldTypeReports(): void {
+ reportedRetiredTypes.clear();
+}
+
/**
* Map field type to form component type
*
@@ -98,11 +164,14 @@ export function mapFieldTypeToFormType(
lookup: 'field:lookup',
master_detail: 'field:master_detail',
tree: 'field:lookup', // hierarchical reference — pick the parent via a lookup
- // `user` is a lookup specialized to sys_user; `owner` mirrors it (record
- // ownership). Both render via the UserField person-picker (delegates to the
- // lookup picker). Without these they would fall through to `field:text`.
+ // `user` is a lookup specialized to sys_user, rendered by the UserField
+ // person-picker (which delegates to the lookup picker). Without it the type
+ // would fall through to `field:text`.
+ //
+ // `owner` was its synonym until objectui#4814 retired it — see
+ // {@link RETIRED_FIELD_TYPES}. It is deliberately NOT re-added here: a
+ // retired spelling must not resolve through the live table.
user: 'field:user',
- owner: 'field:owner',
// Contact fields
email: 'field:email',
@@ -152,5 +221,15 @@ export function mapFieldTypeToFormType(
return MULTI_VALUE_FORM_TYPES[fieldType];
}
- return typeMap[fieldType] || 'field:text';
+ const live = typeMap[fieldType];
+ if (live) return live;
+
+ // A RETIRED spelling is answered before the `field:text` tail, and never by
+ // it: falling through would render a working text input, which is exactly the
+ // silent degradation the tombstone exists to prevent (objectui#4814).
+ if (reportRetiredFieldType(fieldType)) {
+ return `${RETIRED_WIDGET_KEY_PREFIX}${fieldType}`;
+ }
+
+ return 'field:text';
}
diff --git a/packages/fields/src/field-type-coverage.test.ts b/packages/fields/src/field-type-coverage.test.ts
index 5c6fcc5070..af603d7cfa 100644
--- a/packages/fields/src/field-type-coverage.test.ts
+++ b/packages/fields/src/field-type-coverage.test.ts
@@ -45,7 +45,12 @@ const CELL_RENDERER_TYPES = [
'file', 'video', 'audio', 'image', 'avatar', 'signature',
'markdown', 'html', 'richtext',
'location', 'geolocation', 'address', 'color', 'json',
- 'formula', 'summary', 'user', 'owner',
+ // `owner` was listed here (asserting a dedicated cell renderer) until
+ // objectui#4814 retired the spelling. Its retirement is pinned by
+ // `__tests__/owner-retired.test.tsx`, which asserts the OPPOSITE — the text
+ // cell plus a console prescription — so the fact is stated in exactly one
+ // place rather than half-stated in two.
+ 'formula', 'summary', 'user',
];
describe('field-type renderer coverage (regression guard)', () => {
diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx
index c2326890cd..c0c4ece541 100644
--- a/packages/fields/src/index.tsx
+++ b/packages/fields/src/index.tsx
@@ -2111,7 +2111,15 @@ export function getCellRenderer(fieldType: string): React.FC
if (fieldRegistry.has(fieldType)) {
return fieldRegistry.get(fieldType)!;
}
-
+
+ // 1b. A RETIRED spelling reaching the read path says a stored column is still
+ // typed with a name this renderer no longer honours. There is no visible
+ // alert a table CELL can carry without wrecking the row, so the console
+ // prescription is the loud half here (once per spelling —
+ // `reportRetiredFieldType`), and the cell degrades to text deliberately
+ // rather than by omission (objectui#4814).
+ reportRetiredFieldType(fieldType);
+
// 2. Fallback to standard mappings if not overridden
const standardMap: Record> = {
text: TextCellRenderer,
@@ -2154,7 +2162,6 @@ export function getCellRenderer(fieldType: string): React.FC
summary: FormulaCellRenderer,
auto_number: TextCellRenderer,
user: UserCellRenderer,
- owner: UserCellRenderer,
password: () => ••••••,
secret: () => ••••••,
location: LocationCellRenderer,
@@ -2187,7 +2194,9 @@ registerFieldRenderer('master_detail', LookupCellRenderer);
registerFieldRenderer('select', SelectCellRenderer);
registerFieldRenderer('status', SelectCellRenderer);
registerFieldRenderer('user', UserCellRenderer);
-registerFieldRenderer('owner', UserCellRenderer);
+// `owner` was registered here to the same UserCellRenderer until objectui#4814
+// retired the spelling — see the TOMBSTONE below. `getCellRenderer('owner')`
+// now reports the prescription and falls to the text cell.
// Register getCellRenderer in the bridge so RecordPickerDialog can access it
// via LookupField without circular imports.
@@ -2200,6 +2209,8 @@ setCellRendererResolver(getCellRenderer);
// FieldEditWidget can resolve spec aliases without importing this barrel.
export { mapFieldTypeToFormType } from './field-type-alias';
import { mapFieldTypeToFormType } from './field-type-alias';
+export { RETIRED_FIELD_TYPES, reportRetiredFieldType, resetRetiredFieldTypeReports } from './field-type-alias';
+import { RETIRED_FIELD_TYPES, reportRetiredFieldType } from './field-type-alias';
/**
* Formats file size in bytes to human-readable string
@@ -2430,10 +2441,12 @@ const fieldWidgetMap: Record Promise<{ default: React.ComponentTyp
'summary': () => import('./widgets/SummaryField').then(m => ({ default: m.SummaryField })),
'auto_number': () => import('./widgets/AutoNumberField').then(m => ({ default: m.AutoNumberField })),
- // User fields
+ // User fields. `owner` pointed at this same UserField until objectui#4814
+ // retired it (see the TOMBSTONE near `registerAllFields`) — do not re-add it
+ // here: membership in this map is what makes a name a renderable field type
+ // (`FORM_FIELD_TYPES` is its key set).
'user': () => import('./widgets/UserField').then(m => ({ default: m.UserField })),
- 'owner': () => import('./widgets/UserField').then(m => ({ default: m.UserField })),
-
+
// Complex data types
'object': () => import('./widgets/ObjectField').then(m => ({ default: m.ObjectField })),
'vector': () => import('./widgets/VectorField').then(m => ({ default: m.VectorField })),
@@ -2482,6 +2495,11 @@ export const FORM_FIELD_TYPES: readonly string[] = Object.freeze(Object.keys(fie
*/
export function resolveFormWidgetType(fieldType: string): string {
if (fieldWidgetMap[fieldType]) return fieldType;
+ // A retired spelling resolves to ITSELF, not to `text`: the registry holds a
+ // tombstone widget under that key which refuses visibly, so every host built
+ // on this seam (the app-shell `ActionParamDialog`, the bulk dialog) reports
+ // the retirement instead of silently rendering an input (objectui#4814).
+ if (RETIRED_FIELD_TYPES[fieldType]) return fieldType;
const mapped = mapFieldTypeToFormType(fieldType).replace(/^field:/, '');
return fieldWidgetMap[mapped] ? mapped : 'text';
}
@@ -2512,6 +2530,10 @@ const lazyFieldWidgets = new Map>();
*/
export function getLazyFieldWidget(fieldType: string): React.ComponentType {
const key = resolveFormWidgetType(fieldType);
+ // A retired key has no loader in `fieldWidgetMap` by construction, so it is
+ // answered with the tombstone before the lazy path (which would otherwise
+ // call `React.lazy(undefined)`).
+ if (RETIRED_FIELD_TYPES[key]) return RetiredFieldTombstone;
let Widget = lazyFieldWidgets.get(key);
if (!Widget) {
Widget = React.lazy(fieldWidgetMap[key]);
@@ -2644,10 +2666,53 @@ export function registerField(fieldType: string): void {
* // Register all fields at once
* registerAllFields();
*/
+/**
+ * The widget a RETIRED field-type spelling renders (objectui#4814).
+ *
+ * Shape borrowed from the form renderer's spec-vocabulary boundary (#3090),
+ * which is this repo's settled answer to "an authored entry this renderer
+ * cannot honour": an inline alert that NAMES the offending entry, plus a
+ * `console.error` whose text doubles as the fix instruction. Nothing is thrown
+ * — one retired field must not take down the rest of a record form — but
+ * nothing is silently substituted either, which is the whole point: the author
+ * sees a refusal where they expected an input, not a text box that looks like
+ * it worked.
+ */
+export const RetiredFieldTombstone: React.FC> = (props) => {
+ const spelling: string =
+ props?.field?.type ?? props?.schema?.type ?? props?.type ?? 'unknown';
+ const prescription =
+ RETIRED_FIELD_TYPES[spelling] ??
+ `[object-ui] Field type \`${spelling}\` was retired.`;
+ React.useEffect(() => {
+ reportRetiredFieldType(spelling);
+ }, [spelling]);
+ return (
+
+ {prescription}
+
+ );
+};
+
export function registerAllFields(): void {
Object.keys(fieldWidgetMap).forEach(fieldType => {
registerField(fieldType);
});
+ // Retired spellings are registered LAST and only under the `field:` namespace
+ // (`skipFallback` — a tombstone must not claim the bare global name). This is
+ // what makes `widget: 'field:owner'` and a hand-written `type: 'owner'` land
+ // on a visible refusal instead of falling through to the form's text input.
+ Object.keys(RETIRED_FIELD_TYPES).forEach(fieldType => {
+ ComponentRegistry.register(fieldType, RetiredFieldTombstone, {
+ namespace: 'field',
+ skipFallback: true,
+ });
+ });
}
// TOMBSTONE (objectui#3910, ruling B of objectui#3798) — `registerFields()` lived
@@ -2681,6 +2746,34 @@ export function registerAllFields(): void {
// widget NAME stays retired — do not add it to `fieldWidgetMap`.
// `CapabilityMultiSelectField` itself lives on as a plain component, imported and
// rendered directly by Studio's `PermissionMatrixEditor` (ADR-0056 P2's design).
+//
+// TOMBSTONE (objectui#4814, ruling A′, ADR-0049 enforce-or-remove) — the field
+// type `owner` and its widget key `field:owner`. Both pointed at `UserField`,
+// the SAME widget `user` resolves to, so the word carried zero behavioral delta;
+// and `owner` is absent from `@objectstack/spec`'s closed 48-member `FieldType`,
+// so no object schema could declare it — it was reachable only through
+// hand-written SDUI. Three code faces had already drifted apart on this one
+// word: the form's data-source rule excluded it, plugin-grid's bulk dialog
+// included it, and app-shell's `paramToField` included it — which is the
+// standing evidence that a second spelling for one concept is a drift channel,
+// not a convenience.
+//
+// The retirement is LOUD, not silent, and that distinction is the ruling's
+// point. `mapFieldTypeToFormType`'s `|| 'field:text'` tail and
+// `resolveFormWidgetType`'s `: 'text'` tail would each have handed a retired
+// `owner` field a working plain text input, with no gate anywhere turning red —
+// an AI author copying `type: 'owner'` out of a stale doc would have shipped a
+// text box believing it shipped a person picker. So `owner` resolves to
+// `RetiredFieldTombstone` (a visible refusal) and `reportRetiredFieldType`
+// writes the migration to the console. This is also the designed answer to the
+// one surface this retirement could not measure: the `cloud` repo was never
+// scanned (no credentials in the measuring session), so any consumer living
+// there fails loudly and nameably instead of degrading in silence.
+//
+// Do not re-add `owner` to `fieldWidgetMap` or to `field-type-alias`'s live
+// `typeMap`. The surviving idiom is `{ type: 'user', name: 'owner' }` — the
+// field NAME carries ownership meaning, the type carries the widget.
+// `UserField` and `UserCellRenderer` are untouched; only the synonym is gone.
export * from './widgets/types';
// File field value shapes (ObjectStack ADR-0104 D3 wave 2) — the single
diff --git a/packages/fields/src/widgets/types.ts b/packages/fields/src/widgets/types.ts
index 5c35d7d885..79e2b48aae 100644
--- a/packages/fields/src/widgets/types.ts
+++ b/packages/fields/src/widgets/types.ts
@@ -164,9 +164,14 @@ export type FieldWidgetComponentProps = {
/**
* DataSource for widgets that query records (lookup / user / object-ref /
- * recipient-picker / grid). Injected by the form renderer for the field
- * types that need it, and passed directly by inline-edit hosts. Option
- * widgets destructure it purely to keep it off their DOM spread.
+ * recipient-picker). Injected by the form renderer for the field types that
+ * need it, and passed directly by inline-edit hosts. Option widgets
+ * destructure it purely to keep it off their DOM spread.
+ *
+ * `grid` was listed here with zero consumers (objectui#4814): `GridField.tsx`
+ * never reads `dataSource`, and no data-source table has ever contained the
+ * key, so the claim described a wiring that did not exist. `owner` is absent
+ * for a different reason — the spelling itself is retired (same card).
*
* Left structural (`unknown`): `@object-ui/fields` must not depend on a
* concrete adapter — every consumer narrows it itself.
diff --git a/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx b/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx
index d0de04f343..8c8f303c9b 100644
--- a/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx
+++ b/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx
@@ -74,8 +74,13 @@ import { isComputedFieldType, isInlineExcludedDetailFieldType } from '../fieldEn
* reaches a widget through the alias table — `toggle`, `progress`, `json`,
* `composite`, `autonumber`, `secret`, `video` — which is precisely the set
* that fell through the switch in #2942 and again here. The spec enum misses
- * the form-only keys (`owner`, `object`, `grid`, `geolocation`, the
- * widget-hint pickers).
+ * the form-only keys (`object`, `grid`, `geolocation`, the widget-hint
+ * pickers).
+ *
+ * `owner` was named here as a form-only key until objectui#4814 retired the
+ * spelling. It is now in NEITHER set — gone from `FORM_FIELD_TYPES`, and never
+ * a member of the spec enum, which is the card's own premise — so it leaves
+ * this universe entirely rather than moving between buckets.
*/
const specTypes: string[] = Array.isArray((FieldType as { options?: readonly string[] }).options)
? [...(FieldType as { options: readonly string[] }).options]
@@ -228,10 +233,20 @@ describe('inline-edit type coverage — every type has exactly one decision (#42
'recipient-picker', 'record', 'repeater', 'richtext', 'secret',
'summary', 'vector',
],
+ // `owner` stood between `number` and `percent` until objectui#4814
+ // retired the spelling. It is NOT re-bucketed here: it left `ALL_TYPES`
+ // altogether (see the universe note above), so there is no decision left
+ // to document. The retirement itself is pinned in one place —
+ // `packages/fields/src/__tests__/owner-retired.test.tsx`.
+ //
+ // Note this snapshot is derived from `ALL_TYPES`, not from
+ // `INLINE_ROUTED_FIELD_TYPES`, which still lists `'owner'`
+ // (`InlineFieldInput.tsx`). That member is now unreachable through this
+ // guard and is tracked in #4914, not forced away here.
routed: [
'address', 'audio', 'avatar', 'boolean', 'currency', 'date', 'datetime',
'file', 'geolocation', 'image', 'location', 'lookup', 'master_detail',
- 'multiselect', 'number', 'owner', 'percent', 'select', 'signature',
+ 'multiselect', 'number', 'percent', 'select', 'signature',
'tree', 'user', 'video',
],
delegated: ['checkboxes', 'code', 'color', 'json', 'progress', 'qrcode', 'radio', 'rating', 'slider', 'tags', 'time', 'toggle'],
diff --git a/packages/plugin-grid/src/components/bulkParamToField.ts b/packages/plugin-grid/src/components/bulkParamToField.ts
index 0bf9c701ac..9f57a3b319 100644
--- a/packages/plugin-grid/src/components/bulkParamToField.ts
+++ b/packages/plugin-grid/src/components/bulkParamToField.ts
@@ -39,8 +39,16 @@ const BULK_PARAM_TYPE_ALIASES: Record = {
/** Widget keys that render the record-picker family and need a reference target. */
const LOOKUP_WIDGET_TYPES = new Set(['lookup', 'master_detail']);
-/** Widget keys that render the person-picker family (target defaults to sys_user). */
-const USER_WIDGET_TYPES = new Set(['user', 'owner']);
+/**
+ * Widget keys that render the person-picker family (target defaults to sys_user).
+ *
+ * `owner` was a member until objectui#4814 retired the spelling (ruling A′): it
+ * was a synonym for `user` resolving to the same widget, and this set was one of
+ * the three code faces that had drifted apart on the word. It moves in lockstep
+ * with app-shell's `paramToField` — the twin this file mirrors — so the two
+ * param surfaces can never disagree about it again.
+ */
+const USER_WIDGET_TYPES = new Set(['user']);
/** Widget keys whose widget must be handed the grid's DataSource explicitly. */
const DATA_SOURCE_WIDGET_TYPES = new Set([...LOOKUP_WIDGET_TYPES, ...USER_WIDGET_TYPES]);