diff --git a/.changeset/7997-detail-view-related-retired.md b/.changeset/7997-detail-view-related-retired.md
new file mode 100644
index 0000000000..c8437716cf
--- /dev/null
+++ b/.changeset/7997-detail-view-related-retired.md
@@ -0,0 +1,74 @@
+---
+'@object-ui/types': minor
+'@object-ui/plugin-detail': minor
+---
+
+`DetailViewSchema.related` is retired — author a `record:related_list` block
+(objectui#7997, ADR-0049 enforce-or-remove; maintainer ruling 2026-09-10).
+
+**Breaking, and graded `minor` by this repo's convention** — a `major` would drag
+the whole 39-package fixed group off `@objectstack`'s cadence. A `detail-view`
+node authoring `related` used to parse **green** and render a Related section; it
+now reds at that key on both faces, and the renderer draws nothing from it.
+
+**What retired is a DOOR, not the capability.** `record:related_list` is
+unchanged and is now the only **declared, protocol-governed** entry
+(`@objectstack/spec` `RecordRelatedListProps`); it has always rendered through
+the same `RelatedList` component the retired array fed, so nothing about the
+rendered result is lost. ⚠️ Not the only entry full stop — `plugin-detail`
+still registers a bare `related-list` node against the same component with
+untyped `columns`, and that registration is out of this card's scope and
+untouched.
+
+| before, on a `detail-view` node | after |
+| --- | --- |
+| `related: [{ title, type, api, columns: [{ accessorKey, header }] }]` | a `record:related_list` node: `{ objectName, relationshipField, title, columns: ['name', 'email'] }` |
+
+⚠️ `columns` on the surviving entry is an array of **field-name strings**, which
+is what the protocol declares. The header is derived from the related object's
+field `label` and the cell from the field's type, so a label rename reaches the
+list for free — the hand-spelled `{ accessorKey, header }` form froze both.
+`relationshipField` names the field on the related object that points back at
+this record, and replaces the retired form's `api` endpoint.
+
+**Why it retired.** `@objectstack/spec` declares no `DetailView` schema at all —
+every `DetailView` occurrence in `packages/spec/src` is prose about this repo's
+own `RecordDetailView.tsx` — so this array mirrored no protocol schema and
+drifted freely: it declared `columns` as `TableColumn[]` while the renderer it
+fed also accepted bare field names, `{ field, label }` and legacy
+`{ name, label }` spellings. The axis that carried the ruling was measured **zero
+pull**: no application code authored the member, both internal producers of a
+`detail-view` node (`RecordDetailDrawer`, `renderers/record-details.tsx`)
+synthesize it without `related`, and the only in-tree authorings carrying real
+columns were two documents — both rewritten here.
+
+**A named refusal, not a deletion.** `BaseSchemaCore` ends `.passthrough()` and
+the TypeScript `BaseSchema` closes with an any-valued index signature, so a
+*dropped* member key is kept, not refused — deleting the declaration would have
+left the silent accept exactly as it was. The key stays declared and unwritable:
+`retirementTombstone()` on the Zod face, `?: never` on the TypeScript face, one
+guidance string feeding both the parse-time message and `.describe()`. A pin
+authors an undeclared sibling key through the same parse and watches it survive,
+so "a bare delete would not have refused it" is a reading rather than a claim.
+
+**What moved in `@object-ui/plugin-detail`.** `DetailView` no longer reads
+`schema.related`: the flat Related section, the `autoTabs` Related tab, its
+trigger and its count badge are gone, and `related` is off the `detail-view`
+registry's `inputs` and `defaultProps`. `RelatedList` itself, the
+`related-list` / `related_list` registrations and `record:related_list` are
+untouched.
+
+**Documentation.** `packages/plugin-detail/README.md` and
+`content/docs/api/schema-reference.md` stop teaching the retired array and gain a
+migration block each.
+
+⚠️ The docs page had been teaching `{ name, label }` columns, and the two faces
+disagreed about that shape: the retired **TypeScript** declaration never
+admitted it (`TableColumn` requires `header` **and** `accessorKey`), while the
+retired **zod mirror** did — it spelled the member `z.array(z.any())` — so the
+JSON document that page taught parsed green and rendered. The page was wrong for
+a **typed** author and right for a **JSON** author, which is a sharper defect
+than a single wrong example: the two authoring faces of one member disagreed
+about what a column is. Retiring the member closes that split at the source, and
+the page now teaches `record:related_list`, whose `columns` is
+`z.array(z.string())` on both faces.
diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md
index 327e7e2714..f80dd425fa 100644
--- a/content/docs/api/schema-reference.md
+++ b/content/docs/api/schema-reference.md
@@ -1083,7 +1083,7 @@ objectui#5667: nothing read them on the authored-node path.
### DetailViewSchema
-An enhanced detail view for a single record with sections, tabs, related records, and navigation.
+An enhanced detail view for a single record with sections, tabs and navigation.
```json
{
@@ -1122,18 +1122,6 @@ An enhanced detail view for a single record with sections, tabs, related records
"content": { "type": "timeline", "events": [] }
}
],
- "related": [
- {
- "title": "Recent Orders",
- "type": "table",
- "api": "/api/contacts/contact-123/orders",
- "columns": [
- { "name": "id", "label": "Order #" },
- { "name": "total", "label": "Total" },
- { "name": "status", "label": "Status" }
- ]
- }
- ],
"actions": [
{ "type": "action", "label": "Send Email", "icon": "Mail", "level": "primary" }
]
@@ -1152,13 +1140,46 @@ An enhanced detail view for a single record with sections, tabs, related records
| `sections` | `DetailViewSection[]` | Field groups with `title`, `icon`, `fields`, `collapsible`. |
| `fields` | `DetailViewField[]` | Direct fields (without sections). |
| `tabs` | `DetailViewTab[]` | Tabbed content with `key`, `label`, `icon`, `badge`, `content`. |
-| `related` | `array` | Related record sections with `title`, `type`, `api`, `columns`. |
+| `related` | ⛔ **RETIRED** | Retired in objectui#7997 (ADR-0049 enforce-or-remove). Authoring it is now refused by name on both faces. Author a `record:related_list` block instead — see below. |
| `actions` | `ActionSchema[]` | Available actions. |
| `showBack` / `backUrl` | `boolean` / `string` | Back navigation. |
| `showEdit` / `editUrl` | `boolean` / `string` | Edit navigation. |
| `showDelete` / `deleteConfirmation` | `boolean` / `string` | Delete with confirmation message. |
| `header` / `footer` | `SchemaNode` | Custom header/footer content. |
+> **Retired: `related`** (objectui#7997, ADR-0049 enforce-or-remove).
+> Author a `record:related_list` block instead.
+>
+> Until objectui#7997 this block carried its own `related` array, and this page
+> taught it with `{ "name": ..., "label": ... }` columns. That array is retired
+> under ADR-0049 enforce-or-remove: it was a second entry to a capability
+> `@objectstack/spec` already governs, it mirrored no protocol schema, and it
+> drifted from the renderer it fed. Authoring it is now **refused by name** on
+> both the TypeScript and the JSON face — it is not silently ignored.
+>
+> Related lists have one declared entry now, and it renders through the same component:
+>
+> ```json
+> {
+> "type": "record:related_list",
+> "objectName": "order",
+> "relationshipField": "contact_id",
+> "title": "Recent Orders",
+> "columns": ["id", "total", "status"]
+> }
+> ```
+>
+> ⚠️ `columns` here is an array of **field-name strings**, not column objects —
+> that is what the protocol declares (`RecordRelatedListProps.columns`), and the
+> header and cell formatting are derived from the related object's schema, so a
+> field label rename reaches the list for free. `relationshipField` names the
+> field on the RELATED object that points back at this record, and replaces the
+> retired form's `api` endpoint.
+>
+> ⚠️ The block reads the parent record from the record page's `RecordContext`,
+> so author it on a record page. Placed anywhere it cannot resolve a parent id
+> it scopes to nothing and renders an empty list.
+
**Related:** [DetailSchema](#detailschema), [ObjectViewSchema](#objectviewschema)
---
diff --git a/packages/app-shell/src/utils/deriveRelatedLists.ts b/packages/app-shell/src/utils/deriveRelatedLists.ts
index c669a444fd..f8f0e88ad5 100644
--- a/packages/app-shell/src/utils/deriveRelatedLists.ts
+++ b/packages/app-shell/src/utils/deriveRelatedLists.ts
@@ -10,7 +10,10 @@
* This helper scans every object for fields whose `reference`/`reference_to`
* points back at the parent object and produces one related-list descriptor per
* eligible FK. The detail page (`RecordDetailView`) feeds these into the
- * `record:related_list` renderers (and the legacy `DetailView.related`).
+ * `record:related_list` renderers. (It also fed `DetailView.related`, which is
+ * RETIRED as of objectui#7997 — that entry is a `?: never` tombstone on both
+ * faces now, and this helper's output reaches the page only as
+ * `record:related_list` nodes.)
*
* Rules (kept in lockstep with the relationship-level `relatedList` spec flag):
* - Owned children (`master_detail`) and `lookup` children are SHOWN by
diff --git a/packages/plugin-detail/README.md b/packages/plugin-detail/README.md
index ac9fca29eb..c0f4bc64c9 100644
--- a/packages/plugin-detail/README.md
+++ b/packages/plugin-detail/README.md
@@ -7,7 +7,9 @@ DetailView plugin for ObjectUI - A comprehensive detail page component with fiel
- **Field Grouping/Sections**: Organize fields into logical sections with titles
- **Collapsible Sections**: Make sections collapsible to save space
- **Tab Navigation**: Organize content into tabs for better UX
-- **Related Lists**: Display related records (e.g., contacts for an account)
+- **Related Lists**: Display related records (e.g., contacts for an account) —
+ authored as a `record:related_list` block, ⛔ not as a `detail-view` `related`
+ array, which is retired (objectui#7997; see **RelatedList** below)
- **Action Buttons**: Edit, Delete, and custom action buttons
- **Readonly/Edit Mode**: Toggle between view and edit modes
- **Back Navigation**: Built-in back button with customizable behavior
@@ -128,7 +130,7 @@ const accountDetail = ;
```
-### With Tabs and Related Lists
+### With Tabs
```tsx
import { DetailView } from '@object-ui/plugin-detail';
@@ -170,30 +172,6 @@ const accountDetail = **Retired: `DetailViewSchema.related`** (objectui#7997, ADR-0049
+> enforce-or-remove). Author a `record:related_list` block instead.
+>
+> Until objectui#7997 a `detail-view` node could carry its own `related` array,
+> and this README taught it. That array is retired under ADR-0049
+> enforce-or-remove (maintainer ruling 2026-09-10). It was a second entry to a
+> capability `@objectstack/spec` already governs — the protocol declares no
+> `DetailView` schema at all — so it mirrored nothing and drifted from the
+> renderer it fed: it typed `columns` as `TableColumn` objects while the
+> renderer also accepted bare field names. Authoring it is now **refused by
+> name** on both the TypeScript and the JSON face, ⛔ not silently ignored.
+>
+> Nothing about the rendered result changed: both entries always went through
+> the `RelatedList` component documented here. Only the second door closed.
+>
+> ```tsx
+> import { SchemaRenderer } from '@object-ui/react';
+>
+> const contacts = (
+> schema={{
+> type: 'record:related_list',
+> objectName: 'contact',
+> relationshipField: 'account_id',
+> title: 'Contacts',
+> columns: ['name', 'email', 'phone'],
+> }}
+> />
+> );
+> ```
+>
+> ⚠️ `columns` is an array of **field-name strings**, which is what the protocol
+> declares (`RecordRelatedListProps.columns`). The header comes from the related
+> object's field `label` and the cell from the field's type, so a label rename
+> reaches the list for free — the hand-spelled `{ accessorKey, header }` form the
+> retired array taught froze both. `relationshipField` names the field on the
+> related object pointing back at this record, and replaces the retired form's
+> `api` endpoint.
+>
+> ⚠️ The block reads the parent record from the record page's `RecordContext`,
+> so author it on a record page. Placed anywhere it cannot resolve a parent id
+> it scopes to nothing and renders an empty list — which is also what the
+> retired `related` form did whenever it declared `api` without a
+> `referenceField`.
+
+
Related lists are **paged by default**: the `record:related_list` renderer
applies the spec default `limit` of **5** when the node doesn't declare one
(`@objectstack/spec` `RecordRelatedListProps.limit`, "Number of records to
diff --git a/packages/plugin-detail/src/DetailView.tsx b/packages/plugin-detail/src/DetailView.tsx
index 6e2fdb7215..bd87d7fe14 100644
--- a/packages/plugin-detail/src/DetailView.tsx
+++ b/packages/plugin-detail/src/DetailView.tsx
@@ -20,7 +20,6 @@ import {
TabsList,
TabsTrigger,
TabsContent,
- useIsMobile,
} from '@object-ui/components';
import {
ArrowLeft,
@@ -37,7 +36,6 @@ import {
} from 'lucide-react';
import { DetailSection } from './DetailSection';
import { DetailTabs } from './DetailTabs';
-import { RelatedList } from './RelatedList';
import { SectionGroup } from './SectionGroup';
import { HeaderHighlight } from './HeaderHighlight';
import { RecordComments } from './RecordComments';
@@ -56,8 +54,6 @@ import { hasCellValue } from './emptiness';
import { enrichDetailField } from './fieldEnrichment';
import { chipTakesCellRenderer } from './summaryChipRenderers';
-/** Default page size for related lists in the detail view */
-const DEFAULT_RELATED_PAGE_SIZE = 5;
/** Stable empty draft so the section `data`-merge identity is preserved when
* no is mounted (bare / read-only DetailView). */
@@ -298,7 +294,6 @@ export const DetailView: React.FC = ({
// Tenant default currency (ADR-0053) for summary metrics whose field omits one.
const { currency: tenantCurrency } = useLocalization();
const { fieldOptionLabel } = useSafeFieldLabel();
- const isMobile = useIsMobile();
// Field-level permission gate. Filter section.fields and top-level
// fields based on the current user's read permissions BEFORE any
@@ -845,18 +840,22 @@ export const DetailView: React.FC = ({
return () => document.removeEventListener('keydown', handler);
}, [schema.recordNavigation]);
+ // objectui#7997 — the `related` ENTRY on this node is RETIRED (ADR-0049
+ // enforce-or-remove, maintainer ruling 2026-09-10: 「关掉详情页那个入口(推荐)」).
+ // `DetailViewSchema.related` is a `?: never` tombstone on the TypeScript face
+ // and a `retirementTombstone()` arm on the zod mirror, so this component no
+ // longer reads it and no longer renders a Related tab or a Related section.
+ //
+ // ⛔ The capability did not retire, only this door: author a
+ // `record:related_list` block, which is the protocol-governed entry
+ // (@objectstack/spec `RecordRelatedListProps`) and which has always rendered
+ // through the same `RelatedList` component this branch used — see
+ // `renderers/record-related-list.tsx`.
+ //
// Auto-discovery of related panels via INVERSE references (other objects
- // whose FK points to the current record) is the responsibility of the
- // page layer (e.g. RecordDetailView), which has access to the registry of
- // all objects. We deliberately do NOT auto-derive related panels from the
- // current object's *forward* lookups (account, owner, …) — those are
- // parent references already surfaced as detail fields, and listing them
- // here always produces empty 0-count panels with no usable "+ New" CTA
- // (the new child wouldn't have an FK to back-fill). Leaving them out
- // avoids the misleading "为什么有的能新建有的不能" experience.
- const effectiveRelated: NonNullable = React.useMemo(() => {
- return schema.related ?? [];
- }, [schema.related]);
+ // whose FK points at the current record) was never this component's job
+ // either; it belongs to the page layer (e.g. RecordDetailView), which has the
+ // registry of all objects.
/**
* Chrome-level "system" actions (Duplicate, Export, View History, Delete,
@@ -1592,7 +1591,6 @@ export const DetailView: React.FC = ({
When only the Details tab would render (no related, no activity, no
discussion), skip the Tabs strip entirely — it's pure visual noise. */}
{schema.autoTabs && !schema.tabs?.length ? (() => {
- const hasRelated = effectiveRelated.length > 0;
const hasActivity = !!schema.activities && schema.activities.length > 0;
const hasDiscussion = !!discussionSlot;
const hasHistory = !!schema.history;
@@ -1603,7 +1601,6 @@ export const DetailView: React.FC = ({
// in Radix's uncontrolled state.
const tabValues = [
'details',
- ...(hasRelated ? ['related'] : []),
...(hasActivity ? ['activity'] : []),
...(hasDiscussion ? ['discussion'] : []),
...(hasHistory ? ['history'] : []),
@@ -1673,7 +1670,7 @@ export const DetailView: React.FC = ({
);
- if (!hasRelated && !hasActivity && !hasDiscussion && !hasHistory) {
+ if (!hasActivity && !hasDiscussion && !hasHistory) {
// Single-tab case: render just the details content without a tab strip.
return