diff --git a/.changeset/6837-gantt-tree-referenceto-arms.md b/.changeset/6837-gantt-tree-referenceto-arms.md
new file mode 100644
index 0000000000..ca7ce11a55
--- /dev/null
+++ b/.changeset/6837-gantt-tree-referenceto-arms.md
@@ -0,0 +1,53 @@
+---
+'@object-ui/plugin-gantt': minor
+'@object-ui/plugin-tree': minor
+---
+
+`ObjectGantt` and `ObjectTree` resolve a relationship target only from the two
+spellings a contract carries, dropping the third one no contract declares
+(objectui#6837, second slice).
+
+- `ObjectGantt`'s quick-filter option fetch was
+ `fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`; it is now
+ `fd?.reference_to ?? fd?.reference`.
+- `ObjectTree`'s `detectParentField` was
+ `def?.reference || def?.reference_to || def?.referenceTo`; it is now
+ `def?.reference || def?.reference_to`.
+
+**Accept-set move — a def carrying ONLY `referenceTo` stops resolving a target
+at these two seams.** Concretely: the gantt quick filter for that field falls
+back to the distinct values present in the loaded rows instead of fetching the
+referenced object's full domain, and the tree stops auto-detecting that field as
+its parent pointer, so records render as a flat forest unless `parentField` is
+configured explicitly. Nothing else changes; the two surviving arms are
+untouched.
+
+Two things bound that move:
+
+- Any def that entered through the ingestion choke point is unaffected.
+ `normalizeSchemaReferenceKeys` reads `reference_to ?? reference ??
+ referenceTo` and stamps both snake_case keys, so a `referenceTo`-only def
+ arriving via `MetadataProvider` or `ObjectStackAdapter.getObjectSchema`
+ already carries `reference_to` before either component sees it. Only a def
+ that bypassed that door entirely is affected — and that door is not total:
+ `getObjectSchema` is a required member of the published `DataSource`
+ interface, and both components call it on the generic `dataSource`.
+- No contract declares the deleted spelling. `@objectstack/spec` 17.2.0's
+ `FieldSchema` refuses `referenceTo` by name with `unrecognized_keys`, carrying
+ its own "Did you mean `referenceTo` -> `reference`?" rename, and `referenceTo`
+ is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES` (objectui#6041)
+ at all three strip sites, so the designer read door removes it before a draft
+ round-trips.
+
+A repo-wide structure-walk producer census found **0** emitters of `referenceTo`
+reaching either seam, measured in the cell these components read (a value inside
+an object schema's `fields` container) against controls `reference` (92 hits / 36
+files) and `reference_to` (52 / 36) hot in the same pass over the same cells;
+the only two in-cell hits are negative fixtures of the retirement machinery,
+asserting the read door strips the key. Neither `plugin-gantt` nor `plugin-tree`
+emits `referenceTo` anywhere, while both packages' own fixtures are hot on the
+surviving spellings.
+
+Pinned by `ObjectGantt.referenceArms-6837.test.tsx` and
+`ObjectTree.referenceArms-6837.test.tsx`, which keep the live arms green beside a
+named refusal for the deleted key.
diff --git a/packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx b/packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx
new file mode 100644
index 0000000000..f80735933d
--- /dev/null
+++ b/packages/plugin-gantt/src/ObjectGantt.referenceArms-6837.test.tsx
@@ -0,0 +1,285 @@
+/**
+ * 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.
+ */
+
+/**
+ * objectui#6837 (second slice) — the gantt quick-filter's relationship-target
+ * chain drops the arm NO CONTRACT DECLARES, and keeps the two that carry the
+ * value.
+ *
+ * Before: `fd?.reference_to ?? fd?.reference ?? fd?.referenceTo`
+ * After: `fd?.reference_to ?? fd?.reference`
+ *
+ * Form copied from `RecordDetailDrawer.referenceArms-6837.test.tsx` (PR #6920),
+ * which copied it from PR #6916 / card #6840. ⛔ Do not invent a second form.
+ *
+ * ## 1. The measurement this pin stands on (not just its conclusion)
+ *
+ * THE CELL: a value inside an object schema's `fields` container — literally
+ * what this component reads, `objectSchema.fields[name]`. Producer census by
+ * STRUCTURE WALK (TypeScript compiler API over every tracked `.ts`/`.tsx`, plus
+ * parsed JSON), recording each hit's ancestor property chain; EMIT positions
+ * only (`PropertyAssignment` / `ShorthandPropertyAssignment`), so `fd.referenceTo`
+ * — a `PropertyAccessExpression`, i.e. a READ — is never counted as a producer,
+ * and a `PropertySignature` is bucketed as a DECLARATION, never as one either.
+ * Subject and control were extracted BY THE SAME PASS, FROM THE SAME CELLS, IN
+ * THE SAME UNITS, so the control sits on the JOIN and not merely on the terms.
+ *
+ * | term | role | repo-wide emits | IN THE CELL |
+ * |----------------|---------|------------------|-------------|
+ * | `referenceTo` | SUBJECT | 81 / 42 files | **2** / 2 |
+ * | `reference` | CONTROL | 195 / 76 files | 92 / 36 |
+ * | `reference_to` | CONTROL | 137 / 88 files | 52 / 36 |
+ *
+ * Both halves of the control discipline. (1) THE QUERY RAN: the controls are
+ * hot — 92 and 52 — in the very cells where the subject collapses to 2, from
+ * the same pass. (2) THE QUESTION WAS RIGHT: a mis-posed cell would have moved
+ * subject and control together; instead it separates 92-to-2. Third check, the
+ * one only this key affords: `referenceTo` is not a term the scanner cannot
+ * see — it is hot repo-wide at 81 emits across 42 files, and collapses to 2
+ * only under the cell restriction. The zero-ish is produced by the RESTRICTION,
+ * not by scanner blindness.
+ *
+ * The two surviving in-cell hits are NEGATIVE fixtures of the retirement
+ * machinery itself (`object-fields-io.spec-keys.test.ts:235`,
+ * `MetadataFieldsPage.specKeyReference.test.tsx:75`): they poison a draft with
+ * the retired key precisely to assert the read door STRIPS it before
+ * `ObjectSchema.safeParse` sees it. A fixture asserting removal is not a
+ * producer.
+ *
+ * SEAM-LOCAL control, the one this file owes over and above the repo-wide pass:
+ * `plugin-gantt` contains **zero** `referenceTo` emits at any position, in any
+ * cell — while its own fixture corpus is hot on both surviving spellings
+ * (`ObjectGantt.quickfilter.test.tsx:251` emits `reference_to`, `:306` emits
+ * `reference`, `demo/main.tsx:334-335` emit `reference_to`). So the corpus that
+ * actually feeds THIS reader is hot on what survives and empty on what goes.
+ *
+ * ## 2. Why refusal is correct, not merely unused-today
+ *
+ * `@objectstack/spec` 17.2.0's `FieldSchema` (`@objectstack/spec/data`), probed
+ * two-directionally on this branch's installed copy:
+ *
+ * - `reference: 'crm_account'` → ACCEPT
+ * - `reference_to: 'crm_account'` → REFUSE, `unrecognized_keys`
+ * - `referenceTo: 'crm_account'` → REFUSE, `unrecognized_keys`,
+ * "Did you mean `referenceTo` → `reference`?"
+ *
+ * The alias entry is a RENAME HINT ATTACHED TO A REFUSAL, not an acceptance:
+ * the spec names `referenceTo` explicitly in order to refuse it. `referenceTo`
+ * is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
+ * (`@object-ui/types/internal/retired-field-keys`, `retiredBy: 'objectui#6041'`,
+ * `specEquivalent: 'reference'`) at all three strip sites, so the designer read
+ * door removes it before a draft round-trips. So this arm was not a "redundant"
+ * fallback: it was INVENTED tolerance surface — a silent absorption point for a
+ * producer that ought to fail visibly (AGENTS.md #0.1).
+ *
+ * ⚠️ What this does NOT rest on: any claim that no production producer of
+ * `reference_to` exists. That question cannot be answered from inside this repo
+ * — restricting the cell to production files collapses the CONTROL too, and
+ * this repo is a UI library, not a metadata-app repo. `reference_to` and
+ * `reference` are therefore deliberately untouched here; see §4.
+ *
+ * ## 3. No precedence inversion exists here — stated rather than fabricated
+ *
+ * The deleted arm sat at the END of the chain
+ * (`reference_to ?? reference ?? referenceTo`), so it could never preempt a
+ * contract-carrying spelling. There is therefore NO inversion case to pin, and
+ * this file deliberately does not invent one: a
+ * `{ reference: 'projects', referenceTo: 'other' }` case resolves to
+ * `'projects'` both before and after the change and would measure nothing.
+ * (Same call, for the same reason, as PR #6916 and PR #6920.)
+ *
+ * ## 4. THE FLOOR, restated where someone would try to re-widen it
+ *
+ * ⛔ Do not re-add a spelling arm to this chain. A producer emitting a refused
+ * spelling is fixed AT THE PRODUCER, or canonicalised ONCE at the ingestion
+ * choke point — `normalizeSchemaReferenceKeys`, which stamps both snake_case
+ * keys from whichever spelling arrived. Never a renderer-side alias: that is
+ * how ~20 per-consumer dual-key fallbacks got written under a normalizer whose
+ * own docstring says it exists "so per-consumer dual-key fallbacks can't drift".
+ *
+ * ⛔ The two SURVIVING arms are out of this slice's scope. Choosing between
+ * `reference_to` and `reference` per reader is objectui#6837's OPEN scope, and
+ * its classification table measured why a mechanical sweep would be wrong: the
+ * ObjectUI-side contracts (`DetailViewFieldSchema`, `LookupFieldMetadata`,
+ * report columns, designer fields, related-list config) declare `reference_to`,
+ * `referenceTo` and `referenceField` but NONE of them declares `reference` —
+ * these readers sit on a TIER BOUNDARY rather than choosing between a legacy
+ * and a canonical spelling of one key. #6837 stays open.
+ *
+ * ## 5. Ablation direction, predicted before running
+ *
+ * Restore the deleted arm on the committed tree and the refusal below goes RED
+ * while every live-arm control stays GREEN — that contrast is what makes the
+ * controls controls rather than duplicates of the pins. MODULE RESOLUTION: this
+ * file imports the component by RELATIVE SOURCE PATH (`./ObjectGantt`) and
+ * `@object-ui/core` is aliased by the root `vitest.config.mts` to
+ * `packages/core/src`, so both legs resolve to SOURCE — no package `exports`
+ * hop, no `dist`, and therefore NO REBUILD LEG to get wrong.
+ */
+import React from 'react';
+import { render, fireEvent, waitFor, within, cleanup } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { normalizeSchemaReferenceKeys } from '@object-ui/core';
+import { ObjectGantt } from './ObjectGantt';
+
+afterEach(cleanup);
+
+/**
+ * GanttView is mocked to a thin shell that surfaces the task count, exactly as
+ * `ObjectGantt.quickfilter.test.tsx` does — the resolved target is a property
+ * of the option fetch, not of how GanttView paints bars.
+ */
+vi.mock('./GanttView', () => ({
+ GanttView: ({ tasks }: any) => (
+
+ ),
+}));
+
+/** Both loaded rows point at `p1`, so `p2`/`p3` can only come from the lookup domain. */
+const TASKS = [
+ { id: '1', name: 'Alpha', start: '2024-01-01', end: '2024-01-05', project: 'p1' },
+ { id: '2', name: 'Beta', start: '2024-02-01', end: '2024-02-10', project: 'p1' },
+];
+
+/** The referenced object's full domain — reachable ONLY by resolving the target. */
+const PROJECTS = [
+ { id: 'p1', name: 'Apollo' },
+ { id: 'p2', name: 'Borealis' },
+ { id: 'p3', name: 'Cygnus' },
+];
+
+/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
+const FIELD_DEFS: Record> = {
+ // Live arms — the two spellings a contract actually carries at this seam.
+ canonical: { type: 'lookup', reference_to: 'projects' },
+ spec_spelling: { type: 'lookup', reference: 'projects' },
+ // Deleted arm — refused by `FieldSchema` by name, retired at the read door.
+ legacy_camel: { type: 'lookup', referenceTo: 'projects' },
+};
+
+function makeDataSource(projectDef: Record) {
+ return {
+ find: vi.fn(async (object: string) =>
+ object === 'projects' ? { data: PROJECTS } : { data: TASKS },
+ ),
+ findOne: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ getObjectSchema: vi.fn().mockResolvedValue({
+ name: 'task',
+ fields: {
+ name: { type: 'text' },
+ start: { type: 'date' },
+ end: { type: 'date' },
+ project: projectDef,
+ },
+ }),
+ } as any;
+}
+
+const GANTT_SCHEMA = {
+ type: 'gantt',
+ objectName: 'task',
+ startDateField: 'start',
+ endDateField: 'end',
+ titleField: 'name',
+ quickFilters: [{ field: 'project', label: 'Project' }],
+} as any;
+
+/**
+ * Mount over one field def and wait for the SCHEMA-DEPENDENT commit to happen.
+ *
+ * The settle signal is deliberately spelling-INDEPENDENT: once `objectSchema`
+ * lands, the record query is re-issued carrying `$expand`, and
+ * `buildExpandFields` decides that from the field's `type` alone ("the
+ * `reference` / `reference_to` target is irrelevant to the decision"). So a
+ * `find('task', { $expand: [...] })` call proves the component consumed this
+ * schema — for the refusal probe just as much as for the live-arm ones. The
+ * option-fetch effect shares that commit and runs synchronously up to its own
+ * `find`, so by the time this resolves, a resolving arm has ALREADY recorded
+ * `find('projects', …)`.
+ */
+async function mount(projectDef: Record) {
+ const ds = makeDataSource(projectDef);
+ const view = render();
+ await waitFor(() =>
+ expect(
+ ds.find.mock.calls.some((c: any[]) => c[0] === 'task' && c[1]?.$expand?.includes('project')),
+ ).toBe(true),
+ );
+ return { ds, view };
+}
+
+/** Did the component resolve a target, i.e. fetch the referenced object's domain? */
+const fetchedDomain = (ds: any) =>
+ ds.find.mock.calls.some((c: any[]) => c[0] === 'projects');
+
+describe('ObjectGantt resolves only contract-declared target spellings (objectui#6837)', () => {
+ describe('live arms — the value still arrives (without these, a gantt that stopped resolving anything would pass the refusal too)', () => {
+ it("resolves `reference_to`, ObjectUI's own view/field key", async () => {
+ const { ds } = await mount(FIELD_DEFS.canonical);
+ await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
+ });
+
+ it('resolves `reference`, the spelling `FieldSchema` accepts', async () => {
+ const { ds } = await mount(FIELD_DEFS.spec_spelling);
+ await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
+ });
+
+ it('a resolved target widens the dropdown to the FULL domain, past the loaded rows', async () => {
+ // The user-visible half: `p2`/`p3` exist only on the referenced object.
+ const { ds, view } = await mount(FIELD_DEFS.canonical);
+ await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
+ await waitFor(() => {
+ fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
+ const panel = view.getByTestId('quick-filter-panel-project');
+ expect(within(panel).getByTestId('quick-filter-option-project-p3')).toBeTruthy();
+ });
+ });
+ });
+
+ describe('refusal — one named case for the deleted key', () => {
+ it('does NOT read `referenceTo` (RETIRED_FIELD_KEY_TOMBSTONES, objectui#6041/#6519; `FieldSchema` refuses it by name)', async () => {
+ const { ds } = await mount(FIELD_DEFS.legacy_camel);
+ expect(fetchedDomain(ds)).toBe(false);
+ });
+
+ it('and degrades to the distinct loaded values rather than rendering nothing', async () => {
+ // Guards the refusal above against the degenerate pass: a gantt that
+ // rendered no quick filter at all would also never fetch `projects`.
+ const { ds, view } = await mount(FIELD_DEFS.legacy_camel);
+ expect(view.getByTestId('gantt-view').getAttribute('data-count')).toBe('2');
+ fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
+ const panel = view.getByTestId('quick-filter-panel-project');
+ expect(within(panel).getByTestId('quick-filter-option-project-p1')).toBeTruthy();
+ expect(within(panel).queryByTestId('quick-filter-option-project-p3')).toBeNull();
+ expect(fetchedDomain(ds)).toBe(false);
+ });
+ });
+
+ describe('the ingestion choke point is what makes the deletion lossless', () => {
+ it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', async () => {
+ // The mechanism, not a formality: the normalizer reads
+ // `reference_to ?? reference ?? referenceTo` and stamps BOTH snake_case
+ // keys, so every def that entered through `MetadataProvider` or
+ // `ObjectStackAdapter.getObjectSchema` already carries `reference_to` by
+ // the time this component sees it. The deleted arm was dead weight there.
+ //
+ // ⚠️ And this is exactly why the pin above still matters: the door is
+ // NOT total. `getObjectSchema` is a required member of the published
+ // `DataSource` interface and this component calls it on the generic
+ // `dataSource`, so a third-party implementation reaches this reader raw.
+ const def = { ...FIELD_DEFS.legacy_camel };
+ const schema = { name: 'task', fields: { project: def } };
+ normalizeSchemaReferenceKeys(schema);
+ const { ds } = await mount(schema.fields.project as Record);
+ await waitFor(() => expect(fetchedDomain(ds)).toBe(true));
+ });
+ });
+});
diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx
index 677650a239..dc20b2f032 100644
--- a/packages/plugin-gantt/src/ObjectGantt.tsx
+++ b/packages/plugin-gantt/src/ObjectGantt.tsx
@@ -1123,9 +1123,16 @@ export const ObjectGantt: React.FC = ({
const type: string | undefined = fd?.type;
if (type !== 'lookup' && type !== 'master_detail') continue;
// Served schemas key the target as `reference` (ObjectStack
- // convention); reference_to/referenceTo cover ObjectUI-authored defs.
- const refObject: string | undefined =
- fd?.reference_to ?? fd?.reference ?? fd?.referenceTo;
+ // convention); `reference_to` covers ObjectUI-authored defs.
+ //
+ // A third arm, `referenceTo`, was deleted by objectui#6837: no contract
+ // declares that spelling — `@objectstack/spec`'s `FieldSchema` refuses
+ // it by name with `unrecognized_keys` ("Did you mean `referenceTo` ->
+ // `reference`?"), and it is a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
+ // (objectui#6041), so the designer read door strips it. It was not a
+ // redundant fallback but invented tolerance surface. Pinned in
+ // `ObjectGantt.referenceArms-6837.test.tsx`.
+ const refObject: string | undefined = fd?.reference_to ?? fd?.reference;
if (!refObject) continue;
try {
const result = await dataSource.find(refObject, { $top: 1000 });
diff --git a/packages/plugin-tree/src/ObjectTree.referenceArms-6837.test.tsx b/packages/plugin-tree/src/ObjectTree.referenceArms-6837.test.tsx
new file mode 100644
index 0000000000..23a8541a6d
--- /dev/null
+++ b/packages/plugin-tree/src/ObjectTree.referenceArms-6837.test.tsx
@@ -0,0 +1,266 @@
+/**
+ * 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.
+ */
+
+/**
+ * objectui#6837 (second slice) — `detectParentField`'s relationship-target
+ * chain drops the arm NO CONTRACT DECLARES, and keeps the two that carry the
+ * value.
+ *
+ * Before: `def?.reference || def?.reference_to || def?.referenceTo`
+ * After: `def?.reference || def?.reference_to`
+ *
+ * Form copied from `RecordDetailDrawer.referenceArms-6837.test.tsx` (PR #6920),
+ * which copied it from PR #6916 / card #6840. ⛔ Do not invent a second form.
+ *
+ * ## 1. The measurement this pin stands on (not just its conclusion)
+ *
+ * THE CELL: a value inside an object schema's `fields` container — literally
+ * what this function reads, `objectSchema.fields[key]`. Producer census by
+ * STRUCTURE WALK (TypeScript compiler API over every tracked `.ts`/`.tsx`, plus
+ * parsed JSON), recording each hit's ancestor property chain; EMIT positions
+ * only (`PropertyAssignment` / `ShorthandPropertyAssignment`), so
+ * `def?.referenceTo` — a `PropertyAccessExpression`, i.e. a READ — is never
+ * counted as a producer, and a `PropertySignature` is bucketed as a
+ * DECLARATION, never as one either. Subject and control were extracted BY THE
+ * SAME PASS, FROM THE SAME CELLS, IN THE SAME UNITS, so the control sits on the
+ * JOIN and not merely on the terms.
+ *
+ * | term | role | repo-wide emits | IN THE CELL |
+ * |----------------|---------|------------------|-------------|
+ * | `referenceTo` | SUBJECT | 81 / 42 files | **2** / 2 |
+ * | `reference` | CONTROL | 195 / 76 files | 92 / 36 |
+ * | `reference_to` | CONTROL | 137 / 88 files | 52 / 36 |
+ *
+ * Both halves of the control discipline. (1) THE QUERY RAN: the controls are
+ * hot — 92 and 52 — in the very cells where the subject collapses to 2, from
+ * the same pass. (2) THE QUESTION WAS RIGHT: a mis-posed cell would have moved
+ * subject and control together; instead it separates 92-to-2. Third check, the
+ * one only this key affords: `referenceTo` is not a term the scanner cannot
+ * see — it is hot repo-wide at 81 emits across 42 files, and collapses to 2
+ * only under the cell restriction. The zero-ish is produced by the RESTRICTION,
+ * not by scanner blindness.
+ *
+ * The two surviving in-cell hits are NEGATIVE fixtures of the retirement
+ * machinery itself (`object-fields-io.spec-keys.test.ts:235`,
+ * `MetadataFieldsPage.specKeyReference.test.tsx:75`): they poison a draft with
+ * the retired key precisely to assert the read door STRIPS it before
+ * `ObjectSchema.safeParse` sees it. A fixture asserting removal is not a
+ * producer.
+ *
+ * SEAM-LOCAL control, the one this file owes over and above the repo-wide pass:
+ * `plugin-tree` contains **zero** `referenceTo` emits at any position, in any
+ * cell — while its own fixture corpus is hot on a surviving spelling
+ * (`ObjectTree.fieldFormatting.test.tsx:51-52` and
+ * `ObjectTree.settledSchemaKeying-6481.test.tsx:62-71` emit `reference`, six
+ * hits in all, every one of them in a `fields` container feeding THIS
+ * component). So the corpus that actually feeds this reader is hot on what
+ * survives and empty on what goes.
+ *
+ * ## 2. Why refusal is correct, not merely unused-today
+ *
+ * `@objectstack/spec` 17.2.0's `FieldSchema` (`@objectstack/spec/data`), probed
+ * two-directionally on this branch's installed copy:
+ *
+ * - `reference: 'crm_account'` → ACCEPT
+ * - `reference_to: 'crm_account'` → REFUSE, `unrecognized_keys`
+ * - `referenceTo: 'crm_account'` → REFUSE, `unrecognized_keys`,
+ * "Did you mean `referenceTo` → `reference`?"
+ *
+ * The alias entry is a RENAME HINT ATTACHED TO A REFUSAL, not an acceptance:
+ * the spec names `referenceTo` explicitly in order to refuse it. `referenceTo`
+ * is additionally a tombstone in `RETIRED_FIELD_KEY_TOMBSTONES`
+ * (`@object-ui/types/internal/retired-field-keys`, `retiredBy: 'objectui#6041'`,
+ * `specEquivalent: 'reference'`) at all three strip sites, so the designer read
+ * door removes it before a draft round-trips. So this arm was not a "redundant"
+ * fallback: it was INVENTED tolerance surface — a silent absorption point for a
+ * producer that ought to fail visibly (AGENTS.md #0.1).
+ *
+ * ⚠️ What this does NOT rest on: any claim that no production producer of
+ * `reference_to` exists. That question cannot be answered from inside this repo
+ * — restricting the cell to production files collapses the CONTROL too, and
+ * this repo is a UI library, not a metadata-app repo. `reference_to` and
+ * `reference` are therefore deliberately untouched here; see §4.
+ *
+ * ## 3. No precedence inversion exists here — stated rather than fabricated
+ *
+ * The deleted arm sat at the END of the chain, and this chain is already
+ * CANONICAL-FIRST (`reference || reference_to || referenceTo`), so it could
+ * never preempt a contract-carrying spelling. There is therefore NO inversion
+ * case to pin, and this file deliberately does not invent one: a
+ * `{ reference: 'business_unit', referenceTo: 'other' }` case resolves to
+ * `'business_unit'` both before and after the change and would measure nothing.
+ * (Same call, for the same reason, as PR #6916 and PR #6920.)
+ *
+ * ⚠️ One asymmetry worth naming rather than glossing: this chain uses `||`,
+ * not `??`, so it also skips the empty string. That is unchanged by this slice
+ * — the arms move, the operator does not.
+ *
+ * ## 4. THE FLOOR, restated where someone would try to re-widen it
+ *
+ * ⛔ Do not re-add a spelling arm to this chain. A producer emitting a refused
+ * spelling is fixed AT THE PRODUCER, or canonicalised ONCE at the ingestion
+ * choke point — `normalizeSchemaReferenceKeys`, which stamps both snake_case
+ * keys from whichever spelling arrived. Never a renderer-side alias: that is
+ * how ~20 per-consumer dual-key fallbacks got written under a normalizer whose
+ * own docstring says it exists "so per-consumer dual-key fallbacks can't drift".
+ *
+ * ⛔ The two SURVIVING arms are out of this slice's scope. Choosing between
+ * `reference_to` and `reference` per reader is objectui#6837's OPEN scope, and
+ * its classification table measured why a mechanical sweep would be wrong: the
+ * ObjectUI-side contracts (`DetailViewFieldSchema`, `LookupFieldMetadata`,
+ * report columns, designer fields, related-list config) declare `reference_to`,
+ * `referenceTo` and `referenceField` but NONE of them declares `reference` —
+ * these readers sit on a TIER BOUNDARY rather than choosing between a legacy
+ * and a canonical spelling of one key. #6837 stays open.
+ *
+ * ## 5. Ablation direction, predicted before running
+ *
+ * Restore the deleted arm on the committed tree and the refusal below goes RED
+ * while every live-arm control stays GREEN — that contrast is what makes the
+ * controls controls rather than duplicates of the pins. MODULE RESOLUTION: this
+ * file imports the component by RELATIVE SOURCE PATH (`./ObjectTree`) and
+ * `@object-ui/core` is aliased by the root `vitest.config.mts` to
+ * `packages/core/src`, so both legs resolve to SOURCE — no package `exports`
+ * hop, no `dist`, and therefore NO REBUILD LEG to get wrong.
+ */
+import React from 'react';
+import { render, waitFor, cleanup } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { normalizeSchemaReferenceKeys } from '@object-ui/core';
+import { ObjectTree } from './ObjectTree';
+
+afterEach(cleanup);
+
+/**
+ * A two-node self-referencing hierarchy. `Engineering` nests under `Acme` ONLY
+ * if the parent pointer is auto-detected — which is the whole observable here,
+ * because the schema below deliberately omits `parentField`.
+ */
+const RECORDS = [
+ { id: '1', name: 'Acme', parent_id: null },
+ { id: '2', name: 'Engineering', parent_id: '1' },
+];
+
+/**
+ * Every probe is a self-referencing `lookup`, so only the target SPELLING
+ * varies. ⚠️ `lookup` and not `tree`: `detectParentField` returns a `tree` field
+ * before it ever reads a target, which would make the chain unobservable.
+ */
+const FIELD_DEFS: Record> = {
+ // Live arms — the two spellings a contract actually carries at this seam.
+ canonical: { type: 'lookup', reference_to: 'business_unit' },
+ spec_spelling: { type: 'lookup', reference: 'business_unit' },
+ // Deleted arm — refused by `FieldSchema` by name, retired at the read door.
+ legacy_camel: { type: 'lookup', referenceTo: 'business_unit' },
+};
+
+function makeDataSource(parentDef: Record) {
+ return {
+ getObjectSchema: vi.fn().mockResolvedValue({
+ name: 'business_unit',
+ fields: { name: { type: 'text' }, parent_id: parentDef },
+ }),
+ find: vi.fn(async () => RECORDS),
+ } as any;
+}
+
+/** No `parentField` — auto-detection is what is under test. */
+const TREE_SCHEMA = {
+ type: 'object-tree',
+ objectName: 'business_unit',
+ labelField: 'name',
+ fields: ['name'],
+} as any;
+
+/**
+ * Mount over one field def and wait for the SCHEMA-DEPENDENT commit to happen.
+ *
+ * The settle signal is deliberately spelling-INDEPENDENT: the record query is
+ * gated on the settled schema and carries `$expand`, and `buildExpandFields`
+ * decides that from the field's `type` alone ("the `reference` / `reference_to`
+ * target is irrelevant to the decision"). So a `find` carrying
+ * `$expand: ['parent_id']` proves the component consumed this schema — for the
+ * refusal probe just as much as for the live-arm ones, which is what stops the
+ * refusal from being satisfiable by a schema that never arrived.
+ */
+async function mount(parentDef: Record) {
+ const ds = makeDataSource(parentDef);
+ const view = render();
+ await waitFor(() =>
+ expect(
+ ds.find.mock.calls.some((c: any[]) => c[1]?.$expand?.includes('parent_id')),
+ ).toBe(true),
+ );
+ await waitFor(() => expect(view.getAllByTestId('object-tree-row').length).toBe(2));
+ return { ds, view };
+}
+
+/** The rendered depth of one node — 1 once the parent pointer is detected, 0 while it is not. */
+function depthOf(view: ReturnType, label: string) {
+ const row = view
+ .getAllByTestId('object-tree-row')
+ .find((r) => r.textContent?.includes(label));
+ return row?.getAttribute('data-depth');
+}
+
+describe('ObjectTree detects a parent pointer only through contract-declared target spellings (objectui#6837)', () => {
+ describe('live arms — the value still arrives (without these, a tree that stopped detecting anything would pass the refusal too)', () => {
+ it("resolves `reference_to`, ObjectUI's own view/field key", async () => {
+ const { view } = await mount(FIELD_DEFS.canonical);
+ expect(depthOf(view, 'Engineering')).toBe('1');
+ });
+
+ it('resolves `reference`, the spelling `FieldSchema` accepts', async () => {
+ const { view } = await mount(FIELD_DEFS.spec_spelling);
+ expect(depthOf(view, 'Engineering')).toBe('1');
+ });
+
+ it('the root stays a root either way, so depth is reading the hierarchy and not the row order', async () => {
+ const { view } = await mount(FIELD_DEFS.canonical);
+ expect(depthOf(view, 'Acme')).toBe('0');
+ });
+ });
+
+ describe('refusal — one named case for the deleted key', () => {
+ it('does NOT read `referenceTo` (RETIRED_FIELD_KEY_TOMBSTONES, objectui#6041/#6519; `FieldSchema` refuses it by name)', async () => {
+ const { view } = await mount(FIELD_DEFS.legacy_camel);
+ expect(depthOf(view, 'Engineering')).toBe('0');
+ });
+
+ it('and still renders every record as a root rather than rendering nothing', async () => {
+ // Guards the refusal above against the degenerate pass: a tree that
+ // rendered no rows at all would also report no nested child.
+ const { view } = await mount(FIELD_DEFS.legacy_camel);
+ expect(view.getAllByTestId('object-tree-row').length).toBe(2);
+ expect(depthOf(view, 'Acme')).toBe('0');
+ expect(view.getByText('Engineering')).toBeTruthy();
+ });
+ });
+
+ describe('the ingestion choke point is what makes the deletion lossless', () => {
+ it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', async () => {
+ // The mechanism, not a formality: the normalizer reads
+ // `reference_to ?? reference ?? referenceTo` and stamps BOTH snake_case
+ // keys, so every def that entered through `MetadataProvider` or
+ // `ObjectStackAdapter.getObjectSchema` already carries `reference_to` by
+ // the time this component sees it. The deleted arm was dead weight there.
+ //
+ // ⚠️ And this is exactly why the pin above still matters: the door is
+ // NOT total. `getObjectSchema` is a required member of the published
+ // `DataSource` interface and `useSettledSchema` calls it on the generic
+ // `dataSource`, so a third-party implementation reaches this reader raw.
+ const schema = {
+ name: 'business_unit',
+ fields: { parent_id: { ...FIELD_DEFS.legacy_camel } },
+ };
+ normalizeSchemaReferenceKeys(schema);
+ const { view } = await mount(schema.fields.parent_id as Record);
+ expect(depthOf(view, 'Engineering')).toBe('1');
+ });
+ });
+});
diff --git a/packages/plugin-tree/src/ObjectTree.tsx b/packages/plugin-tree/src/ObjectTree.tsx
index 94ba6dce7f..568c4d355d 100644
--- a/packages/plugin-tree/src/ObjectTree.tsx
+++ b/packages/plugin-tree/src/ObjectTree.tsx
@@ -129,7 +129,14 @@ function detectParentField(objectSchema: any, objectName?: string): string | und
let firstSelfRef: string | undefined;
for (const [key, def] of Object.entries(fields)) {
if (def?.type === 'tree') return key;
- const ref = def?.reference || def?.reference_to || def?.referenceTo;
+ // A third arm, `def?.referenceTo`, was deleted by objectui#6837: no
+ // contract declares that spelling — `@objectstack/spec`'s `FieldSchema`
+ // refuses it by name with `unrecognized_keys` ("Did you mean `referenceTo`
+ // -> `reference`?"), and it is a tombstone in
+ // `RETIRED_FIELD_KEY_TOMBSTONES` (objectui#6041), so the designer read door
+ // strips it. It was not a redundant fallback but invented tolerance
+ // surface. Pinned in `ObjectTree.referenceArms-6837.test.tsx`.
+ const ref = def?.reference || def?.reference_to;
if (
!firstSelfRef &&
(def?.type === 'lookup' || def?.type === 'master_detail') &&