Skip to content

Commit 07eefb0

Browse files
docs(adr),chore(devx): reconstruct ADR-0079 + make cited ADR numbers resolve (#6634) (#6732)
ADR-0079 was cited by 77 tracked files in this repo (14 occurrences inside packages/spec source alone) while docs/adr/ held no 0079 record. Nothing the citing sites SAID was wrong — the display-name contract they describe is the one the code implements — but the pointer was dead and the number was squatted. - docs/adr/0079-record-display-name.md — the record, reconstructed from the citations and labelled as a retroactive reconstruction (2026-08-08) rather than a contemporaneous record. Provenance is explicit: PR #2434's description places the contemporaneous original in objectstack-ai/cloud at docs/adr/0079-record-display-name.md, which is why docs/adr/0079-* never existed here — a cross-repo split, not a loss. The reconstruction reuses that slug and could not be diffed against the original (repo unreachable). Every clause carries a file:line citation; four questions the citations leave open are listed as open, not answered. - scripts/check-adr-anchors.mjs — third audit: every ADR-NNNN in a tracked file must name a record under docs/adr/. A sibling repo's registry is exempted structurally by a qualifier ("ObjectUI ADR-0001"), not by allowlist; two withdrawn/deleted numbers (0001, 0107) sit on a shrink-only allowlist audited in both directions. Red path exercised in --self-test over the real function (39 assertions) plus a live-tree ablation. Deletion-history chase: re-run on the FULL history (the local clone was shallow at 106 commits; unshallowed to 8810). docs/adr/0079-* has never existed here — the only path ever containing "0079" is .changeset/adr-0079-record-title-gate.md. Fixes #6634 Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8ad609c commit 07eefb0

2 files changed

Lines changed: 726 additions & 2 deletions

File tree

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
# ADR-0079: The record display-name contract — `nameField` is the one canonical primary-title pointer
2+
3+
- **Status**: Accepted — implemented. **This file is a RETROACTIVE RECONSTRUCTION written 2026-08-08**, not a contemporaneous record; see [Provenance](#provenance-read-this-before-citing-this-file) before you cite it.
4+
- **Decided**: 2026-06-28 (the decision), reconstructed 2026-08-08 (this file)
5+
- **Reconstructed by**: #6634
6+
- **Implemented by**: [#2434](https://github.com/objectstack-ai/objectstack/pull/2434) (foundation — `nameField` + resolver), [#2458](https://github.com/objectstack-ai/objectstack/pull/2458) (designate-only provisioning at the registry seam), [#2463](https://github.com/objectstack-ai/objectstack/pull/2463) (the author-time gate)
7+
- **Builds on**: [ADR-0078](./0078-no-silently-inert-metadata.md) (author-time completeness must not be cloud-only), [ADR-0061](./0061-record-search-architecture.md) (`$search` field resolution reads the display field)
8+
- **Precedent for**: [ADR-0085](./0085-object-semantic-roles-over-surface-hint-blocks.md) (`compactLayout``highlightFields` follows this ADR's alias mechanics "exactly"), [ADR-0098](./0098-pinyin-search-companion-column.md) (the search companion column is fed by this ADR's resolved display field)
9+
- **Consumers**: `@objectstack/spec` (`data/display-name.ts`, `data/object.zod.ts`, `data/search-fields.ts`, `ai/solution-blueprint.zod.ts`), `@objectstack/objectql` (registry materialization seam, `$search` expansion, search companion), `@objectstack/metadata-protocol` (REST `$searchFields` ingress gate), `@objectstack/lint` (`validate-record-title`, `data-model-rules` R9), `@objectstack/platform-objects` and the first-party plugins/services (41 explicit `nameField:` designations), the `objectstack-data` / `objectstack-formula` skills
10+
11+
---
12+
13+
## Provenance — read this before citing this file
14+
15+
**This file was written on 2026-08-08, roughly six weeks after the decision it
16+
records, by reading the code that cites it.** It is a reconstruction. Treat its
17+
*emphasis and phrasing* as this author's, and its *content* as evidence-backed
18+
but second-hand.
19+
20+
Three facts make that disclosure necessary rather than decorative:
21+
22+
1. **The decision is real and contemporaneous; only this file is late.** It was
23+
taken on 2026-06-28 and implemented across three PRs in two days
24+
(#2434 / #2458 / #2463). Nothing here is being decided now.
25+
26+
2. **A contemporaneous record exists — in a different repository.** #2434's
27+
own description ends: *"ADR: `objectstack-ai/cloud`
28+
`docs/adr/0079-record-display-name.md`."* The decision record was authored
29+
in the sibling `cloud` repo while every consumer of the decision was built
30+
here. That is why `docs/adr/0079-*` has never existed in this repo's
31+
history — not a loss, a **cross-repo split**. This file deliberately reuses
32+
the original's slug (`record-display-name`) so the two are recognisably one
33+
record.
34+
35+
⚠️ **The reconstruction could not be checked against that original.** The
36+
`cloud` repo was not reachable from the session that wrote this file. Where
37+
this document and the cloud original differ, **the cloud original is the
38+
decision** and this file is the bug — say so in an issue and this file gets
39+
corrected.
40+
41+
3. **Why write it here at all, rather than a pointer.** 77 files in *this*
42+
repo cite `ADR-0079` (measurement in #6634), and every other `ADR-NNNN` they
43+
cite resolves to `docs/adr/` *here*. A reader who follows the convention
44+
lands on nothing. A pointer to a repo most readers of this one cannot open
45+
is not an improvement on nothing; the decision has to be *readable* at the
46+
end of the citation.
47+
48+
**What is reconstructed vs. what is quoted.** Every clause in
49+
[Decision](#decision) is traceable to a citing site, quoted or cited by
50+
`file:line`. The [Context](#context) section is inference from the
51+
implementation and the three PR descriptions. [What this
52+
reconstruction does NOT settle](#what-this-reconstruction-does-not-settle) is
53+
the honest residue: places where the 77 citing sites are silent, or where they
54+
use one word for two different states. Those are **not** decided here.
55+
56+
---
57+
58+
## Context
59+
60+
A record's human name — what shows on a card, a lookup chip, a breadcrumb, an
61+
approval notification, a search result — was, before this decision, a
62+
render-time guess. Two object-level keys competed (`displayNameField`, a
63+
render-only `titleFormat` template), neither was required, and consumers each
64+
re-derived a title from whatever they found.
65+
66+
That is the ADR-0078 failure shape applied to identity: an object with no
67+
resolvable title parses, "renders", and reports success — and every record it
68+
holds is anonymous. The cost is asymmetric for an AI author, which is the
69+
argument ADR-0078 makes in general and which lands hardest here, because the
70+
symptom (records displaying as raw IDs) appears only in a UI a build never
71+
opens.
72+
73+
The specific defects the implementation names:
74+
75+
- **`titleFormat` is unqueryable by construction.** It is a render-only
76+
template. `packages/lint/src/validate-record-title.ts:92` states the
77+
consequence: *"titleFormat is a render-only template the server cannot return
78+
or query"*. A title the server cannot return cannot be sorted on, searched,
79+
or sent in a notification body — so a title expressed only as a template is
80+
not a title, it is a client-side decoration.
81+
- **A guessed title cannot be relied on by the layers underneath the UI.**
82+
`$search` expansion, the REST `$searchFields` ingress gate, the pinyin search
83+
companion column, and approval/notification display enrichment all need *one*
84+
answer to "what is this record called", computed the same way at every seam.
85+
- **Two spellings, no canon.** `displayNameField` existed; nothing said it was
86+
the authority, and nothing stopped a third spelling appearing.
87+
88+
---
89+
90+
## Decision
91+
92+
### D1 — A record's title is a structural invariant, not a render-time hint
93+
94+
Every object has exactly **one** primary title field, and it is a **real stored
95+
field**`text`-ish, `autonumber`, or a `formula` whose result type is `text`.
96+
Not a template, not a client-side composition.
97+
98+
> "A record's human title is a STRUCTURAL INVARIANT: every object has exactly
99+
> one primary title field, which is a real STORED field (text / autonumber /
100+
> formula whose result is text)."
101+
> `packages/spec/src/data/display-name.ts:6-8`
102+
103+
### D2 — `nameField` is canonical; `displayNameField` is a deprecated alias
104+
105+
`nameField` is the object-level pointer to the primary title field.
106+
`displayNameField` is **accepted as a parse-time alias**, never as a second
107+
contract:
108+
109+
- `ObjectSchema.parse` / `.safeParse` / `.create()` copy `displayNameField` onto
110+
`nameField` when `nameField` is absent
111+
(`normalizeNameFieldAlias`, `packages/spec/src/data/object.zod.ts:2082-2095`,
112+
installed on the parse path at `:2149-2158` and reached by `create()` at
113+
`:2204`).
114+
- **Both keys are preserved on the parsed output** — deliberately, for
115+
cross-repo consumers and older tests that still read the old spelling
116+
(`object.zod.ts:2084-2085`).
117+
- The schema's own text marks the direction: `displayNameField` describes
118+
itself as `[DEPRECATED → nameField]` (`object.zod.ts:1698`).
119+
120+
This is the alias mechanic ADR-0085 later adopted verbatim: *"Mechanics follow
121+
ADR-0079's `displayNameField → nameField` precedent exactly: `compactLayout` is
122+
accepted as a parse-time alias, copied onto `highlightFields`, both preserved on
123+
output, describe marks the old key deprecated."*
124+
(`docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md:57`)
125+
126+
### D3 — `titleFormat` is retired in favour of `nameField`
127+
128+
`titleFormat` is deprecated and lint-warned, and an explicit `nameField` takes
129+
precedence over it. It **still parses** — existing metadata keeps loading — so
130+
the diagnostic is advisory, not an error:
131+
132+
> "`title-format-retired` — flags an object that declares a `titleFormat`. That
133+
> key is a render-only template the server can neither return nor query;
134+
> ADR-0079 retires it in favour of `nameField`. The schema still parses it
135+
> (existing metadata keeps loading), so this is advisory, not an error."
136+
> — the #2463 changeset (`.changeset/adr-0079-record-title-gate.md`, since
137+
> consumed by the release)
138+
139+
**Migration is stated, not left to the author**: a single-field title becomes
140+
`nameField: '<field>'`; a **composite** title becomes a `formula` field with
141+
`returnType: 'text'`, designated as the `nameField`
142+
(`validate-record-title.ts:91-96`; the worked example is
143+
`content/docs/data-modeling/formulas.mdx:94-102`).
144+
145+
### D4 — One resolution order, shared by every consumer
146+
147+
nameField ?? displayNameField ?? deterministic derivation
148+
149+
An **explicit pointer is honored even when the field it names is not
150+
title-eligible** — the author asserted it, and eligibility gates *derivation*
151+
only (`display-name.ts:resolveDisplayField`).
152+
153+
Derivation, restricted to title-eligible fields, is ranked:
154+
155+
1. name-ish **exact**, in priority order — `name` > `title` > `subject` >
156+
`label` > `full_name` > `display_name` (so `name` beats `title` regardless of
157+
declaration order);
158+
2. name-ish **affix**`*_name` / `*_title` / `name_*`, by declaration order;
159+
3. the **first** title-eligible field by declaration order.
160+
161+
The two runtime seams that consume it compute it identically, and each says so
162+
where it does:
163+
164+
- `packages/objectql/src/engine.ts:5212`*"[ADR-0079] `nameField` is the
165+
canonical primary-title pointer; `displayNameField` is the deprecated alias
166+
(still honored)"*, feeding `expandSearchToFilter`.
167+
- `packages/metadata-protocol/src/protocol.ts:4793`*"[ADR-0079] Same
168+
precedence the engine's search expansion applies"*, feeding the REST
169+
`$searchFields` ingress gate.
170+
171+
That duplication is intentional and is the point: **one precedence, asserted at
172+
both seams, so the request gate and the query engine cannot drift.**
173+
174+
### D5 — Title eligibility is a fail-closed allowlist
175+
176+
Eligible: `text`, `textarea`, `email`, `url`, `markdown`, `html`, `richtext`,
177+
plus `formula` when its result type (`returnType`, or `valueType` for
178+
cross-repo compatibility) is `text`. Everything else is ineligible, and an
179+
**unknown/new field type is ineligible by default** — a positive allowlist, so
180+
a field type added later cannot silently become a title
181+
(`display-name.ts:TITLE_ELIGIBLE_TYPES` / `isTitleEligible`).
182+
183+
Two judgement calls are recorded rather than left implicit:
184+
185+
- **`email` is eligible, `phone` is not.** *"`phone` is deliberately excluded (a
186+
phone number is not a title); `email` IS eligible (commonly the human handle
187+
on identity-ish objects)"* (`display-name.ts:71-72`).
188+
- **`autonumber` is a valid primary but is never *derived*.** *"an autonumber is
189+
a valid primary only when an author points at it explicitly … not something we
190+
silently pick"* (`display-name.ts:66-68`).
191+
192+
### D6 — A record never renders as "Untitled"; the floor is `Record #<id>`
193+
194+
`resolveRecordDisplayName` returns the value at the resolved field, and falls
195+
back to a stable `Record #<id>`*"NEVER a bare 'Untitled'"*
196+
(`display-name.ts:13-14`). A view may override the object's choice for one
197+
render via `viewTitleField` (e.g. a list view labelling rows by another column);
198+
that override is per-render and does not change the object's title.
199+
200+
### D7 — Provisioning is designate-only at the materialization seam
201+
202+
`SchemaRegistry.registerObject` runs `provisionPrimary(schema, { synthesize:
203+
false })` — for **owned** objects only, after `applySystemFields`
204+
(`packages/objectql/src/registry.ts:1079-1090`).
205+
206+
- Where a title-eligible field already exists, `nameField` is **designated**
207+
so it is reliably populated for normal / user-built / AI-built objects.
208+
- Where nothing is eligible, the object is left **exactly as-is**. No `name`
209+
column is synthesized here, because that is a schema migration on dozens of
210+
title-less system tables.
211+
- **Extensions must not redesignate the owner's title** — hence owned-only.
212+
213+
The `synthesize: true` half of `provisionPrimary` exists and guarantees a
214+
primary by adding a `name` text field, but is deliberately **not** wired at this
215+
seam (#2434's "Staged (deliberate)" section, kept as the `TODO(ADR-0079)` that
216+
#2458 then resolved in the designate-only direction).
217+
218+
### D8 — The author-time gate is advisory by design
219+
220+
`@objectstack/lint`'s `validate-record-title` reports two warnings —
221+
`title-format-retired` (D3) and `title-unresolvable` (`objectTitleCompleteness`
222+
returns `status: 'none'`) — and **never errors**:
223+
224+
> "Both are warnings: the auto-provision transform and the id floor mean a green
225+
> build never ships a fully title-less object."
226+
> `packages/lint/src/validate-record-title.ts:26-27`
227+
228+
It runs on `os build` / `os validate` / `os lint`, the MCP authoring surface and
229+
hand authoring — *not* only on the cloud graph-lint path. That is ADR-0078's
230+
"not cloud-only" principle applied
231+
(`validate-record-title.ts:12-14`, `packages/lint/src/authoring-rules.ts:668-678`).
232+
233+
### D9 — There is exactly one title pointer; a second is not a tolerable alias
234+
235+
`primaryField` was read as a title pointer by two lint rules and was **removed**,
236+
not declared, in #6326. The reasoning is recorded at
237+
`packages/lint/src/data-model-rules.ts:404-409`:
238+
239+
> "The maintainer ruled remove, not declare: `nameField` is ADR-0079's one
240+
> canonical title pointer and a second parallel pointer contradicts 'one Zod
241+
> source per metadata type' (Prime Directive #7). Do not reintroduce it as a
242+
> tolerated alias — a consumer-side `??` for a key the producer rejects is
243+
> exactly the second de-facto contract Prime Directive #12 bans."
244+
245+
Note the asymmetry with D2, because it is the whole distinction: a **producer-
246+
side, parse-time, both-keys-preserved** alias with a stated deprecation
247+
(`displayNameField`) is the sanctioned migration mechanic; a **consumer-side
248+
`??`** for a key the schema rejects (`primaryField`) is a banned second
249+
contract. Same-looking code, opposite verdicts.
250+
251+
### D10 — Downstream: the name field leads the search set by ORDERING only
252+
253+
Search-adjacent consumers read the resolved display field, but it does not
254+
buy the field an exemption from their own exclusion rules. `$search` field
255+
resolution leads with the display field **as ordering, never as membership**
256+
(`packages/spec/src/data/search-fields.ts:75-92`, #4483) — the concrete failure
257+
that forced the distinction being D7's designate-only pass setting `nameField:
258+
'id'` on tables whose only textual column is the primary key, which had turned
259+
`$search` into a substring scan over the primary key. The ADR-0098 pinyin
260+
companion column likewise takes *only* the resolved display field as its source
261+
(`packages/objectql/src/search-companion.ts:19`, `:104`).
262+
263+
---
264+
265+
## Status of the surface today (2026-08-08)
266+
267+
| Key | Spec status | Ledger (`packages/spec/liveness/object.json`) |
268+
|:---|:---|:---|
269+
| `nameField` | canonical, `.optional()` | `live` — "ADR-0079 canonical record-title pointer" |
270+
| `displayNameField` | deprecated alias, still parsed and preserved | `live` — "still read by objectui RecordDetailView + `resolveDisplayField` back-compat" |
271+
| `titleFormat` | deprecated, still parsed, lint-warned | `live` — "objectui (`{{record.field}}` interpolation)" |
272+
273+
41 first-party objects across `platform-objects`, the plugins and the services
274+
carry an explicit `nameField:` with an `[ADR-0079]` comment.
275+
276+
---
277+
278+
## What this reconstruction does NOT settle
279+
280+
These are places where the 77 citing sites are **silent, or use one word for two
281+
states**. They are recorded as open, not resolved by this file. Deciding any of
282+
them needs a maintainer, and — for the first three — probably the cloud original.
283+
284+
1. **When, if ever, does `nameField` become required?** #2434 recorded "No hard
285+
`.refine()` requiring a title (would reject existing metadata)" and
286+
`object.zod.ts:1684-1685` still says *"Optional at the schema level for now
287+
(a hard required-refine is staged)"*. Two months on, "staged" names no
288+
trigger and no criterion. Nothing in the tree says what would make it fire.
289+
290+
2. **When is the `displayNameField` alias retired?** Its own precedent argues
291+
for a deadline and does not have one: ADR-0085 copied this alias mechanic for
292+
`compactLayout`, ran it for *"one deprecation window"*, and retired it in
293+
framework#2536. `displayNameField` has run since 2026-06-28 with no window
294+
declared, and the ledger records a live cross-repo reader (objectui
295+
`RecordDetailView`), so retiring it is a cross-repo change, not a local one.
296+
297+
3. **"Retired" is used for a key that is still live.** D3's citations call
298+
`titleFormat` *retired*; the liveness ledger records it `"status": "live"`,
299+
the schema parses it, and objectui still interpolates it. Under ADR-0049's
300+
enforce-or-remove vocabulary "retired" normally means *gone*. Here it means
301+
*deprecated, lint-warned, still parsed, still read cross-repo*. The citations
302+
are consistent with each other about the behaviour and inconsistent about the
303+
word.
304+
305+
4. **`code` is name-like to lint and not to spec.** `packages/lint/src/data-
306+
model-rules.ts:36`'s `NAME_LIKE_FIELDS` includes `code`; spec's derivation
307+
set (`display-name.ts:NAME_ISH_EXACT`) does not. So an object whose only
308+
name-ish field is `code` passes lint's R9 "has a title face" check while
309+
`resolveDisplayField` will not derive `code` as its title (it may still be
310+
picked by tier 3, as the first title-eligible field — but by a different rule
311+
and a different priority). Whether the two sets are meant to be the same set
312+
is not stated anywhere; this reconstruction does not assume they are.
313+
314+
---
315+
316+
## Alternatives considered
317+
318+
Reconstructed from what the implementation rejected; not an exhaustive record of
319+
the 2026-06-28 discussion.
320+
321+
- **Keep `titleFormat` as the composite-title mechanism.** Rejected: a template
322+
the server can neither return nor query cannot be sorted, searched, or put in
323+
a notification. Composite titles are expressible without it, as a text formula
324+
field designated `nameField` (D3), which the server *can* return and query.
325+
- **Require `nameField` immediately (hard `.refine()`).** Rejected as staging,
326+
not on the merits — it would reject metadata that already exists (#2434). See
327+
open question 1.
328+
- **Synthesize a `name` column wherever no title is derivable.** Rejected at the
329+
registry seam: it is a DB migration on every title-less system table. The
330+
capability is kept behind `provisionPrimary`'s `synthesize` option for
331+
authoring-time use (D7).
332+
- **Let each consumer derive a title for itself.** Rejected implicitly by
333+
building one shared pure module and having both search seams assert they use
334+
the same precedence (D4). The alternative is drift that shows up as a request
335+
gate and a query engine disagreeing about what is searchable.
336+
- **Accept `primaryField` as an additional tolerated alias.** Rejected
337+
explicitly by the maintainer in #6326 (D9).

0 commit comments

Comments
 (0)