Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/6837-recorddrawer-invented-target-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/plugin-detail': minor
---

`RecordDetailDrawer` resolves a relationship target only from the two spellings
a contract carries, dropping the two no contract declares (objectui#6837, first
slice).

The chain was `def.reference_to ?? def.reference ?? def.referenceTo ??
def.target`; it is now `def.reference_to ?? def.reference`.

**Accept-set move — a def carrying ONLY `referenceTo`, or ONLY `target`, stops
resolving a target** and the field renders without one (the drawer already marks
every reference-bearing field readonly, so nothing becomes editable that was
not). Two things bound that:

- 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 the drawer sees it. Only a def that
bypassed that door entirely is affected.
- `target` was never read anywhere else in the stack — not by the normalizer,
not by the spec. `@objectstack/spec`'s `FieldSchema` refuses both deleted
spellings by name with `unrecognized_keys`, each carrying its own "did you
mean `reference`" rename, and `referenceTo` is additionally stripped at the
designer read door (`RETIRED_FIELD_KEYS`, objectui#6041 / #6519).

A repo-wide structure-walk producer census found **0** emitters of `target` and
**0** reaching this seam for `referenceTo`, measured in the cell the drawer
reads (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.

Pinned by `RecordDetailDrawer.referenceArms-6837.test.tsx`, which keeps the live
arms green beside a named refusal per deleted key.
28 changes: 26 additions & 2 deletions packages/plugin-detail/src/RecordDetailDrawer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -272,8 +272,32 @@ export function RecordDetailDrawer({
format: def.format,
// Served schemas key the target as `reference` (ObjectStack
// convention, #2407); the drawer can receive a raw schema from any
// DataSource, so resolve every spelling.
reference_to: def.reference_to ?? def.reference ?? def.referenceTo ?? def.target,
// DataSource, so both snake_case spellings are resolved here.
//
// Two further arms stood here until objectui#6837 — `def.referenceTo`
// and `def.target` — and they were NOT redundant-but-harmless: no
// contract declares either spelling. `FieldSchema` refuses BOTH by name
// with `unrecognized_keys`, each carrying its own "did you mean
// `reference`" rename; `referenceTo` is additionally stripped at the
// designer read door (`RETIRED_FIELD_KEYS`, objectui#6041 / #6519), so
// that arm could never hit. A structure-walk producer census found ZERO
// emitters of either at THIS cell — a value inside an object schema's
// `fields` container, which is what `objectSchema.fields[name]` reads —
// while the controls `reference` (92 hits / 36 files) and `reference_to`
// (52 / 36) were hot in the same pass over the same cells. So the two
// arms were invented tolerance surface: a silent absorption point for a
// producer that should fail visibly (AGENTS.md #0.1).
//
// ⛔ Do not re-add a spelling arm here. 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 by a
// renderer-side alias.
//
// The two remaining arms are deliberately left standing: deciding
// between them per reader is objectui#6837's OPEN scope (the
// classification table over ~20 more readers), not this slice's.
reference_to: def.reference_to ?? def.reference,
reference_field: def.reference_field ?? def.referenceField,
required: def.required,
validation: def.validation,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
/**
* 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 (first slice) — the drawer's relationship-target chain drops the
* two arms NO CONTRACT DECLARES, and keeps the two that carry the value.
*
* Before: `def.reference_to ?? def.reference ?? def.referenceTo ?? def.target`
* After: `def.reference_to ?? def.reference`
*
* ## 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/YAML), recording each hit's ancestor property chain; EMIT
* positions only, so `def.target` (a read) is never counted as a producer.
* Subject and control terms were extracted BY THE SAME PASS, FROM THE SAME
* CELLS, IN THE SAME UNITS — the control sits on the JOIN, not merely on the
* terms.
*
* | term | role | repo-wide emits | IN THE CELL |
* |----------------|---------|-----------------|---------------|
* | `target` | SUBJECT | 1329 / 366 files| **0** / 0 |
* | `referenceTo` | SUBJECT | 80 / 41 files| **2** / 2 |
* | `reference` | CONTROL | 194 / 75 files| 92 / 36 |
* | `reference_to` | CONTROL | 136 / 87 files| 52 / 36 |
*
* A second, INDEPENDENT cell test that does not rely on a `fields` ancestor —
* "the enclosing object's own `type` is reference-bearing"
* (`EXPANDABLE_FIELD_TYPES`) — agrees: `target` 0, and every one of the 29
* `referenceTo` hits is a test fixture at OTHER seams (action params, the
* filter builder, the retirement machinery), none in `plugin-detail` and none
* reaching this drawer.
*
* The two in-cell `referenceTo` hits are NEGATIVE fixtures of the retirement
* machinery itself (`object-fields-io.spec-keys`, `MetadataFieldsPage
* .specKeyReference`): they poison a draft with the retired key precisely to
* assert the read door STRIPS it. A fixture asserting removal is not a producer.
*
* Why `target`'s repo-wide 1329 is not a counter-example: every one of those
* emits belongs to a DIFFERENT TIER. Attributed by the enclosing object's own
* `type` value they are `api` (137), `url` (35), `script` (32), `form` (16),
* `flow` (13), `back` (9), `modal` (6), `fault` (5) — action and navigation
* nodes — plus 1062 with no sibling `type` at all (DOM event targets, link
* targets). Not one lands on a field definition.
*
* ## 2. Why refusal is correct, not merely unused-today
*
* `@objectstack/spec`'s `FieldSchema` refuses BOTH deleted spellings BY NAME
* with `unrecognized_keys`, each carrying its own "did you mean `reference`"
* rename — measured two-directionally against the installed spec, alongside
* `reference` parsing clean. `referenceTo` is additionally in
* `RETIRED_FIELD_KEYS` (objectui#6041 / #6519), so the designer read door
* strips it before any draft round-trips. So these were not "redundant"
* fallbacks: they were INVENTED tolerance surface — a silent absorption point
* for a producer that ought to fail visibly (AGENTS.md #0.1).
*
* ## 3. No precedence inversion exists here — stated rather than fabricated
*
* Both deleted arms sat at the END of the chain
* (`reference_to ?? reference ?? referenceTo ?? target`), so neither could ever
* preempt a contract-carrying spelling. There is therefore NO inversion case to
* pin, and this file deliberately does not invent one: a
* `{ reference: 'a', target: 'b' }` case would resolve to `'a'` both before and
* after the change and would measure nothing. (Copied from PR #6916 / card
* #6840, which set this form and made the same call for `value`.)
*
* ## 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 twenty 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. Deciding between
* `reference_to` and `reference` per reader is objectui#6837's OPEN scope (the
* classification table over ~20 more readers across eight other packages), and
* triage refused a single mechanical sweep because those readers are fed by
* different contracts. #6837 stays open.
*
* ## 5. Ablation direction, predicted before running
*
* Restore either deleted arm on the committed tree and the matching refusal
* goes RED while every live-arm control stays GREEN — that contrast is what
* makes the controls controls rather than duplicates of the pins. The pin
* imports the component by RELATIVE SOURCE PATH (`../RecordDetailDrawer`), so
* no package `exports` hop and no `dist` leg is involved.
*/
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import React from 'react';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { RecordDetailDrawer } from '../RecordDetailDrawer';

/**
* The drawer hands its derived field list to `DetailView` as `schema.fields`.
* Standing in for `DetailView` captures that list without rendering the whole
* detail tree — the resolved `reference_to` under test is a property of the
* list, not of how DetailView paints it. (Same harness as
* `expandableFamily.identity-5874.test.tsx`, which pins the sibling rule
* derived from the very same `.map()`.)
*/
const capturedFields: { current: any[] } = { current: [] };
vi.mock('../DetailView', () => ({
DetailView: ({ schema }: any) => {
capturedFields.current = schema?.fields ?? [];
return <div data-testid="detail-view-stub" />;
},
}));

/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
const FIELD_DEFS: Record<string, Record<string, unknown>> = {
// Live arms — the two spellings a contract actually carries at this seam.
canonical: { type: 'lookup', label: 'Canonical', reference_to: 'crm_account' },
spec_spelling: { type: 'lookup', label: 'Spec', reference: 'crm_account' },
// Deleted arms — refused by name, zero producers in the cell.
legacy_camel: { type: 'lookup', label: 'Legacy camel', referenceTo: 'crm_account' },
invented: { type: 'lookup', label: 'Invented', target: 'crm_account' },
// Non-relation control: carries no target spelling at all.
plain_text: { type: 'text', label: 'Plain' },
};

const RECORD: Record<string, unknown> = {
id: 'r1',
canonical: 'acc-1',
spec_spelling: 'acc-1',
legacy_camel: 'acc-1',
invented: 'acc-1',
plain_text: 'hello',
};

/** Render the drawer over `fields` and return the list handed to DetailView. */
function resolveFields(fields: Record<string, unknown>) {
capturedFields.current = [];
render(
<RecordDetailDrawer
open
onClose={() => {}}
title="Probe"
record={RECORD}
recordId="r1"
objectName="probe"
objectSchema={{ name: 'probe', fields } as any}
onFieldSave={async () => {}}
/>,
);
return capturedFields.current;
}

/** The `reference_to` the drawer resolved for one field name. */
function resolvedTarget(name: string, fields: Record<string, unknown> = FIELD_DEFS) {
return resolveFields(fields).find((f: any) => f.name === name)?.reference_to;
}

describe('RecordDetailDrawer resolves only contract-declared target spellings (objectui#6837)', () => {
describe('live arms — the value still arrives (without these, a drawer that stopped resolving anything would pass the refusals too)', () => {
it("resolves `reference_to`, ObjectUI's own view/field key", () => {
expect(resolvedTarget('canonical')).toBe('crm_account');
});

it('resolves `reference`, the spelling `FieldSchema` accepts', () => {
expect(resolvedTarget('spec_spelling')).toBe('crm_account');
});

it('still derives the whole field list, relations and non-relations alike', () => {
// Guards the refusals below against the degenerate pass: a drawer that
// produced no fields at all would satisfy every `toBeUndefined()`.
const names = resolveFields(FIELD_DEFS).map((f: any) => f.name);
expect(names).toEqual(
expect.arrayContaining(['canonical', 'spec_spelling', 'legacy_camel', 'invented', 'plain_text']),
);
});

it('marks the relation fields readonly, so the list is genuinely populated', () => {
const f = resolveFields(FIELD_DEFS).find((x: any) => x.name === 'canonical');
expect(f?.readonly).toBe(true);
});
});

describe('refusals — one named case per deleted key', () => {
it("does NOT read `referenceTo` (RETIRED_FIELD_KEYS, objectui#6041/#6519; `FieldSchema` refuses it by name)", () => {
expect(resolvedTarget('legacy_camel')).toBeUndefined();
});

it("does NOT read `target` (no contract declares it; 0 producers in the cell against controls of 92 and 52)", () => {
expect(resolvedTarget('invented')).toBeUndefined();
});
});

describe('the ingestion choke point is what makes the `referenceTo` deletion lossless', () => {
it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', () => {
// This is 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 the drawer sees it. The deleted arm was dead weight for those.
const schema = { name: 'probe', fields: { legacy_camel: { ...FIELD_DEFS.legacy_camel } } };
normalizeSchemaReferenceKeys(schema);
expect(resolvedTarget('legacy_camel', schema.fields)).toBe('crm_account');
});

it('a `target`-only def does NOT resolve even through the normalizer — nothing in the stack ever declared it', () => {
// `normalizeFieldReferenceKeys` never read `target` either. So unlike
// `referenceTo`, `target` had no home anywhere in the stack: not in the
// spec, not at the choke point, not in the retirement registry.
const schema = { name: 'probe', fields: { invented: { ...FIELD_DEFS.invented } } };
normalizeSchemaReferenceKeys(schema);
expect(resolvedTarget('invented', schema.fields)).toBeUndefined();
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain by os-sam · Pull Request #6920 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/6837-recorddrawer-invented-target-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/plugin-detail': minor
---

`RecordDetailDrawer` resolves a relationship target only from the two spellings
a contract carries, dropping the two no contract declares (objectui#6837, first
slice).

The chain was `def.reference_to ?? def.reference ?? def.referenceTo ??
def.target`; it is now `def.reference_to ?? def.reference`.

**Accept-set move — a def carrying ONLY `referenceTo`, or ONLY `target`, stops
resolving a target** and the field renders without one (the drawer already marks
every reference-bearing field readonly, so nothing becomes editable that was
not). Two things bound that:

- 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 the drawer sees it. Only a def that
bypassed that door entirely is affected.
- `target` was never read anywhere else in the stack — not by the normalizer,
not by the spec. `@objectstack/spec`'s `FieldSchema` refuses both deleted
spellings by name with `unrecognized_keys`, each carrying its own "did you
mean `reference`" rename, and `referenceTo` is additionally stripped at the
designer read door (`RETIRED_FIELD_KEYS`, objectui#6041 / #6519).

A repo-wide structure-walk producer census found **0** emitters of `target` and
**0** reaching this seam for `referenceTo`, measured in the cell the drawer
reads (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.

Pinned by `RecordDetailDrawer.referenceArms-6837.test.tsx`, which keeps the live
arms green beside a named refusal per deleted key.
28 changes: 26 additions & 2 deletions packages/plugin-detail/src/RecordDetailDrawer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -272,8 +272,32 @@ export function RecordDetailDrawer({
format: def.format,
// Served schemas key the target as `reference` (ObjectStack
// convention, #2407); the drawer can receive a raw schema from any
// DataSource, so resolve every spelling.
reference_to: def.reference_to ?? def.reference ?? def.referenceTo ?? def.target,
// DataSource, so both snake_case spellings are resolved here.
//
// Two further arms stood here until objectui#6837 — `def.referenceTo`
// and `def.target` — and they were NOT redundant-but-harmless: no
// contract declares either spelling. `FieldSchema` refuses BOTH by name
// with `unrecognized_keys`, each carrying its own "did you mean
// `reference`" rename; `referenceTo` is additionally stripped at the
// designer read door (`RETIRED_FIELD_KEYS`, objectui#6041 / #6519), so
// that arm could never hit. A structure-walk producer census found ZERO
// emitters of either at THIS cell — a value inside an object schema's
// `fields` container, which is what `objectSchema.fields[name]` reads —
// while the controls `reference` (92 hits / 36 files) and `reference_to`
// (52 / 36) were hot in the same pass over the same cells. So the two
// arms were invented tolerance surface: a silent absorption point for a
// producer that should fail visibly (AGENTS.md #0.1).
//
// ⛔ Do not re-add a spelling arm here. 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 by a
// renderer-side alias.
//
// The two remaining arms are deliberately left standing: deciding
// between them per reader is objectui#6837's OPEN scope (the
// classification table over ~20 more readers), not this slice's.
reference_to: def.reference_to ?? def.reference,
reference_field: def.reference_field ?? def.referenceField,
required: def.required,
validation: def.validation,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
/**
* 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 (first slice) — the drawer's relationship-target chain drops the
* two arms NO CONTRACT DECLARES, and keeps the two that carry the value.
*
* Before: `def.reference_to ?? def.reference ?? def.referenceTo ?? def.target`
* After: `def.reference_to ?? def.reference`
*
* ## 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/YAML), recording each hit's ancestor property chain; EMIT
* positions only, so `def.target` (a read) is never counted as a producer.
* Subject and control terms were extracted BY THE SAME PASS, FROM THE SAME
* CELLS, IN THE SAME UNITS — the control sits on the JOIN, not merely on the
* terms.
*
* | term | role | repo-wide emits | IN THE CELL |
* |----------------|---------|-----------------|---------------|
* | `target` | SUBJECT | 1329 / 366 files| **0** / 0 |
* | `referenceTo` | SUBJECT | 80 / 41 files| **2** / 2 |
* | `reference` | CONTROL | 194 / 75 files| 92 / 36 |
* | `reference_to` | CONTROL | 136 / 87 files| 52 / 36 |
*
* A second, INDEPENDENT cell test that does not rely on a `fields` ancestor —
* "the enclosing object's own `type` is reference-bearing"
* (`EXPANDABLE_FIELD_TYPES`) — agrees: `target` 0, and every one of the 29
* `referenceTo` hits is a test fixture at OTHER seams (action params, the
* filter builder, the retirement machinery), none in `plugin-detail` and none
* reaching this drawer.
*
* The two in-cell `referenceTo` hits are NEGATIVE fixtures of the retirement
* machinery itself (`object-fields-io.spec-keys`, `MetadataFieldsPage
* .specKeyReference`): they poison a draft with the retired key precisely to
* assert the read door STRIPS it. A fixture asserting removal is not a producer.
*
* Why `target`'s repo-wide 1329 is not a counter-example: every one of those
* emits belongs to a DIFFERENT TIER. Attributed by the enclosing object's own
* `type` value they are `api` (137), `url` (35), `script` (32), `form` (16),
* `flow` (13), `back` (9), `modal` (6), `fault` (5) — action and navigation
* nodes — plus 1062 with no sibling `type` at all (DOM event targets, link
* targets). Not one lands on a field definition.
*
* ## 2. Why refusal is correct, not merely unused-today
*
* `@objectstack/spec`'s `FieldSchema` refuses BOTH deleted spellings BY NAME
* with `unrecognized_keys`, each carrying its own "did you mean `reference`"
* rename — measured two-directionally against the installed spec, alongside
* `reference` parsing clean. `referenceTo` is additionally in
* `RETIRED_FIELD_KEYS` (objectui#6041 / #6519), so the designer read door
* strips it before any draft round-trips. So these were not "redundant"
* fallbacks: they were INVENTED tolerance surface — a silent absorption point
* for a producer that ought to fail visibly (AGENTS.md #0.1).
*
* ## 3. No precedence inversion exists here — stated rather than fabricated
*
* Both deleted arms sat at the END of the chain
* (`reference_to ?? reference ?? referenceTo ?? target`), so neither could ever
* preempt a contract-carrying spelling. There is therefore NO inversion case to
* pin, and this file deliberately does not invent one: a
* `{ reference: 'a', target: 'b' }` case would resolve to `'a'` both before and
* after the change and would measure nothing. (Copied from PR #6916 / card
* #6840, which set this form and made the same call for `value`.)
*
* ## 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 twenty 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. Deciding between
* `reference_to` and `reference` per reader is objectui#6837's OPEN scope (the
* classification table over ~20 more readers across eight other packages), and
* triage refused a single mechanical sweep because those readers are fed by
* different contracts. #6837 stays open.
*
* ## 5. Ablation direction, predicted before running
*
* Restore either deleted arm on the committed tree and the matching refusal
* goes RED while every live-arm control stays GREEN — that contrast is what
* makes the controls controls rather than duplicates of the pins. The pin
* imports the component by RELATIVE SOURCE PATH (`../RecordDetailDrawer`), so
* no package `exports` hop and no `dist` leg is involved.
*/
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import React from 'react';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { RecordDetailDrawer } from '../RecordDetailDrawer';

/**
* The drawer hands its derived field list to `DetailView` as `schema.fields`.
* Standing in for `DetailView` captures that list without rendering the whole
* detail tree — the resolved `reference_to` under test is a property of the
* list, not of how DetailView paints it. (Same harness as
* `expandableFamily.identity-5874.test.tsx`, which pins the sibling rule
* derived from the very same `.map()`.)
*/
const capturedFields: { current: any[] } = { current: [] };
vi.mock('../DetailView', () => ({
DetailView: ({ schema }: any) => {
capturedFields.current = schema?.fields ?? [];
return <div data-testid="detail-view-stub" />;
},
}));

/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
const FIELD_DEFS: Record<string, Record<string, unknown>> = {
// Live arms — the two spellings a contract actually carries at this seam.
canonical: { type: 'lookup', label: 'Canonical', reference_to: 'crm_account' },
spec_spelling: { type: 'lookup', label: 'Spec', reference: 'crm_account' },
// Deleted arms — refused by name, zero producers in the cell.
legacy_camel: { type: 'lookup', label: 'Legacy camel', referenceTo: 'crm_account' },
invented: { type: 'lookup', label: 'Invented', target: 'crm_account' },
// Non-relation control: carries no target spelling at all.
plain_text: { type: 'text', label: 'Plain' },
};

const RECORD: Record<string, unknown> = {
id: 'r1',
canonical: 'acc-1',
spec_spelling: 'acc-1',
legacy_camel: 'acc-1',
invented: 'acc-1',
plain_text: 'hello',
};

/** Render the drawer over `fields` and return the list handed to DetailView. */
function resolveFields(fields: Record<string, unknown>) {
capturedFields.current = [];
render(
<RecordDetailDrawer
open
onClose={() => {}}
title="Probe"
record={RECORD}
recordId="r1"
objectName="probe"
objectSchema={{ name: 'probe', fields } as any}
onFieldSave={async () => {}}
/>,
);
return capturedFields.current;
}

/** The `reference_to` the drawer resolved for one field name. */
function resolvedTarget(name: string, fields: Record<string, unknown> = FIELD_DEFS) {
return resolveFields(fields).find((f: any) => f.name === name)?.reference_to;
}

describe('RecordDetailDrawer resolves only contract-declared target spellings (objectui#6837)', () => {
describe('live arms — the value still arrives (without these, a drawer that stopped resolving anything would pass the refusals too)', () => {
it("resolves `reference_to`, ObjectUI's own view/field key", () => {
expect(resolvedTarget('canonical')).toBe('crm_account');
});

it('resolves `reference`, the spelling `FieldSchema` accepts', () => {
expect(resolvedTarget('spec_spelling')).toBe('crm_account');
});

it('still derives the whole field list, relations and non-relations alike', () => {
// Guards the refusals below against the degenerate pass: a drawer that
// produced no fields at all would satisfy every `toBeUndefined()`.
const names = resolveFields(FIELD_DEFS).map((f: any) => f.name);
expect(names).toEqual(
expect.arrayContaining(['canonical', 'spec_spelling', 'legacy_camel', 'invented', 'plain_text']),
);
});

it('marks the relation fields readonly, so the list is genuinely populated', () => {
const f = resolveFields(FIELD_DEFS).find((x: any) => x.name === 'canonical');
expect(f?.readonly).toBe(true);
});
});

describe('refusals — one named case per deleted key', () => {
it("does NOT read `referenceTo` (RETIRED_FIELD_KEYS, objectui#6041/#6519; `FieldSchema` refuses it by name)", () => {
expect(resolvedTarget('legacy_camel')).toBeUndefined();
});

it("does NOT read `target` (no contract declares it; 0 producers in the cell against controls of 92 and 52)", () => {
expect(resolvedTarget('invented')).toBeUndefined();
});
});

describe('the ingestion choke point is what makes the `referenceTo` deletion lossless', () => {
it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', () => {
// This is 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 the drawer sees it. The deleted arm was dead weight for those.
const schema = { name: 'probe', fields: { legacy_camel: { ...FIELD_DEFS.legacy_camel } } };
normalizeSchemaReferenceKeys(schema);
expect(resolvedTarget('legacy_camel', schema.fields)).toBe('crm_account');
});

it('a `target`-only def does NOT resolve even through the normalizer — nothing in the stack ever declared it', () => {
// `normalizeFieldReferenceKeys` never read `target` either. So unlike
// `referenceTo`, `target` had no home anywhere in the stack: not in the
// spec, not at the choke point, not in the retirement registry.
const schema = { name: 'probe', fields: { invented: { ...FIELD_DEFS.invented } } };
normalizeSchemaReferenceKeys(schema);
expect(resolvedTarget('invented', schema.fields)).toBeUndefined();
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain by os-sam · Pull Request #6920 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/6837-recorddrawer-invented-target-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/plugin-detail': minor
---

`RecordDetailDrawer` resolves a relationship target only from the two spellings
a contract carries, dropping the two no contract declares (objectui#6837, first
slice).

The chain was `def.reference_to ?? def.reference ?? def.referenceTo ??
def.target`; it is now `def.reference_to ?? def.reference`.

**Accept-set move — a def carrying ONLY `referenceTo`, or ONLY `target`, stops
resolving a target** and the field renders without one (the drawer already marks
every reference-bearing field readonly, so nothing becomes editable that was
not). Two things bound that:

- 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 the drawer sees it. Only a def that
bypassed that door entirely is affected.
- `target` was never read anywhere else in the stack — not by the normalizer,
not by the spec. `@objectstack/spec`'s `FieldSchema` refuses both deleted
spellings by name with `unrecognized_keys`, each carrying its own "did you
mean `reference`" rename, and `referenceTo` is additionally stripped at the
designer read door (`RETIRED_FIELD_KEYS`, objectui#6041 / #6519).

A repo-wide structure-walk producer census found **0** emitters of `target` and
**0** reaching this seam for `referenceTo`, measured in the cell the drawer
reads (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.

Pinned by `RecordDetailDrawer.referenceArms-6837.test.tsx`, which keeps the live
arms green beside a named refusal per deleted key.
28 changes: 26 additions & 2 deletions packages/plugin-detail/src/RecordDetailDrawer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -272,8 +272,32 @@ export function RecordDetailDrawer({
format: def.format,
// Served schemas key the target as `reference` (ObjectStack
// convention, #2407); the drawer can receive a raw schema from any
// DataSource, so resolve every spelling.
reference_to: def.reference_to ?? def.reference ?? def.referenceTo ?? def.target,
// DataSource, so both snake_case spellings are resolved here.
//
// Two further arms stood here until objectui#6837 — `def.referenceTo`
// and `def.target` — and they were NOT redundant-but-harmless: no
// contract declares either spelling. `FieldSchema` refuses BOTH by name
// with `unrecognized_keys`, each carrying its own "did you mean
// `reference`" rename; `referenceTo` is additionally stripped at the
// designer read door (`RETIRED_FIELD_KEYS`, objectui#6041 / #6519), so
// that arm could never hit. A structure-walk producer census found ZERO
// emitters of either at THIS cell — a value inside an object schema's
// `fields` container, which is what `objectSchema.fields[name]` reads —
// while the controls `reference` (92 hits / 36 files) and `reference_to`
// (52 / 36) were hot in the same pass over the same cells. So the two
// arms were invented tolerance surface: a silent absorption point for a
// producer that should fail visibly (AGENTS.md #0.1).
//
// ⛔ Do not re-add a spelling arm here. 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 by a
// renderer-side alias.
//
// The two remaining arms are deliberately left standing: deciding
// between them per reader is objectui#6837's OPEN scope (the
// classification table over ~20 more readers), not this slice's.
reference_to: def.reference_to ?? def.reference,
reference_field: def.reference_field ?? def.referenceField,
required: def.required,
validation: def.validation,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
/**
* 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 (first slice) — the drawer's relationship-target chain drops the
* two arms NO CONTRACT DECLARES, and keeps the two that carry the value.
*
* Before: `def.reference_to ?? def.reference ?? def.referenceTo ?? def.target`
* After: `def.reference_to ?? def.reference`
*
* ## 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/YAML), recording each hit's ancestor property chain; EMIT
* positions only, so `def.target` (a read) is never counted as a producer.
* Subject and control terms were extracted BY THE SAME PASS, FROM THE SAME
* CELLS, IN THE SAME UNITS — the control sits on the JOIN, not merely on the
* terms.
*
* | term | role | repo-wide emits | IN THE CELL |
* |----------------|---------|-----------------|---------------|
* | `target` | SUBJECT | 1329 / 366 files| **0** / 0 |
* | `referenceTo` | SUBJECT | 80 / 41 files| **2** / 2 |
* | `reference` | CONTROL | 194 / 75 files| 92 / 36 |
* | `reference_to` | CONTROL | 136 / 87 files| 52 / 36 |
*
* A second, INDEPENDENT cell test that does not rely on a `fields` ancestor —
* "the enclosing object's own `type` is reference-bearing"
* (`EXPANDABLE_FIELD_TYPES`) — agrees: `target` 0, and every one of the 29
* `referenceTo` hits is a test fixture at OTHER seams (action params, the
* filter builder, the retirement machinery), none in `plugin-detail` and none
* reaching this drawer.
*
* The two in-cell `referenceTo` hits are NEGATIVE fixtures of the retirement
* machinery itself (`object-fields-io.spec-keys`, `MetadataFieldsPage
* .specKeyReference`): they poison a draft with the retired key precisely to
* assert the read door STRIPS it. A fixture asserting removal is not a producer.
*
* Why `target`'s repo-wide 1329 is not a counter-example: every one of those
* emits belongs to a DIFFERENT TIER. Attributed by the enclosing object's own
* `type` value they are `api` (137), `url` (35), `script` (32), `form` (16),
* `flow` (13), `back` (9), `modal` (6), `fault` (5) — action and navigation
* nodes — plus 1062 with no sibling `type` at all (DOM event targets, link
* targets). Not one lands on a field definition.
*
* ## 2. Why refusal is correct, not merely unused-today
*
* `@objectstack/spec`'s `FieldSchema` refuses BOTH deleted spellings BY NAME
* with `unrecognized_keys`, each carrying its own "did you mean `reference`"
* rename — measured two-directionally against the installed spec, alongside
* `reference` parsing clean. `referenceTo` is additionally in
* `RETIRED_FIELD_KEYS` (objectui#6041 / #6519), so the designer read door
* strips it before any draft round-trips. So these were not "redundant"
* fallbacks: they were INVENTED tolerance surface — a silent absorption point
* for a producer that ought to fail visibly (AGENTS.md #0.1).
*
* ## 3. No precedence inversion exists here — stated rather than fabricated
*
* Both deleted arms sat at the END of the chain
* (`reference_to ?? reference ?? referenceTo ?? target`), so neither could ever
* preempt a contract-carrying spelling. There is therefore NO inversion case to
* pin, and this file deliberately does not invent one: a
* `{ reference: 'a', target: 'b' }` case would resolve to `'a'` both before and
* after the change and would measure nothing. (Copied from PR #6916 / card
* #6840, which set this form and made the same call for `value`.)
*
* ## 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 twenty 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. Deciding between
* `reference_to` and `reference` per reader is objectui#6837's OPEN scope (the
* classification table over ~20 more readers across eight other packages), and
* triage refused a single mechanical sweep because those readers are fed by
* different contracts. #6837 stays open.
*
* ## 5. Ablation direction, predicted before running
*
* Restore either deleted arm on the committed tree and the matching refusal
* goes RED while every live-arm control stays GREEN — that contrast is what
* makes the controls controls rather than duplicates of the pins. The pin
* imports the component by RELATIVE SOURCE PATH (`../RecordDetailDrawer`), so
* no package `exports` hop and no `dist` leg is involved.
*/
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import React from 'react';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { RecordDetailDrawer } from '../RecordDetailDrawer';

/**
* The drawer hands its derived field list to `DetailView` as `schema.fields`.
* Standing in for `DetailView` captures that list without rendering the whole
* detail tree — the resolved `reference_to` under test is a property of the
* list, not of how DetailView paints it. (Same harness as
* `expandableFamily.identity-5874.test.tsx`, which pins the sibling rule
* derived from the very same `.map()`.)
*/
const capturedFields: { current: any[] } = { current: [] };
vi.mock('../DetailView', () => ({
DetailView: ({ schema }: any) => {
capturedFields.current = schema?.fields ?? [];
return <div data-testid="detail-view-stub" />;
},
}));

/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
const FIELD_DEFS: Record<string, Record<string, unknown>> = {
// Live arms — the two spellings a contract actually carries at this seam.
canonical: { type: 'lookup', label: 'Canonical', reference_to: 'crm_account' },
spec_spelling: { type: 'lookup', label: 'Spec', reference: 'crm_account' },
// Deleted arms — refused by name, zero producers in the cell.
legacy_camel: { type: 'lookup', label: 'Legacy camel', referenceTo: 'crm_account' },
invented: { type: 'lookup', label: 'Invented', target: 'crm_account' },
// Non-relation control: carries no target spelling at all.
plain_text: { type: 'text', label: 'Plain' },
};

const RECORD: Record<string, unknown> = {
id: 'r1',
canonical: 'acc-1',
spec_spelling: 'acc-1',
legacy_camel: 'acc-1',
invented: 'acc-1',
plain_text: 'hello',
};

/** Render the drawer over `fields` and return the list handed to DetailView. */
function resolveFields(fields: Record<string, unknown>) {
capturedFields.current = [];
render(
<RecordDetailDrawer
open
onClose={() => {}}
title="Probe"
record={RECORD}
recordId="r1"
objectName="probe"
objectSchema={{ name: 'probe', fields } as any}
onFieldSave={async () => {}}
/>,
);
return capturedFields.current;
}

/** The `reference_to` the drawer resolved for one field name. */
function resolvedTarget(name: string, fields: Record<string, unknown> = FIELD_DEFS) {
return resolveFields(fields).find((f: any) => f.name === name)?.reference_to;
}

describe('RecordDetailDrawer resolves only contract-declared target spellings (objectui#6837)', () => {
describe('live arms — the value still arrives (without these, a drawer that stopped resolving anything would pass the refusals too)', () => {
it("resolves `reference_to`, ObjectUI's own view/field key", () => {
expect(resolvedTarget('canonical')).toBe('crm_account');
});

it('resolves `reference`, the spelling `FieldSchema` accepts', () => {
expect(resolvedTarget('spec_spelling')).toBe('crm_account');
});

it('still derives the whole field list, relations and non-relations alike', () => {
// Guards the refusals below against the degenerate pass: a drawer that
// produced no fields at all would satisfy every `toBeUndefined()`.
const names = resolveFields(FIELD_DEFS).map((f: any) => f.name);
expect(names).toEqual(
expect.arrayContaining(['canonical', 'spec_spelling', 'legacy_camel', 'invented', 'plain_text']),
);
});

it('marks the relation fields readonly, so the list is genuinely populated', () => {
const f = resolveFields(FIELD_DEFS).find((x: any) => x.name === 'canonical');
expect(f?.readonly).toBe(true);
});
});

describe('refusals — one named case per deleted key', () => {
it("does NOT read `referenceTo` (RETIRED_FIELD_KEYS, objectui#6041/#6519; `FieldSchema` refuses it by name)", () => {
expect(resolvedTarget('legacy_camel')).toBeUndefined();
});

it("does NOT read `target` (no contract declares it; 0 producers in the cell against controls of 92 and 52)", () => {
expect(resolvedTarget('invented')).toBeUndefined();
});
});

describe('the ingestion choke point is what makes the `referenceTo` deletion lossless', () => {
it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', () => {
// This is 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 the drawer sees it. The deleted arm was dead weight for those.
const schema = { name: 'probe', fields: { legacy_camel: { ...FIELD_DEFS.legacy_camel } } };
normalizeSchemaReferenceKeys(schema);
expect(resolvedTarget('legacy_camel', schema.fields)).toBe('crm_account');
});

it('a `target`-only def does NOT resolve even through the normalizer — nothing in the stack ever declared it', () => {
// `normalizeFieldReferenceKeys` never read `target` either. So unlike
// `referenceTo`, `target` had no home anywhere in the stack: not in the
// spec, not at the choke point, not in the retirement registry.
const schema = { name: 'probe', fields: { invented: { ...FIELD_DEFS.invented } } };
normalizeSchemaReferenceKeys(schema);
expect(resolvedTarget('invented', schema.fields)).toBeUndefined();
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain by os-sam · Pull Request #6920 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/6837-recorddrawer-invented-target-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/plugin-detail': minor
---

`RecordDetailDrawer` resolves a relationship target only from the two spellings
a contract carries, dropping the two no contract declares (objectui#6837, first
slice).

The chain was `def.reference_to ?? def.reference ?? def.referenceTo ??
def.target`; it is now `def.reference_to ?? def.reference`.

**Accept-set move — a def carrying ONLY `referenceTo`, or ONLY `target`, stops
resolving a target** and the field renders without one (the drawer already marks
every reference-bearing field readonly, so nothing becomes editable that was
not). Two things bound that:

- 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 the drawer sees it. Only a def that
bypassed that door entirely is affected.
- `target` was never read anywhere else in the stack — not by the normalizer,
not by the spec. `@objectstack/spec`'s `FieldSchema` refuses both deleted
spellings by name with `unrecognized_keys`, each carrying its own "did you
mean `reference`" rename, and `referenceTo` is additionally stripped at the
designer read door (`RETIRED_FIELD_KEYS`, objectui#6041 / #6519).

A repo-wide structure-walk producer census found **0** emitters of `target` and
**0** reaching this seam for `referenceTo`, measured in the cell the drawer
reads (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.

Pinned by `RecordDetailDrawer.referenceArms-6837.test.tsx`, which keeps the live
arms green beside a named refusal per deleted key.
28 changes: 26 additions & 2 deletions packages/plugin-detail/src/RecordDetailDrawer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -272,8 +272,32 @@ export function RecordDetailDrawer({
format: def.format,
// Served schemas key the target as `reference` (ObjectStack
// convention, #2407); the drawer can receive a raw schema from any
// DataSource, so resolve every spelling.
reference_to: def.reference_to ?? def.reference ?? def.referenceTo ?? def.target,
// DataSource, so both snake_case spellings are resolved here.
//
// Two further arms stood here until objectui#6837 — `def.referenceTo`
// and `def.target` — and they were NOT redundant-but-harmless: no
// contract declares either spelling. `FieldSchema` refuses BOTH by name
// with `unrecognized_keys`, each carrying its own "did you mean
// `reference`" rename; `referenceTo` is additionally stripped at the
// designer read door (`RETIRED_FIELD_KEYS`, objectui#6041 / #6519), so
// that arm could never hit. A structure-walk producer census found ZERO
// emitters of either at THIS cell — a value inside an object schema's
// `fields` container, which is what `objectSchema.fields[name]` reads —
// while the controls `reference` (92 hits / 36 files) and `reference_to`
// (52 / 36) were hot in the same pass over the same cells. So the two
// arms were invented tolerance surface: a silent absorption point for a
// producer that should fail visibly (AGENTS.md #0.1).
//
// ⛔ Do not re-add a spelling arm here. 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 by a
// renderer-side alias.
//
// The two remaining arms are deliberately left standing: deciding
// between them per reader is objectui#6837's OPEN scope (the
// classification table over ~20 more readers), not this slice's.
reference_to: def.reference_to ?? def.reference,
reference_field: def.reference_field ?? def.referenceField,
required: def.required,
validation: def.validation,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
/**
* 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 (first slice) — the drawer's relationship-target chain drops the
* two arms NO CONTRACT DECLARES, and keeps the two that carry the value.
*
* Before: `def.reference_to ?? def.reference ?? def.referenceTo ?? def.target`
* After: `def.reference_to ?? def.reference`
*
* ## 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/YAML), recording each hit's ancestor property chain; EMIT
* positions only, so `def.target` (a read) is never counted as a producer.
* Subject and control terms were extracted BY THE SAME PASS, FROM THE SAME
* CELLS, IN THE SAME UNITS — the control sits on the JOIN, not merely on the
* terms.
*
* | term | role | repo-wide emits | IN THE CELL |
* |----------------|---------|-----------------|---------------|
* | `target` | SUBJECT | 1329 / 366 files| **0** / 0 |
* | `referenceTo` | SUBJECT | 80 / 41 files| **2** / 2 |
* | `reference` | CONTROL | 194 / 75 files| 92 / 36 |
* | `reference_to` | CONTROL | 136 / 87 files| 52 / 36 |
*
* A second, INDEPENDENT cell test that does not rely on a `fields` ancestor —
* "the enclosing object's own `type` is reference-bearing"
* (`EXPANDABLE_FIELD_TYPES`) — agrees: `target` 0, and every one of the 29
* `referenceTo` hits is a test fixture at OTHER seams (action params, the
* filter builder, the retirement machinery), none in `plugin-detail` and none
* reaching this drawer.
*
* The two in-cell `referenceTo` hits are NEGATIVE fixtures of the retirement
* machinery itself (`object-fields-io.spec-keys`, `MetadataFieldsPage
* .specKeyReference`): they poison a draft with the retired key precisely to
* assert the read door STRIPS it. A fixture asserting removal is not a producer.
*
* Why `target`'s repo-wide 1329 is not a counter-example: every one of those
* emits belongs to a DIFFERENT TIER. Attributed by the enclosing object's own
* `type` value they are `api` (137), `url` (35), `script` (32), `form` (16),
* `flow` (13), `back` (9), `modal` (6), `fault` (5) — action and navigation
* nodes — plus 1062 with no sibling `type` at all (DOM event targets, link
* targets). Not one lands on a field definition.
*
* ## 2. Why refusal is correct, not merely unused-today
*
* `@objectstack/spec`'s `FieldSchema` refuses BOTH deleted spellings BY NAME
* with `unrecognized_keys`, each carrying its own "did you mean `reference`"
* rename — measured two-directionally against the installed spec, alongside
* `reference` parsing clean. `referenceTo` is additionally in
* `RETIRED_FIELD_KEYS` (objectui#6041 / #6519), so the designer read door
* strips it before any draft round-trips. So these were not "redundant"
* fallbacks: they were INVENTED tolerance surface — a silent absorption point
* for a producer that ought to fail visibly (AGENTS.md #0.1).
*
* ## 3. No precedence inversion exists here — stated rather than fabricated
*
* Both deleted arms sat at the END of the chain
* (`reference_to ?? reference ?? referenceTo ?? target`), so neither could ever
* preempt a contract-carrying spelling. There is therefore NO inversion case to
* pin, and this file deliberately does not invent one: a
* `{ reference: 'a', target: 'b' }` case would resolve to `'a'` both before and
* after the change and would measure nothing. (Copied from PR #6916 / card
* #6840, which set this form and made the same call for `value`.)
*
* ## 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 twenty 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. Deciding between
* `reference_to` and `reference` per reader is objectui#6837's OPEN scope (the
* classification table over ~20 more readers across eight other packages), and
* triage refused a single mechanical sweep because those readers are fed by
* different contracts. #6837 stays open.
*
* ## 5. Ablation direction, predicted before running
*
* Restore either deleted arm on the committed tree and the matching refusal
* goes RED while every live-arm control stays GREEN — that contrast is what
* makes the controls controls rather than duplicates of the pins. The pin
* imports the component by RELATIVE SOURCE PATH (`../RecordDetailDrawer`), so
* no package `exports` hop and no `dist` leg is involved.
*/
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import React from 'react';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { RecordDetailDrawer } from '../RecordDetailDrawer';

/**
* The drawer hands its derived field list to `DetailView` as `schema.fields`.
* Standing in for `DetailView` captures that list without rendering the whole
* detail tree — the resolved `reference_to` under test is a property of the
* list, not of how DetailView paints it. (Same harness as
* `expandableFamily.identity-5874.test.tsx`, which pins the sibling rule
* derived from the very same `.map()`.)
*/
const capturedFields: { current: any[] } = { current: [] };
vi.mock('../DetailView', () => ({
DetailView: ({ schema }: any) => {
capturedFields.current = schema?.fields ?? [];
return <div data-testid="detail-view-stub" />;
},
}));

/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
const FIELD_DEFS: Record<string, Record<string, unknown>> = {
// Live arms — the two spellings a contract actually carries at this seam.
canonical: { type: 'lookup', label: 'Canonical', reference_to: 'crm_account' },
spec_spelling: { type: 'lookup', label: 'Spec', reference: 'crm_account' },
// Deleted arms — refused by name, zero producers in the cell.
legacy_camel: { type: 'lookup', label: 'Legacy camel', referenceTo: 'crm_account' },
invented: { type: 'lookup', label: 'Invented', target: 'crm_account' },
// Non-relation control: carries no target spelling at all.
plain_text: { type: 'text', label: 'Plain' },
};

const RECORD: Record<string, unknown> = {
id: 'r1',
canonical: 'acc-1',
spec_spelling: 'acc-1',
legacy_camel: 'acc-1',
invented: 'acc-1',
plain_text: 'hello',
};

/** Render the drawer over `fields` and return the list handed to DetailView. */
function resolveFields(fields: Record<string, unknown>) {
capturedFields.current = [];
render(
<RecordDetailDrawer
open
onClose={() => {}}
title="Probe"
record={RECORD}
recordId="r1"
objectName="probe"
objectSchema={{ name: 'probe', fields } as any}
onFieldSave={async () => {}}
/>,
);
return capturedFields.current;
}

/** The `reference_to` the drawer resolved for one field name. */
function resolvedTarget(name: string, fields: Record<string, unknown> = FIELD_DEFS) {
return resolveFields(fields).find((f: any) => f.name === name)?.reference_to;
}

describe('RecordDetailDrawer resolves only contract-declared target spellings (objectui#6837)', () => {
describe('live arms — the value still arrives (without these, a drawer that stopped resolving anything would pass the refusals too)', () => {
it("resolves `reference_to`, ObjectUI's own view/field key", () => {
expect(resolvedTarget('canonical')).toBe('crm_account');
});

it('resolves `reference`, the spelling `FieldSchema` accepts', () => {
expect(resolvedTarget('spec_spelling')).toBe('crm_account');
});

it('still derives the whole field list, relations and non-relations alike', () => {
// Guards the refusals below against the degenerate pass: a drawer that
// produced no fields at all would satisfy every `toBeUndefined()`.
const names = resolveFields(FIELD_DEFS).map((f: any) => f.name);
expect(names).toEqual(
expect.arrayContaining(['canonical', 'spec_spelling', 'legacy_camel', 'invented', 'plain_text']),
);
});

it('marks the relation fields readonly, so the list is genuinely populated', () => {
const f = resolveFields(FIELD_DEFS).find((x: any) => x.name === 'canonical');
expect(f?.readonly).toBe(true);
});
});

describe('refusals — one named case per deleted key', () => {
it("does NOT read `referenceTo` (RETIRED_FIELD_KEYS, objectui#6041/#6519; `FieldSchema` refuses it by name)", () => {
expect(resolvedTarget('legacy_camel')).toBeUndefined();
});

it("does NOT read `target` (no contract declares it; 0 producers in the cell against controls of 92 and 52)", () => {
expect(resolvedTarget('invented')).toBeUndefined();
});
});

describe('the ingestion choke point is what makes the `referenceTo` deletion lossless', () => {
it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', () => {
// This is 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 the drawer sees it. The deleted arm was dead weight for those.
const schema = { name: 'probe', fields: { legacy_camel: { ...FIELD_DEFS.legacy_camel } } };
normalizeSchemaReferenceKeys(schema);
expect(resolvedTarget('legacy_camel', schema.fields)).toBe('crm_account');
});

it('a `target`-only def does NOT resolve even through the normalizer — nothing in the stack ever declared it', () => {
// `normalizeFieldReferenceKeys` never read `target` either. So unlike
// `referenceTo`, `target` had no home anywhere in the stack: not in the
// spec, not at the choke point, not in the retirement registry.
const schema = { name: 'probe', fields: { invented: { ...FIELD_DEFS.invented } } };
normalizeSchemaReferenceKeys(schema);
expect(resolvedTarget('invented', schema.fields)).toBeUndefined();
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain by os-sam · Pull Request #6920 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/6837-recorddrawer-invented-target-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/plugin-detail': minor
---

`RecordDetailDrawer` resolves a relationship target only from the two spellings
a contract carries, dropping the two no contract declares (objectui#6837, first
slice).

The chain was `def.reference_to ?? def.reference ?? def.referenceTo ??
def.target`; it is now `def.reference_to ?? def.reference`.

**Accept-set move — a def carrying ONLY `referenceTo`, or ONLY `target`, stops
resolving a target** and the field renders without one (the drawer already marks
every reference-bearing field readonly, so nothing becomes editable that was
not). Two things bound that:

- 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 the drawer sees it. Only a def that
bypassed that door entirely is affected.
- `target` was never read anywhere else in the stack — not by the normalizer,
not by the spec. `@objectstack/spec`'s `FieldSchema` refuses both deleted
spellings by name with `unrecognized_keys`, each carrying its own "did you
mean `reference`" rename, and `referenceTo` is additionally stripped at the
designer read door (`RETIRED_FIELD_KEYS`, objectui#6041 / #6519).

A repo-wide structure-walk producer census found **0** emitters of `target` and
**0** reaching this seam for `referenceTo`, measured in the cell the drawer
reads (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.

Pinned by `RecordDetailDrawer.referenceArms-6837.test.tsx`, which keeps the live
arms green beside a named refusal per deleted key.
28 changes: 26 additions & 2 deletions packages/plugin-detail/src/RecordDetailDrawer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -272,8 +272,32 @@ export function RecordDetailDrawer({
format: def.format,
// Served schemas key the target as `reference` (ObjectStack
// convention, #2407); the drawer can receive a raw schema from any
// DataSource, so resolve every spelling.
reference_to: def.reference_to ?? def.reference ?? def.referenceTo ?? def.target,
// DataSource, so both snake_case spellings are resolved here.
//
// Two further arms stood here until objectui#6837 — `def.referenceTo`
// and `def.target` — and they were NOT redundant-but-harmless: no
// contract declares either spelling. `FieldSchema` refuses BOTH by name
// with `unrecognized_keys`, each carrying its own "did you mean
// `reference`" rename; `referenceTo` is additionally stripped at the
// designer read door (`RETIRED_FIELD_KEYS`, objectui#6041 / #6519), so
// that arm could never hit. A structure-walk producer census found ZERO
// emitters of either at THIS cell — a value inside an object schema's
// `fields` container, which is what `objectSchema.fields[name]` reads —
// while the controls `reference` (92 hits / 36 files) and `reference_to`
// (52 / 36) were hot in the same pass over the same cells. So the two
// arms were invented tolerance surface: a silent absorption point for a
// producer that should fail visibly (AGENTS.md #0.1).
//
// ⛔ Do not re-add a spelling arm here. 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 by a
// renderer-side alias.
//
// The two remaining arms are deliberately left standing: deciding
// between them per reader is objectui#6837's OPEN scope (the
// classification table over ~20 more readers), not this slice's.
reference_to: def.reference_to ?? def.reference,
reference_field: def.reference_field ?? def.referenceField,
required: def.required,
validation: def.validation,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
/**
* 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 (first slice) — the drawer's relationship-target chain drops the
* two arms NO CONTRACT DECLARES, and keeps the two that carry the value.
*
* Before: `def.reference_to ?? def.reference ?? def.referenceTo ?? def.target`
* After: `def.reference_to ?? def.reference`
*
* ## 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/YAML), recording each hit's ancestor property chain; EMIT
* positions only, so `def.target` (a read) is never counted as a producer.
* Subject and control terms were extracted BY THE SAME PASS, FROM THE SAME
* CELLS, IN THE SAME UNITS — the control sits on the JOIN, not merely on the
* terms.
*
* | term | role | repo-wide emits | IN THE CELL |
* |----------------|---------|-----------------|---------------|
* | `target` | SUBJECT | 1329 / 366 files| **0** / 0 |
* | `referenceTo` | SUBJECT | 80 / 41 files| **2** / 2 |
* | `reference` | CONTROL | 194 / 75 files| 92 / 36 |
* | `reference_to` | CONTROL | 136 / 87 files| 52 / 36 |
*
* A second, INDEPENDENT cell test that does not rely on a `fields` ancestor —
* "the enclosing object's own `type` is reference-bearing"
* (`EXPANDABLE_FIELD_TYPES`) — agrees: `target` 0, and every one of the 29
* `referenceTo` hits is a test fixture at OTHER seams (action params, the
* filter builder, the retirement machinery), none in `plugin-detail` and none
* reaching this drawer.
*
* The two in-cell `referenceTo` hits are NEGATIVE fixtures of the retirement
* machinery itself (`object-fields-io.spec-keys`, `MetadataFieldsPage
* .specKeyReference`): they poison a draft with the retired key precisely to
* assert the read door STRIPS it. A fixture asserting removal is not a producer.
*
* Why `target`'s repo-wide 1329 is not a counter-example: every one of those
* emits belongs to a DIFFERENT TIER. Attributed by the enclosing object's own
* `type` value they are `api` (137), `url` (35), `script` (32), `form` (16),
* `flow` (13), `back` (9), `modal` (6), `fault` (5) — action and navigation
* nodes — plus 1062 with no sibling `type` at all (DOM event targets, link
* targets). Not one lands on a field definition.
*
* ## 2. Why refusal is correct, not merely unused-today
*
* `@objectstack/spec`'s `FieldSchema` refuses BOTH deleted spellings BY NAME
* with `unrecognized_keys`, each carrying its own "did you mean `reference`"
* rename — measured two-directionally against the installed spec, alongside
* `reference` parsing clean. `referenceTo` is additionally in
* `RETIRED_FIELD_KEYS` (objectui#6041 / #6519), so the designer read door
* strips it before any draft round-trips. So these were not "redundant"
* fallbacks: they were INVENTED tolerance surface — a silent absorption point
* for a producer that ought to fail visibly (AGENTS.md #0.1).
*
* ## 3. No precedence inversion exists here — stated rather than fabricated
*
* Both deleted arms sat at the END of the chain
* (`reference_to ?? reference ?? referenceTo ?? target`), so neither could ever
* preempt a contract-carrying spelling. There is therefore NO inversion case to
* pin, and this file deliberately does not invent one: a
* `{ reference: 'a', target: 'b' }` case would resolve to `'a'` both before and
* after the change and would measure nothing. (Copied from PR #6916 / card
* #6840, which set this form and made the same call for `value`.)
*
* ## 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 twenty 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. Deciding between
* `reference_to` and `reference` per reader is objectui#6837's OPEN scope (the
* classification table over ~20 more readers across eight other packages), and
* triage refused a single mechanical sweep because those readers are fed by
* different contracts. #6837 stays open.
*
* ## 5. Ablation direction, predicted before running
*
* Restore either deleted arm on the committed tree and the matching refusal
* goes RED while every live-arm control stays GREEN — that contrast is what
* makes the controls controls rather than duplicates of the pins. The pin
* imports the component by RELATIVE SOURCE PATH (`../RecordDetailDrawer`), so
* no package `exports` hop and no `dist` leg is involved.
*/
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import React from 'react';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { RecordDetailDrawer } from '../RecordDetailDrawer';

/**
* The drawer hands its derived field list to `DetailView` as `schema.fields`.
* Standing in for `DetailView` captures that list without rendering the whole
* detail tree — the resolved `reference_to` under test is a property of the
* list, not of how DetailView paints it. (Same harness as
* `expandableFamily.identity-5874.test.tsx`, which pins the sibling rule
* derived from the very same `.map()`.)
*/
const capturedFields: { current: any[] } = { current: [] };
vi.mock('../DetailView', () => ({
DetailView: ({ schema }: any) => {
capturedFields.current = schema?.fields ?? [];
return <div data-testid="detail-view-stub" />;
},
}));

/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
const FIELD_DEFS: Record<string, Record<string, unknown>> = {
// Live arms — the two spellings a contract actually carries at this seam.
canonical: { type: 'lookup', label: 'Canonical', reference_to: 'crm_account' },
spec_spelling: { type: 'lookup', label: 'Spec', reference: 'crm_account' },
// Deleted arms — refused by name, zero producers in the cell.
legacy_camel: { type: 'lookup', label: 'Legacy camel', referenceTo: 'crm_account' },
invented: { type: 'lookup', label: 'Invented', target: 'crm_account' },
// Non-relation control: carries no target spelling at all.
plain_text: { type: 'text', label: 'Plain' },
};

const RECORD: Record<string, unknown> = {
id: 'r1',
canonical: 'acc-1',
spec_spelling: 'acc-1',
legacy_camel: 'acc-1',
invented: 'acc-1',
plain_text: 'hello',
};

/** Render the drawer over `fields` and return the list handed to DetailView. */
function resolveFields(fields: Record<string, unknown>) {
capturedFields.current = [];
render(
<RecordDetailDrawer
open
onClose={() => {}}
title="Probe"
record={RECORD}
recordId="r1"
objectName="probe"
objectSchema={{ name: 'probe', fields } as any}
onFieldSave={async () => {}}
/>,
);
return capturedFields.current;
}

/** The `reference_to` the drawer resolved for one field name. */
function resolvedTarget(name: string, fields: Record<string, unknown> = FIELD_DEFS) {
return resolveFields(fields).find((f: any) => f.name === name)?.reference_to;
}

describe('RecordDetailDrawer resolves only contract-declared target spellings (objectui#6837)', () => {
describe('live arms — the value still arrives (without these, a drawer that stopped resolving anything would pass the refusals too)', () => {
it("resolves `reference_to`, ObjectUI's own view/field key", () => {
expect(resolvedTarget('canonical')).toBe('crm_account');
});

it('resolves `reference`, the spelling `FieldSchema` accepts', () => {
expect(resolvedTarget('spec_spelling')).toBe('crm_account');
});

it('still derives the whole field list, relations and non-relations alike', () => {
// Guards the refusals below against the degenerate pass: a drawer that
// produced no fields at all would satisfy every `toBeUndefined()`.
const names = resolveFields(FIELD_DEFS).map((f: any) => f.name);
expect(names).toEqual(
expect.arrayContaining(['canonical', 'spec_spelling', 'legacy_camel', 'invented', 'plain_text']),
);
});

it('marks the relation fields readonly, so the list is genuinely populated', () => {
const f = resolveFields(FIELD_DEFS).find((x: any) => x.name === 'canonical');
expect(f?.readonly).toBe(true);
});
});

describe('refusals — one named case per deleted key', () => {
it("does NOT read `referenceTo` (RETIRED_FIELD_KEYS, objectui#6041/#6519; `FieldSchema` refuses it by name)", () => {
expect(resolvedTarget('legacy_camel')).toBeUndefined();
});

it("does NOT read `target` (no contract declares it; 0 producers in the cell against controls of 92 and 52)", () => {
expect(resolvedTarget('invented')).toBeUndefined();
});
});

describe('the ingestion choke point is what makes the `referenceTo` deletion lossless', () => {
it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', () => {
// This is 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 the drawer sees it. The deleted arm was dead weight for those.
const schema = { name: 'probe', fields: { legacy_camel: { ...FIELD_DEFS.legacy_camel } } };
normalizeSchemaReferenceKeys(schema);
expect(resolvedTarget('legacy_camel', schema.fields)).toBe('crm_account');
});

it('a `target`-only def does NOT resolve even through the normalizer — nothing in the stack ever declared it', () => {
// `normalizeFieldReferenceKeys` never read `target` either. So unlike
// `referenceTo`, `target` had no home anywhere in the stack: not in the
// spec, not at the choke point, not in the retirement registry.
const schema = { name: 'probe', fields: { invented: { ...FIELD_DEFS.invented } } };
normalizeSchemaReferenceKeys(schema);
expect(resolvedTarget('invented', schema.fields)).toBeUndefined();
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain by os-sam · Pull Request #6920 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/6837-recorddrawer-invented-target-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/plugin-detail': minor
---

`RecordDetailDrawer` resolves a relationship target only from the two spellings
a contract carries, dropping the two no contract declares (objectui#6837, first
slice).

The chain was `def.reference_to ?? def.reference ?? def.referenceTo ??
def.target`; it is now `def.reference_to ?? def.reference`.

**Accept-set move — a def carrying ONLY `referenceTo`, or ONLY `target`, stops
resolving a target** and the field renders without one (the drawer already marks
every reference-bearing field readonly, so nothing becomes editable that was
not). Two things bound that:

- 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 the drawer sees it. Only a def that
bypassed that door entirely is affected.
- `target` was never read anywhere else in the stack — not by the normalizer,
not by the spec. `@objectstack/spec`'s `FieldSchema` refuses both deleted
spellings by name with `unrecognized_keys`, each carrying its own "did you
mean `reference`" rename, and `referenceTo` is additionally stripped at the
designer read door (`RETIRED_FIELD_KEYS`, objectui#6041 / #6519).

A repo-wide structure-walk producer census found **0** emitters of `target` and
**0** reaching this seam for `referenceTo`, measured in the cell the drawer
reads (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.

Pinned by `RecordDetailDrawer.referenceArms-6837.test.tsx`, which keeps the live
arms green beside a named refusal per deleted key.
28 changes: 26 additions & 2 deletions packages/plugin-detail/src/RecordDetailDrawer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -272,8 +272,32 @@ export function RecordDetailDrawer({
format: def.format,
// Served schemas key the target as `reference` (ObjectStack
// convention, #2407); the drawer can receive a raw schema from any
// DataSource, so resolve every spelling.
reference_to: def.reference_to ?? def.reference ?? def.referenceTo ?? def.target,
// DataSource, so both snake_case spellings are resolved here.
//
// Two further arms stood here until objectui#6837 — `def.referenceTo`
// and `def.target` — and they were NOT redundant-but-harmless: no
// contract declares either spelling. `FieldSchema` refuses BOTH by name
// with `unrecognized_keys`, each carrying its own "did you mean
// `reference`" rename; `referenceTo` is additionally stripped at the
// designer read door (`RETIRED_FIELD_KEYS`, objectui#6041 / #6519), so
// that arm could never hit. A structure-walk producer census found ZERO
// emitters of either at THIS cell — a value inside an object schema's
// `fields` container, which is what `objectSchema.fields[name]` reads —
// while the controls `reference` (92 hits / 36 files) and `reference_to`
// (52 / 36) were hot in the same pass over the same cells. So the two
// arms were invented tolerance surface: a silent absorption point for a
// producer that should fail visibly (AGENTS.md #0.1).
//
// ⛔ Do not re-add a spelling arm here. 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 by a
// renderer-side alias.
//
// The two remaining arms are deliberately left standing: deciding
// between them per reader is objectui#6837's OPEN scope (the
// classification table over ~20 more readers), not this slice's.
reference_to: def.reference_to ?? def.reference,
reference_field: def.reference_field ?? def.referenceField,
required: def.required,
validation: def.validation,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
/**
* 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 (first slice) — the drawer's relationship-target chain drops the
* two arms NO CONTRACT DECLARES, and keeps the two that carry the value.
*
* Before: `def.reference_to ?? def.reference ?? def.referenceTo ?? def.target`
* After: `def.reference_to ?? def.reference`
*
* ## 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/YAML), recording each hit's ancestor property chain; EMIT
* positions only, so `def.target` (a read) is never counted as a producer.
* Subject and control terms were extracted BY THE SAME PASS, FROM THE SAME
* CELLS, IN THE SAME UNITS — the control sits on the JOIN, not merely on the
* terms.
*
* | term | role | repo-wide emits | IN THE CELL |
* |----------------|---------|-----------------|---------------|
* | `target` | SUBJECT | 1329 / 366 files| **0** / 0 |
* | `referenceTo` | SUBJECT | 80 / 41 files| **2** / 2 |
* | `reference` | CONTROL | 194 / 75 files| 92 / 36 |
* | `reference_to` | CONTROL | 136 / 87 files| 52 / 36 |
*
* A second, INDEPENDENT cell test that does not rely on a `fields` ancestor —
* "the enclosing object's own `type` is reference-bearing"
* (`EXPANDABLE_FIELD_TYPES`) — agrees: `target` 0, and every one of the 29
* `referenceTo` hits is a test fixture at OTHER seams (action params, the
* filter builder, the retirement machinery), none in `plugin-detail` and none
* reaching this drawer.
*
* The two in-cell `referenceTo` hits are NEGATIVE fixtures of the retirement
* machinery itself (`object-fields-io.spec-keys`, `MetadataFieldsPage
* .specKeyReference`): they poison a draft with the retired key precisely to
* assert the read door STRIPS it. A fixture asserting removal is not a producer.
*
* Why `target`'s repo-wide 1329 is not a counter-example: every one of those
* emits belongs to a DIFFERENT TIER. Attributed by the enclosing object's own
* `type` value they are `api` (137), `url` (35), `script` (32), `form` (16),
* `flow` (13), `back` (9), `modal` (6), `fault` (5) — action and navigation
* nodes — plus 1062 with no sibling `type` at all (DOM event targets, link
* targets). Not one lands on a field definition.
*
* ## 2. Why refusal is correct, not merely unused-today
*
* `@objectstack/spec`'s `FieldSchema` refuses BOTH deleted spellings BY NAME
* with `unrecognized_keys`, each carrying its own "did you mean `reference`"
* rename — measured two-directionally against the installed spec, alongside
* `reference` parsing clean. `referenceTo` is additionally in
* `RETIRED_FIELD_KEYS` (objectui#6041 / #6519), so the designer read door
* strips it before any draft round-trips. So these were not "redundant"
* fallbacks: they were INVENTED tolerance surface — a silent absorption point
* for a producer that ought to fail visibly (AGENTS.md #0.1).
*
* ## 3. No precedence inversion exists here — stated rather than fabricated
*
* Both deleted arms sat at the END of the chain
* (`reference_to ?? reference ?? referenceTo ?? target`), so neither could ever
* preempt a contract-carrying spelling. There is therefore NO inversion case to
* pin, and this file deliberately does not invent one: a
* `{ reference: 'a', target: 'b' }` case would resolve to `'a'` both before and
* after the change and would measure nothing. (Copied from PR #6916 / card
* #6840, which set this form and made the same call for `value`.)
*
* ## 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 twenty 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. Deciding between
* `reference_to` and `reference` per reader is objectui#6837's OPEN scope (the
* classification table over ~20 more readers across eight other packages), and
* triage refused a single mechanical sweep because those readers are fed by
* different contracts. #6837 stays open.
*
* ## 5. Ablation direction, predicted before running
*
* Restore either deleted arm on the committed tree and the matching refusal
* goes RED while every live-arm control stays GREEN — that contrast is what
* makes the controls controls rather than duplicates of the pins. The pin
* imports the component by RELATIVE SOURCE PATH (`../RecordDetailDrawer`), so
* no package `exports` hop and no `dist` leg is involved.
*/
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import React from 'react';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { RecordDetailDrawer } from '../RecordDetailDrawer';

/**
* The drawer hands its derived field list to `DetailView` as `schema.fields`.
* Standing in for `DetailView` captures that list without rendering the whole
* detail tree — the resolved `reference_to` under test is a property of the
* list, not of how DetailView paints it. (Same harness as
* `expandableFamily.identity-5874.test.tsx`, which pins the sibling rule
* derived from the very same `.map()`.)
*/
const capturedFields: { current: any[] } = { current: [] };
vi.mock('../DetailView', () => ({
DetailView: ({ schema }: any) => {
capturedFields.current = schema?.fields ?? [];
return <div data-testid="detail-view-stub" />;
},
}));

/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
const FIELD_DEFS: Record<string, Record<string, unknown>> = {
// Live arms — the two spellings a contract actually carries at this seam.
canonical: { type: 'lookup', label: 'Canonical', reference_to: 'crm_account' },
spec_spelling: { type: 'lookup', label: 'Spec', reference: 'crm_account' },
// Deleted arms — refused by name, zero producers in the cell.
legacy_camel: { type: 'lookup', label: 'Legacy camel', referenceTo: 'crm_account' },
invented: { type: 'lookup', label: 'Invented', target: 'crm_account' },
// Non-relation control: carries no target spelling at all.
plain_text: { type: 'text', label: 'Plain' },
};

const RECORD: Record<string, unknown> = {
id: 'r1',
canonical: 'acc-1',
spec_spelling: 'acc-1',
legacy_camel: 'acc-1',
invented: 'acc-1',
plain_text: 'hello',
};

/** Render the drawer over `fields` and return the list handed to DetailView. */
function resolveFields(fields: Record<string, unknown>) {
capturedFields.current = [];
render(
<RecordDetailDrawer
open
onClose={() => {}}
title="Probe"
record={RECORD}
recordId="r1"
objectName="probe"
objectSchema={{ name: 'probe', fields } as any}
onFieldSave={async () => {}}
/>,
);
return capturedFields.current;
}

/** The `reference_to` the drawer resolved for one field name. */
function resolvedTarget(name: string, fields: Record<string, unknown> = FIELD_DEFS) {
return resolveFields(fields).find((f: any) => f.name === name)?.reference_to;
}

describe('RecordDetailDrawer resolves only contract-declared target spellings (objectui#6837)', () => {
describe('live arms — the value still arrives (without these, a drawer that stopped resolving anything would pass the refusals too)', () => {
it("resolves `reference_to`, ObjectUI's own view/field key", () => {
expect(resolvedTarget('canonical')).toBe('crm_account');
});

it('resolves `reference`, the spelling `FieldSchema` accepts', () => {
expect(resolvedTarget('spec_spelling')).toBe('crm_account');
});

it('still derives the whole field list, relations and non-relations alike', () => {
// Guards the refusals below against the degenerate pass: a drawer that
// produced no fields at all would satisfy every `toBeUndefined()`.
const names = resolveFields(FIELD_DEFS).map((f: any) => f.name);
expect(names).toEqual(
expect.arrayContaining(['canonical', 'spec_spelling', 'legacy_camel', 'invented', 'plain_text']),
);
});

it('marks the relation fields readonly, so the list is genuinely populated', () => {
const f = resolveFields(FIELD_DEFS).find((x: any) => x.name === 'canonical');
expect(f?.readonly).toBe(true);
});
});

describe('refusals — one named case per deleted key', () => {
it("does NOT read `referenceTo` (RETIRED_FIELD_KEYS, objectui#6041/#6519; `FieldSchema` refuses it by name)", () => {
expect(resolvedTarget('legacy_camel')).toBeUndefined();
});

it("does NOT read `target` (no contract declares it; 0 producers in the cell against controls of 92 and 52)", () => {
expect(resolvedTarget('invented')).toBeUndefined();
});
});

describe('the ingestion choke point is what makes the `referenceTo` deletion lossless', () => {
it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', () => {
// This is 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 the drawer sees it. The deleted arm was dead weight for those.
const schema = { name: 'probe', fields: { legacy_camel: { ...FIELD_DEFS.legacy_camel } } };
normalizeSchemaReferenceKeys(schema);
expect(resolvedTarget('legacy_camel', schema.fields)).toBe('crm_account');
});

it('a `target`-only def does NOT resolve even through the normalizer — nothing in the stack ever declared it', () => {
// `normalizeFieldReferenceKeys` never read `target` either. So unlike
// `referenceTo`, `target` had no home anywhere in the stack: not in the
// spec, not at the choke point, not in the retirement registry.
const schema = { name: 'probe', fields: { invented: { ...FIELD_DEFS.invented } } };
normalizeSchemaReferenceKeys(schema);
expect(resolvedTarget('invented', schema.fields)).toBeUndefined();
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain by os-sam · Pull Request #6920 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/6837-recorddrawer-invented-target-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/plugin-detail': minor
---

`RecordDetailDrawer` resolves a relationship target only from the two spellings
a contract carries, dropping the two no contract declares (objectui#6837, first
slice).

The chain was `def.reference_to ?? def.reference ?? def.referenceTo ??
def.target`; it is now `def.reference_to ?? def.reference`.

**Accept-set move — a def carrying ONLY `referenceTo`, or ONLY `target`, stops
resolving a target** and the field renders without one (the drawer already marks
every reference-bearing field readonly, so nothing becomes editable that was
not). Two things bound that:

- 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 the drawer sees it. Only a def that
bypassed that door entirely is affected.
- `target` was never read anywhere else in the stack — not by the normalizer,
not by the spec. `@objectstack/spec`'s `FieldSchema` refuses both deleted
spellings by name with `unrecognized_keys`, each carrying its own "did you
mean `reference`" rename, and `referenceTo` is additionally stripped at the
designer read door (`RETIRED_FIELD_KEYS`, objectui#6041 / #6519).

A repo-wide structure-walk producer census found **0** emitters of `target` and
**0** reaching this seam for `referenceTo`, measured in the cell the drawer
reads (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.

Pinned by `RecordDetailDrawer.referenceArms-6837.test.tsx`, which keeps the live
arms green beside a named refusal per deleted key.
28 changes: 26 additions & 2 deletions packages/plugin-detail/src/RecordDetailDrawer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -272,8 +272,32 @@ export function RecordDetailDrawer({
format: def.format,
// Served schemas key the target as `reference` (ObjectStack
// convention, #2407); the drawer can receive a raw schema from any
// DataSource, so resolve every spelling.
reference_to: def.reference_to ?? def.reference ?? def.referenceTo ?? def.target,
// DataSource, so both snake_case spellings are resolved here.
//
// Two further arms stood here until objectui#6837 — `def.referenceTo`
// and `def.target` — and they were NOT redundant-but-harmless: no
// contract declares either spelling. `FieldSchema` refuses BOTH by name
// with `unrecognized_keys`, each carrying its own "did you mean
// `reference`" rename; `referenceTo` is additionally stripped at the
// designer read door (`RETIRED_FIELD_KEYS`, objectui#6041 / #6519), so
// that arm could never hit. A structure-walk producer census found ZERO
// emitters of either at THIS cell — a value inside an object schema's
// `fields` container, which is what `objectSchema.fields[name]` reads —
// while the controls `reference` (92 hits / 36 files) and `reference_to`
// (52 / 36) were hot in the same pass over the same cells. So the two
// arms were invented tolerance surface: a silent absorption point for a
// producer that should fail visibly (AGENTS.md #0.1).
//
// ⛔ Do not re-add a spelling arm here. 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 by a
// renderer-side alias.
//
// The two remaining arms are deliberately left standing: deciding
// between them per reader is objectui#6837's OPEN scope (the
// classification table over ~20 more readers), not this slice's.
reference_to: def.reference_to ?? def.reference,
reference_field: def.reference_field ?? def.referenceField,
required: def.required,
validation: def.validation,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
/**
* 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 (first slice) — the drawer's relationship-target chain drops the
* two arms NO CONTRACT DECLARES, and keeps the two that carry the value.
*
* Before: `def.reference_to ?? def.reference ?? def.referenceTo ?? def.target`
* After: `def.reference_to ?? def.reference`
*
* ## 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/YAML), recording each hit's ancestor property chain; EMIT
* positions only, so `def.target` (a read) is never counted as a producer.
* Subject and control terms were extracted BY THE SAME PASS, FROM THE SAME
* CELLS, IN THE SAME UNITS — the control sits on the JOIN, not merely on the
* terms.
*
* | term | role | repo-wide emits | IN THE CELL |
* |----------------|---------|-----------------|---------------|
* | `target` | SUBJECT | 1329 / 366 files| **0** / 0 |
* | `referenceTo` | SUBJECT | 80 / 41 files| **2** / 2 |
* | `reference` | CONTROL | 194 / 75 files| 92 / 36 |
* | `reference_to` | CONTROL | 136 / 87 files| 52 / 36 |
*
* A second, INDEPENDENT cell test that does not rely on a `fields` ancestor —
* "the enclosing object's own `type` is reference-bearing"
* (`EXPANDABLE_FIELD_TYPES`) — agrees: `target` 0, and every one of the 29
* `referenceTo` hits is a test fixture at OTHER seams (action params, the
* filter builder, the retirement machinery), none in `plugin-detail` and none
* reaching this drawer.
*
* The two in-cell `referenceTo` hits are NEGATIVE fixtures of the retirement
* machinery itself (`object-fields-io.spec-keys`, `MetadataFieldsPage
* .specKeyReference`): they poison a draft with the retired key precisely to
* assert the read door STRIPS it. A fixture asserting removal is not a producer.
*
* Why `target`'s repo-wide 1329 is not a counter-example: every one of those
* emits belongs to a DIFFERENT TIER. Attributed by the enclosing object's own
* `type` value they are `api` (137), `url` (35), `script` (32), `form` (16),
* `flow` (13), `back` (9), `modal` (6), `fault` (5) — action and navigation
* nodes — plus 1062 with no sibling `type` at all (DOM event targets, link
* targets). Not one lands on a field definition.
*
* ## 2. Why refusal is correct, not merely unused-today
*
* `@objectstack/spec`'s `FieldSchema` refuses BOTH deleted spellings BY NAME
* with `unrecognized_keys`, each carrying its own "did you mean `reference`"
* rename — measured two-directionally against the installed spec, alongside
* `reference` parsing clean. `referenceTo` is additionally in
* `RETIRED_FIELD_KEYS` (objectui#6041 / #6519), so the designer read door
* strips it before any draft round-trips. So these were not "redundant"
* fallbacks: they were INVENTED tolerance surface — a silent absorption point
* for a producer that ought to fail visibly (AGENTS.md #0.1).
*
* ## 3. No precedence inversion exists here — stated rather than fabricated
*
* Both deleted arms sat at the END of the chain
* (`reference_to ?? reference ?? referenceTo ?? target`), so neither could ever
* preempt a contract-carrying spelling. There is therefore NO inversion case to
* pin, and this file deliberately does not invent one: a
* `{ reference: 'a', target: 'b' }` case would resolve to `'a'` both before and
* after the change and would measure nothing. (Copied from PR #6916 / card
* #6840, which set this form and made the same call for `value`.)
*
* ## 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 twenty 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. Deciding between
* `reference_to` and `reference` per reader is objectui#6837's OPEN scope (the
* classification table over ~20 more readers across eight other packages), and
* triage refused a single mechanical sweep because those readers are fed by
* different contracts. #6837 stays open.
*
* ## 5. Ablation direction, predicted before running
*
* Restore either deleted arm on the committed tree and the matching refusal
* goes RED while every live-arm control stays GREEN — that contrast is what
* makes the controls controls rather than duplicates of the pins. The pin
* imports the component by RELATIVE SOURCE PATH (`../RecordDetailDrawer`), so
* no package `exports` hop and no `dist` leg is involved.
*/
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import React from 'react';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { RecordDetailDrawer } from '../RecordDetailDrawer';

/**
* The drawer hands its derived field list to `DetailView` as `schema.fields`.
* Standing in for `DetailView` captures that list without rendering the whole
* detail tree — the resolved `reference_to` under test is a property of the
* list, not of how DetailView paints it. (Same harness as
* `expandableFamily.identity-5874.test.tsx`, which pins the sibling rule
* derived from the very same `.map()`.)
*/
const capturedFields: { current: any[] } = { current: [] };
vi.mock('../DetailView', () => ({
DetailView: ({ schema }: any) => {
capturedFields.current = schema?.fields ?? [];
return <div data-testid="detail-view-stub" />;
},
}));

/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
const FIELD_DEFS: Record<string, Record<string, unknown>> = {
// Live arms — the two spellings a contract actually carries at this seam.
canonical: { type: 'lookup', label: 'Canonical', reference_to: 'crm_account' },
spec_spelling: { type: 'lookup', label: 'Spec', reference: 'crm_account' },
// Deleted arms — refused by name, zero producers in the cell.
legacy_camel: { type: 'lookup', label: 'Legacy camel', referenceTo: 'crm_account' },
invented: { type: 'lookup', label: 'Invented', target: 'crm_account' },
// Non-relation control: carries no target spelling at all.
plain_text: { type: 'text', label: 'Plain' },
};

const RECORD: Record<string, unknown> = {
id: 'r1',
canonical: 'acc-1',
spec_spelling: 'acc-1',
legacy_camel: 'acc-1',
invented: 'acc-1',
plain_text: 'hello',
};

/** Render the drawer over `fields` and return the list handed to DetailView. */
function resolveFields(fields: Record<string, unknown>) {
capturedFields.current = [];
render(
<RecordDetailDrawer
open
onClose={() => {}}
title="Probe"
record={RECORD}
recordId="r1"
objectName="probe"
objectSchema={{ name: 'probe', fields } as any}
onFieldSave={async () => {}}
/>,
);
return capturedFields.current;
}

/** The `reference_to` the drawer resolved for one field name. */
function resolvedTarget(name: string, fields: Record<string, unknown> = FIELD_DEFS) {
return resolveFields(fields).find((f: any) => f.name === name)?.reference_to;
}

describe('RecordDetailDrawer resolves only contract-declared target spellings (objectui#6837)', () => {
describe('live arms — the value still arrives (without these, a drawer that stopped resolving anything would pass the refusals too)', () => {
it("resolves `reference_to`, ObjectUI's own view/field key", () => {
expect(resolvedTarget('canonical')).toBe('crm_account');
});

it('resolves `reference`, the spelling `FieldSchema` accepts', () => {
expect(resolvedTarget('spec_spelling')).toBe('crm_account');
});

it('still derives the whole field list, relations and non-relations alike', () => {
// Guards the refusals below against the degenerate pass: a drawer that
// produced no fields at all would satisfy every `toBeUndefined()`.
const names = resolveFields(FIELD_DEFS).map((f: any) => f.name);
expect(names).toEqual(
expect.arrayContaining(['canonical', 'spec_spelling', 'legacy_camel', 'invented', 'plain_text']),
);
});

it('marks the relation fields readonly, so the list is genuinely populated', () => {
const f = resolveFields(FIELD_DEFS).find((x: any) => x.name === 'canonical');
expect(f?.readonly).toBe(true);
});
});

describe('refusals — one named case per deleted key', () => {
it("does NOT read `referenceTo` (RETIRED_FIELD_KEYS, objectui#6041/#6519; `FieldSchema` refuses it by name)", () => {
expect(resolvedTarget('legacy_camel')).toBeUndefined();
});

it("does NOT read `target` (no contract declares it; 0 producers in the cell against controls of 92 and 52)", () => {
expect(resolvedTarget('invented')).toBeUndefined();
});
});

describe('the ingestion choke point is what makes the `referenceTo` deletion lossless', () => {
it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', () => {
// This is 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 the drawer sees it. The deleted arm was dead weight for those.
const schema = { name: 'probe', fields: { legacy_camel: { ...FIELD_DEFS.legacy_camel } } };
normalizeSchemaReferenceKeys(schema);
expect(resolvedTarget('legacy_camel', schema.fields)).toBe('crm_account');
});

it('a `target`-only def does NOT resolve even through the normalizer — nothing in the stack ever declared it', () => {
// `normalizeFieldReferenceKeys` never read `target` either. So unlike
// `referenceTo`, `target` had no home anywhere in the stack: not in the
// spec, not at the choke point, not in the retirement registry.
const schema = { name: 'probe', fields: { invented: { ...FIELD_DEFS.invented } } };
normalizeSchemaReferenceKeys(schema);
expect(resolvedTarget('invented', schema.fields)).toBeUndefined();
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain by os-sam · Pull Request #6920 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/6837-recorddrawer-invented-target-arms.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/plugin-detail': minor
---

`RecordDetailDrawer` resolves a relationship target only from the two spellings
a contract carries, dropping the two no contract declares (objectui#6837, first
slice).

The chain was `def.reference_to ?? def.reference ?? def.referenceTo ??
def.target`; it is now `def.reference_to ?? def.reference`.

**Accept-set move — a def carrying ONLY `referenceTo`, or ONLY `target`, stops
resolving a target** and the field renders without one (the drawer already marks
every reference-bearing field readonly, so nothing becomes editable that was
not). Two things bound that:

- 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 the drawer sees it. Only a def that
bypassed that door entirely is affected.
- `target` was never read anywhere else in the stack — not by the normalizer,
not by the spec. `@objectstack/spec`'s `FieldSchema` refuses both deleted
spellings by name with `unrecognized_keys`, each carrying its own "did you
mean `reference`" rename, and `referenceTo` is additionally stripped at the
designer read door (`RETIRED_FIELD_KEYS`, objectui#6041 / #6519).

A repo-wide structure-walk producer census found **0** emitters of `target` and
**0** reaching this seam for `referenceTo`, measured in the cell the drawer
reads (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.

Pinned by `RecordDetailDrawer.referenceArms-6837.test.tsx`, which keeps the live
arms green beside a named refusal per deleted key.
28 changes: 26 additions & 2 deletions packages/plugin-detail/src/RecordDetailDrawer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -272,8 +272,32 @@ export function RecordDetailDrawer({
format: def.format,
// Served schemas key the target as `reference` (ObjectStack
// convention, #2407); the drawer can receive a raw schema from any
// DataSource, so resolve every spelling.
reference_to: def.reference_to ?? def.reference ?? def.referenceTo ?? def.target,
// DataSource, so both snake_case spellings are resolved here.
//
// Two further arms stood here until objectui#6837 — `def.referenceTo`
// and `def.target` — and they were NOT redundant-but-harmless: no
// contract declares either spelling. `FieldSchema` refuses BOTH by name
// with `unrecognized_keys`, each carrying its own "did you mean
// `reference`" rename; `referenceTo` is additionally stripped at the
// designer read door (`RETIRED_FIELD_KEYS`, objectui#6041 / #6519), so
// that arm could never hit. A structure-walk producer census found ZERO
// emitters of either at THIS cell — a value inside an object schema's
// `fields` container, which is what `objectSchema.fields[name]` reads —
// while the controls `reference` (92 hits / 36 files) and `reference_to`
// (52 / 36) were hot in the same pass over the same cells. So the two
// arms were invented tolerance surface: a silent absorption point for a
// producer that should fail visibly (AGENTS.md #0.1).
//
// ⛔ Do not re-add a spelling arm here. 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 by a
// renderer-side alias.
//
// The two remaining arms are deliberately left standing: deciding
// between them per reader is objectui#6837's OPEN scope (the
// classification table over ~20 more readers), not this slice's.
reference_to: def.reference_to ?? def.reference,
reference_field: def.reference_field ?? def.referenceField,
required: def.required,
validation: def.validation,
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
/**
* 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 (first slice) — the drawer's relationship-target chain drops the
* two arms NO CONTRACT DECLARES, and keeps the two that carry the value.
*
* Before: `def.reference_to ?? def.reference ?? def.referenceTo ?? def.target`
* After: `def.reference_to ?? def.reference`
*
* ## 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/YAML), recording each hit's ancestor property chain; EMIT
* positions only, so `def.target` (a read) is never counted as a producer.
* Subject and control terms were extracted BY THE SAME PASS, FROM THE SAME
* CELLS, IN THE SAME UNITS — the control sits on the JOIN, not merely on the
* terms.
*
* | term | role | repo-wide emits | IN THE CELL |
* |----------------|---------|-----------------|---------------|
* | `target` | SUBJECT | 1329 / 366 files| **0** / 0 |
* | `referenceTo` | SUBJECT | 80 / 41 files| **2** / 2 |
* | `reference` | CONTROL | 194 / 75 files| 92 / 36 |
* | `reference_to` | CONTROL | 136 / 87 files| 52 / 36 |
*
* A second, INDEPENDENT cell test that does not rely on a `fields` ancestor —
* "the enclosing object's own `type` is reference-bearing"
* (`EXPANDABLE_FIELD_TYPES`) — agrees: `target` 0, and every one of the 29
* `referenceTo` hits is a test fixture at OTHER seams (action params, the
* filter builder, the retirement machinery), none in `plugin-detail` and none
* reaching this drawer.
*
* The two in-cell `referenceTo` hits are NEGATIVE fixtures of the retirement
* machinery itself (`object-fields-io.spec-keys`, `MetadataFieldsPage
* .specKeyReference`): they poison a draft with the retired key precisely to
* assert the read door STRIPS it. A fixture asserting removal is not a producer.
*
* Why `target`'s repo-wide 1329 is not a counter-example: every one of those
* emits belongs to a DIFFERENT TIER. Attributed by the enclosing object's own
* `type` value they are `api` (137), `url` (35), `script` (32), `form` (16),
* `flow` (13), `back` (9), `modal` (6), `fault` (5) — action and navigation
* nodes — plus 1062 with no sibling `type` at all (DOM event targets, link
* targets). Not one lands on a field definition.
*
* ## 2. Why refusal is correct, not merely unused-today
*
* `@objectstack/spec`'s `FieldSchema` refuses BOTH deleted spellings BY NAME
* with `unrecognized_keys`, each carrying its own "did you mean `reference`"
* rename — measured two-directionally against the installed spec, alongside
* `reference` parsing clean. `referenceTo` is additionally in
* `RETIRED_FIELD_KEYS` (objectui#6041 / #6519), so the designer read door
* strips it before any draft round-trips. So these were not "redundant"
* fallbacks: they were INVENTED tolerance surface — a silent absorption point
* for a producer that ought to fail visibly (AGENTS.md #0.1).
*
* ## 3. No precedence inversion exists here — stated rather than fabricated
*
* Both deleted arms sat at the END of the chain
* (`reference_to ?? reference ?? referenceTo ?? target`), so neither could ever
* preempt a contract-carrying spelling. There is therefore NO inversion case to
* pin, and this file deliberately does not invent one: a
* `{ reference: 'a', target: 'b' }` case would resolve to `'a'` both before and
* after the change and would measure nothing. (Copied from PR #6916 / card
* #6840, which set this form and made the same call for `value`.)
*
* ## 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 twenty 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. Deciding between
* `reference_to` and `reference` per reader is objectui#6837's OPEN scope (the
* classification table over ~20 more readers across eight other packages), and
* triage refused a single mechanical sweep because those readers are fed by
* different contracts. #6837 stays open.
*
* ## 5. Ablation direction, predicted before running
*
* Restore either deleted arm on the committed tree and the matching refusal
* goes RED while every live-arm control stays GREEN — that contrast is what
* makes the controls controls rather than duplicates of the pins. The pin
* imports the component by RELATIVE SOURCE PATH (`../RecordDetailDrawer`), so
* no package `exports` hop and no `dist` leg is involved.
*/
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import React from 'react';
import { normalizeSchemaReferenceKeys } from '@object-ui/core';
import { RecordDetailDrawer } from '../RecordDetailDrawer';

/**
* The drawer hands its derived field list to `DetailView` as `schema.fields`.
* Standing in for `DetailView` captures that list without rendering the whole
* detail tree — the resolved `reference_to` under test is a property of the
* list, not of how DetailView paints it. (Same harness as
* `expandableFamily.identity-5874.test.tsx`, which pins the sibling rule
* derived from the very same `.map()`.)
*/
const capturedFields: { current: any[] } = { current: [] };
vi.mock('../DetailView', () => ({
DetailView: ({ schema }: any) => {
capturedFields.current = schema?.fields ?? [];
return <div data-testid="detail-view-stub" />;
},
}));

/** Every probe is a `lookup`, so only the target SPELLING varies between them. */
const FIELD_DEFS: Record<string, Record<string, unknown>> = {
// Live arms — the two spellings a contract actually carries at this seam.
canonical: { type: 'lookup', label: 'Canonical', reference_to: 'crm_account' },
spec_spelling: { type: 'lookup', label: 'Spec', reference: 'crm_account' },
// Deleted arms — refused by name, zero producers in the cell.
legacy_camel: { type: 'lookup', label: 'Legacy camel', referenceTo: 'crm_account' },
invented: { type: 'lookup', label: 'Invented', target: 'crm_account' },
// Non-relation control: carries no target spelling at all.
plain_text: { type: 'text', label: 'Plain' },
};

const RECORD: Record<string, unknown> = {
id: 'r1',
canonical: 'acc-1',
spec_spelling: 'acc-1',
legacy_camel: 'acc-1',
invented: 'acc-1',
plain_text: 'hello',
};

/** Render the drawer over `fields` and return the list handed to DetailView. */
function resolveFields(fields: Record<string, unknown>) {
capturedFields.current = [];
render(
<RecordDetailDrawer
open
onClose={() => {}}
title="Probe"
record={RECORD}
recordId="r1"
objectName="probe"
objectSchema={{ name: 'probe', fields } as any}
onFieldSave={async () => {}}
/>,
);
return capturedFields.current;
}

/** The `reference_to` the drawer resolved for one field name. */
function resolvedTarget(name: string, fields: Record<string, unknown> = FIELD_DEFS) {
return resolveFields(fields).find((f: any) => f.name === name)?.reference_to;
}

describe('RecordDetailDrawer resolves only contract-declared target spellings (objectui#6837)', () => {
describe('live arms — the value still arrives (without these, a drawer that stopped resolving anything would pass the refusals too)', () => {
it("resolves `reference_to`, ObjectUI's own view/field key", () => {
expect(resolvedTarget('canonical')).toBe('crm_account');
});

it('resolves `reference`, the spelling `FieldSchema` accepts', () => {
expect(resolvedTarget('spec_spelling')).toBe('crm_account');
});

it('still derives the whole field list, relations and non-relations alike', () => {
// Guards the refusals below against the degenerate pass: a drawer that
// produced no fields at all would satisfy every `toBeUndefined()`.
const names = resolveFields(FIELD_DEFS).map((f: any) => f.name);
expect(names).toEqual(
expect.arrayContaining(['canonical', 'spec_spelling', 'legacy_camel', 'invented', 'plain_text']),
);
});

it('marks the relation fields readonly, so the list is genuinely populated', () => {
const f = resolveFields(FIELD_DEFS).find((x: any) => x.name === 'canonical');
expect(f?.readonly).toBe(true);
});
});

describe('refusals — one named case per deleted key', () => {
it("does NOT read `referenceTo` (RETIRED_FIELD_KEYS, objectui#6041/#6519; `FieldSchema` refuses it by name)", () => {
expect(resolvedTarget('legacy_camel')).toBeUndefined();
});

it("does NOT read `target` (no contract declares it; 0 producers in the cell against controls of 92 and 52)", () => {
expect(resolvedTarget('invented')).toBeUndefined();
});
});

describe('the ingestion choke point is what makes the `referenceTo` deletion lossless', () => {
it('a `referenceTo`-only def that came through `normalizeSchemaReferenceKeys` STILL resolves', () => {
// This is 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 the drawer sees it. The deleted arm was dead weight for those.
const schema = { name: 'probe', fields: { legacy_camel: { ...FIELD_DEFS.legacy_camel } } };
normalizeSchemaReferenceKeys(schema);
expect(resolvedTarget('legacy_camel', schema.fields)).toBe('crm_account');
});

it('a `target`-only def does NOT resolve even through the normalizer — nothing in the stack ever declared it', () => {
// `normalizeFieldReferenceKeys` never read `target` either. So unlike
// `referenceTo`, `target` had no home anywhere in the stack: not in the
// spec, not at the choke point, not in the retirement registry.
const schema = { name: 'probe', fields: { invented: { ...FIELD_DEFS.invented } } };
normalizeSchemaReferenceKeys(schema);
expect(resolvedTarget('invented', schema.fields)).toBeUndefined();
});
});
});
Loading