|
| 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