diff --git a/.changeset/8351-titleformat-hides-no-row.md b/.changeset/8351-titleformat-hides-no-row.md
new file mode 100644
index 0000000000..8dc1dbeb70
--- /dev/null
+++ b/.changeset/8351-titleformat-hides-no-row.md
@@ -0,0 +1,40 @@
+---
+'@object-ui/plugin-detail': patch
+---
+
+`record:details`' dedupe no longer hides a field row when the page H1 is an
+interpolated `titleFormat` that names no single field (objectui#8351, maintainer
+ruling 2026-09-08, decision batch #82, option B).
+
+**What changed for a user.** On an object declaring a `titleFormat`, a record
+whose title renders as a composite — `"{contract_no} - {name}"` rendering
+`HT-2026-001 - Acme Corporation` — now shows **every** field in the details grid.
+Before, one row silently disappeared: the ladder asked "which single field is the
+H1 showing?", got the ADR-0079 declared pointer as an answer, and hid that row —
+while the H1 was showing a template that is not that field's value, and is not any
+field's value. Nothing errored; a field was simply absent.
+
+**What did not change.** The dedupe still removes a genuine duplicate. Three cases
+are distinguished, and the rendered title decides which:
+
+- the template renders a composite no field's value equals → nothing is hidden;
+- the template renders nothing on this record (no placeholder resolved) → the
+ header has already walked past that rung onto the declared pointer, and the
+ existing ladder hides that row exactly as before;
+- the template collapses onto one field's value — a blank placeholder dropped with
+ its orphan separator, or a single-field format like `"{name}"` → that row *is*
+ the duplicate and is still hidden.
+
+"Fully interpolates" is measured with the instruments already in the chain, not a
+new predicate: `formatTitleTemplate` (what `getRecordDisplayName` step 3 and
+`DetailView.resolveDisplayTitle` step 2 both call) renders the title, and
+`recordDisplayValueAt` — the same emptiness authority the rest of the ladder uses
+— answers whether that string is some candidate's value.
+
+**Deliberately out of scope**, both by the same ruling: `page:header`'s own
+`schema.title`, which this package cannot see at all; and the order in which
+`PageHeaderRenderer` ranks `titleFormat` against the ADR-0079 name pointer, which
+would move what the H1 *shows* on existing records and retire a currently-green
+pin — a maintainer question carrying its own card. This fix deliberately does not
+depend on which of those two rungs wins the header: it compares the rendered title
+against the candidates' values, which answers the dedupe under either order.
diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatNoDedupe-8351.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatNoDedupe-8351.test.tsx
new file mode 100644
index 0000000000..cb915870c4
--- /dev/null
+++ b/packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatNoDedupe-8351.test.tsx
@@ -0,0 +1,234 @@
+/**
+ * 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.
+ */
+
+/**
+ * `record:details`' dedupe must not hide a row when the H1 is an interpolated
+ * `titleFormat` that names no single field (objectui#8351, ruled option B).
+ *
+ * ## The surface under test is the DEDUPE, not the title
+ *
+ * Same boundary as `record-details.nameFieldDedupe-8175.test.tsx`: this
+ * renderer draws the body grid, never the H1. Every case below therefore
+ * asserts which row RENDERS and which row DROPS.
+ *
+ * ## The defect
+ *
+ * The ladder is keyed on "which single FIELD is the H1 showing". A
+ * `titleFormat` rung answers with a rendered TEMPLATE, which on a multi-field
+ * format is no field's value at all. The ladder had no rung for it, so it
+ * walked straight on to `resolveNameField` and hid THAT row — a row whose
+ * value the H1 was not showing. The field simply vanished from the grid, and
+ * nothing errored.
+ *
+ * ## What "fully interpolates" is measured against (objectui#8351 ZONE 2)
+ *
+ * Not a new predicate. The ladder asks `formatTitleTemplate` — the SAME
+ * function `getRecordDisplayName`'s step 3 and this package's own
+ * `DetailView.resolveDisplayTitle` step 2 call — for the title the template
+ * renders on THIS record, and then asks `recordDisplayValueAt` whether that
+ * string IS some candidate's value:
+ *
+ * - renders a composite (no candidate's value equals it) -> hide NOTHING.
+ * That is the ruled case: "the H1 is not any single field's value, so
+ * there is no row to hide".
+ * - renders nothing (no placeholder resolved) -> the header has already
+ * walked past the template rung, so the ladder runs unchanged.
+ * - collapses onto ONE field's value (a blank placeholder was dropped, or
+ * the format names a single field) -> that row IS the duplicate the
+ * dedupe exists for, and it still goes.
+ *
+ * The last two are the lit controls: a rule that suppressed the dedupe on the
+ * mere PRESENCE of a resolving `titleFormat` prints "Contract No: HT-2026-003"
+ * directly under an H1 reading `HT-2026-003`, which is the exact duplication
+ * Phase P.0 of the ladder exists to remove.
+ *
+ * ⛔ Out of scope by the same ruling: `page:header`'s own `schema.title`, which
+ * this package cannot see, and the ORDER in which `PageHeaderRenderer` ranks
+ * `titleFormat` against the ADR-0079 pointer (its own card).
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import * as React from 'react';
+import { RecordContextProvider } from '@object-ui/react';
+import { RecordDetailsRenderer } from '../record-details';
+
+/**
+ * An object that declares BOTH a `nameField` and a two-field `titleFormat`.
+ * The two answer differently on every record below, so "which row is hidden"
+ * is answerable from the DOM alone.
+ */
+const contractSchema = {
+ name: 'contract',
+ label: 'Contract',
+ nameField: 'contract_no',
+ titleFormat: '{contract_no} - {name}',
+ fields: {
+ contract_no: { type: 'text', label: 'Contract No' },
+ name: { type: 'text', label: 'Name' },
+ amount: { type: 'number', label: 'Amount' },
+ },
+};
+
+const CONTRACT_FIELDS = ['contract_no', 'name', 'amount'];
+
+function renderBody(record: any, schema: any, fields: string[]) {
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ // `useRecordEditable` probes `POST /api/v1/security/explain` for the
+ // ROW-level verdict; happy-dom resolves that relative URL to a REAL socket,
+ // which the repo's network-escape guard fails the file for (objectui#6640).
+ // Serve it from a double — its answer is orthogonal to which row is hidden.
+ vi.stubGlobal('fetch', vi.fn(async () => ({
+ ok: true,
+ status: 200,
+ json: async () => ({ allowed: true }),
+ text: async () => '{"allowed":true}',
+ })) as never);
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe('record:details dedupe — an interpolated `titleFormat` hides no row (#8351)', () => {
+ /**
+ * The halves are separate cases ON PURPOSE, exactly as in the objectui#8175
+ * pin: asserting both inside one `it` lets the first failure short-circuit
+ * the second, so an ablation of the read site reddens one half and says
+ * nothing at all about the other.
+ */
+ it('HALF 1 — KEEPS the declared `nameField` row (the H1 is not its value)', () => {
+ renderBody(
+ { id: 'C1', contract_no: 'HT-2026-001', name: 'Acme Corporation', amount: 42 },
+ contractSchema,
+ CONTRACT_FIELDS,
+ );
+
+ // The H1 for this record reads `HT-2026-001 - Acme Corporation`. It is not
+ // `contract_no`'s value, so `contract_no` duplicates no heading — and the
+ // ladder used to hide it anyway, because `resolveNameField` names it.
+ expect(screen.getByText('HT-2026-001')).toBeInTheDocument();
+
+ // CONTROL — the grid rendered at all. Without it a build that rendered
+ // nothing would satisfy nothing here, and `queryByText === null` cases
+ // below are trivially true on an empty document.
+ expect(screen.getByText('42')).toBeInTheDocument();
+ expect(screen.getByText('Amount')).toBeInTheDocument();
+ });
+
+ it('HALF 2 — KEEPS the ordinary `name` row too (the template names no ONE field)', () => {
+ renderBody(
+ { id: 'C1', contract_no: 'HT-2026-001', name: 'Acme Corporation', amount: 42 },
+ contractSchema,
+ CONTRACT_FIELDS,
+ );
+
+ expect(screen.getByText('Acme Corporation')).toBeInTheDocument();
+
+ // CONTROL — the grid rendered at all.
+ expect(screen.getByText('42')).toBeInTheDocument();
+ });
+
+ it('LIT CONTROL A — a `titleFormat` that resolves to NOTHING leaves the ladder alone', () => {
+ // No placeholder resolves on this record, so `formatTitleTemplate` returns
+ // '' and the header has already walked PAST the template rung onto the
+ // declared pointer. The H1 reads `HT-2026-002`, and that row must still go.
+ renderBody(
+ { id: 'C2', contract_no: 'HT-2026-002', name: 'internal-name', amount: 7 },
+ { ...contractSchema, titleFormat: '{ref_a} - {ref_b}' },
+ CONTRACT_FIELDS,
+ );
+
+ expect(screen.queryByText('HT-2026-002')).toBeNull();
+ expect(screen.getByText('internal-name')).toBeInTheDocument();
+ expect(screen.getByText('7')).toBeInTheDocument(); // CONTROL: grid rendered
+ });
+
+ it('LIT CONTROL B — a template that COLLAPSES onto one field still hides that row', () => {
+ // `name` is blank, so `formatTitleTemplate` drops that placeholder and the
+ // orphan separator with it: the H1 reads exactly `HT-2026-003`. That IS
+ // `contract_no`'s value, so the row is a real duplicate and still goes.
+ // This is the case a presence-only rule gets wrong — it would print
+ // "Contract No: HT-2026-003" directly under an identical H1.
+ renderBody(
+ { id: 'C3', contract_no: 'HT-2026-003', name: '', amount: 9 },
+ contractSchema,
+ CONTRACT_FIELDS,
+ );
+
+ expect(screen.queryByText('HT-2026-003')).toBeNull();
+ expect(screen.getByText('9')).toBeInTheDocument(); // CONTROL: grid rendered
+ });
+
+ it('LIT CONTROL C — a single-field `titleFormat` is that field, and still dedupes', () => {
+ // `{name}` renders exactly `name`'s value. "Interpolated" does not mean
+ // "composite": the ruled reason is that the H1 names no ONE field, and
+ // here it names exactly one.
+ renderBody(
+ { id: 'C4', name: 'Acme Corporation', amount: 5 },
+ {
+ name: 'plain',
+ label: 'Plain',
+ titleFormat: '{name}',
+ fields: {
+ name: { type: 'text', label: 'Name' },
+ amount: { type: 'number', label: 'Amount' },
+ },
+ },
+ ['name', 'amount'],
+ );
+
+ expect(screen.queryByText('Acme Corporation')).toBeNull();
+ expect(screen.getByText('5')).toBeInTheDocument(); // CONTROL: grid rendered
+ });
+
+ it('hides the row the TEMPLATE names, not the one the declared pointer names', () => {
+ // `titleFormat` is `{name}` while `nameField` points at `contract_no`. The
+ // H1 reads `Acme Corporation`. The ladder's first candidate WITH A VALUE
+ // is `contract_no` — so a walk that stops at the first resolving candidate
+ // hides `HT-2026-005`, a row the H1 never showed, and leaves the real
+ // duplicate printed underneath. Both halves wrong at once, the objectui#8175
+ // shape one rung further along.
+ renderBody(
+ { id: 'C5', contract_no: 'HT-2026-005', name: 'Acme Corporation', amount: 3 },
+ { ...contractSchema, titleFormat: '{name}' },
+ CONTRACT_FIELDS,
+ );
+
+ expect(screen.queryByText('Acme Corporation')).toBeNull();
+ expect(screen.getByText('HT-2026-005')).toBeInTheDocument();
+ expect(screen.getByText('3')).toBeInTheDocument(); // CONTROL: grid rendered
+ });
+
+ it('CONTROL — an object with NO `titleFormat` dedupes exactly as before', () => {
+ // The objectui#8175 ladder, untouched. Without this case, a change that
+ // disabled the H1 dedupe outright would pass every assertion above.
+ renderBody(
+ { id: 'C6', contract_no: 'HT-2026-006', name: 'internal-name', amount: 11 },
+ { ...contractSchema, titleFormat: undefined },
+ CONTRACT_FIELDS,
+ );
+
+ expect(screen.queryByText('HT-2026-006')).toBeNull();
+ expect(screen.getByText('internal-name')).toBeInTheDocument();
+ expect(screen.getByText('11')).toBeInTheDocument(); // CONTROL: grid rendered
+ });
+});
diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx
index d16371f23d..badbb75bdd 100644
--- a/packages/plugin-detail/src/renderers/record-details.tsx
+++ b/packages/plugin-detail/src/renderers/record-details.tsx
@@ -18,6 +18,7 @@ import type { RecordDetailsComponentProps } from '@object-ui/types';
import {
columnIdentity,
deriveTitleField,
+ formatTitleTemplate,
isObjectInlineEditable,
recordDisplayValueAt,
resolveNameField,
@@ -344,14 +345,26 @@ export const RecordDetailsRenderer: React.FC = ({
// lets the fall-through happen here too. When nothing is declared the two
// return the same name and the first one simply wins.
//
- // ⚠️ Two disagreements with the header chain are KNOWN and deliberately NOT
- // repaired here — both are rungs that name no FIELD, so there is no row to
- // hide for either, and closing them is a separate ruling:
- // - `page:header`'s own `schema.title`, which this package cannot see;
- // - `objectSchema.titleFormat`, a render-only template the header ranks
- // ABOVE the declared pointer (pinned in `@object-ui/components`'
- // `__tests__/page-header-title.test.tsx`, "titleFormat still outranks
- // nameField"), interpolating any number of fields.
+ // ⚠️ Two rungs of the header chain name no FIELD at all, so a dedupe keyed
+ // on "which single field is the H1" is structurally unable to answer them
+ // (objectui#8351). They are NOT symmetric and only ONE is answered here:
+ // - `objectSchema.titleFormat` — ANSWERED, below, by the ruled option B:
+ // the template's rendered output is compared against the candidates'
+ // values, and a composite that is no field's value hides no row.
+ // - `page:header`'s own `schema.title` — NOT answered, and not answerable
+ // from this package: it is a key on the HEADER schema, which
+ // `record:details` never receives. Same shape, its own card.
+ //
+ // ⛔ Neither of those is the ORDER question. `PageHeaderRenderer` ranks the
+ // interpolated `titleFormat` ABOVE the ADR-0079 declared pointer, while
+ // `getRecordDisplayName` documents it BELOW (step 3) — pinned green in
+ // `@object-ui/components`' `__tests__/page-header-title.test.tsx` as
+ // "titleFormat still outranks nameField". Closing that divergence moves what
+ // the H1 SHOWS on existing records and retires that pin, so it is a
+ // maintainer ruling and carries its own `needs-user-decision` card. This
+ // ladder deliberately does not depend on which of the two wins: it asks
+ // whether the rendered template IS some candidate's value, which answers the
+ // dedupe under either order.
//
// ⛔ `objSchema?.primaryField` used to top this list, and it is gone
// (objectui#7586). It is a `DetailViewSchema` key (`@object-ui/types`
@@ -406,10 +419,61 @@ export const RecordDetailsRenderer: React.FC = ({
// down: it would still disagree with the header about an expanded lookup
// object whose display chain yields nothing (`{ id: 'u1' }` is not a title),
// which the raw test — and a trim of it — both read as a value.
- for (const candidate of titleCandidates) {
- if (recordDisplayValueAt(data, candidate) !== undefined) {
- hideFieldNames.add(candidate);
- break;
+ //
+ // ⭐ THE `titleFormat` RUNG (objectui#8351, maintainer ruling option B).
+ //
+ // A `titleFormat` H1 is a rendered TEMPLATE, not a field. On a multi-field
+ // format it is no single field's value, so NO row duplicates it and the walk
+ // below must not run at all — it would hide `resolveNameField`'s row, a row
+ // the H1 never showed, exactly the "a field silently vanished" shape
+ // objectui#8175 closed one rung higher.
+ //
+ // ⚠️ "Fully interpolates" is measured, not assumed, and it is measured with
+ // the instruments already here — no new predicate, which is the same rule
+ // the emptiness note below states:
+ // - `formatTitleTemplate` is THE renderer of this rung. It is what
+ // `getRecordDisplayName` step 3 calls and what this package's own
+ // `DetailView.resolveDisplayTitle` step 2 calls, so all three agree
+ // about what the template produces on a given record.
+ // - `recordDisplayValueAt` then answers the only question a dedupe has:
+ // is that string some candidate's value?
+ //
+ // Three outcomes, and the two that are NOT the ruled case are what keep this
+ // honest:
+ // - composite (no candidate's value equals it) → hide NOTHING. The ruled
+ // case: "the H1 is not any single field's value, so there is no row to
+ // hide".
+ // - empty (no placeholder resolved on this record) → the header has
+ // ALREADY walked past this rung onto the declared pointer, so the
+ // value-keyed walk below runs unchanged. Suppressing on the mere
+ // PRESENCE of a `titleFormat` would blind the dedupe on every record
+ // where the template renders nothing.
+ // - collapsed onto ONE field's value (a blank placeholder was dropped with
+ // its orphan separator, or the format names a single field) → that row
+ // IS the duplicate, and it still goes. A presence-only rule prints
+ // "Contract No: HT-0001" directly beneath an H1 reading `HT-0001`, which
+ // is the duplication Phase P.0 exists to remove.
+ //
+ // Pinned in `__tests__/record-details.titleFormatNoDedupe-8351.test.tsx`,
+ // which asserts which row RENDERS and which row DROPS — never the heading,
+ // which this package does not draw.
+ //
+ // ⚠️ The match is a SCAN of the candidates, not a peek at the first one with
+ // a value: with `titleFormat: '{name}'` over `nameField: 'contract_no'` the
+ // first resolving candidate is `contract_no` and the H1 is `name`'s value,
+ // so stopping early would hide the wrong row AND leave the real duplicate.
+ const interpolatedTitle = formatTitleTemplate(objSchema?.titleFormat, data);
+ if (interpolatedTitle) {
+ const shownAs = titleCandidates.find(
+ (candidate) => recordDisplayValueAt(data, candidate) === interpolatedTitle,
+ );
+ if (shownAs) hideFieldNames.add(shownAs);
+ } else {
+ for (const candidate of titleCandidates) {
+ if (recordDisplayValueAt(data, candidate) !== undefined) {
+ hideFieldNames.add(candidate);
+ break;
+ }
}
}