|
7 | 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 | 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 | 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 | +- **Addenda**: [2026-10-18 (#24197) — a formula title reads one hop through a reference, and is not sortable or searchable](#addendum-2026-10-18-24197--a-formula-title-reads-one-hop-through-a-reference-and-is-not-sortable-or-searchable) |
10 | 11 |
|
11 | 12 | --- |
12 | 13 |
|
@@ -335,3 +336,79 @@ the 2026-06-28 discussion. |
335 | 336 | gate and a query engine disagreeing about what is searchable. |
336 | 337 | - **Accept `primaryField` as an additional tolerated alias.** Rejected |
337 | 338 | explicitly by the maintainer in #6326 (D9). |
| 339 | + |
| 340 | +--- |
| 341 | + |
| 342 | +## Addendum (2026-10-18, #24197) — a formula title reads one hop through a reference, and is not sortable or searchable |
| 343 | + |
| 344 | +Records ruling 6245544505 (letter A, direction (a), on `objectstack-ai/cloud#2925`), |
| 345 | +shipped by #24274 (`d537a318b`) and #24307 (`1bf5f6e17c`). It states what is on |
| 346 | +`main` after those two changes and decides nothing more; no section above is edited. |
| 347 | +The ruling, verbatim: |
| 348 | + |
| 349 | +> The formula read path preloads one hop of the referenced fields a formula |
| 350 | +> names (page-batched, as validation rules do); lint admits one hop and refuses |
| 351 | +> two; a title formula over lookups sorts and searches by its stored value or is |
| 352 | +> declared unsortable — written as an ADR-0079 note. |
| 353 | +
|
| 354 | +**The read path** (`find`, `findOne`, and the insert and update responses; |
| 355 | +`packages/objectql/src/engine.ts`): |
| 356 | + |
| 357 | +1. A formula field reads ONE hop through a reference field (`lookup`, |
| 358 | + `master_detail`, `user`, `tree`), as `record.FIELD.RELATED`. The read path |
| 359 | + preloads the related fields the formula names once per page, with one batched |
| 360 | + read per reference field and never one per row, under the reader's own |
| 361 | + permissions. |
| 362 | +2. A related record that is missing, or that the reader may not read, leaves the |
| 363 | + formula `null` with a reported reason, never a computed fallback; the reason |
| 364 | + does not say which of the two held. An empty reference is not a missing |
| 365 | + record: the formula is evaluated over the empty value. |
| 366 | +3. A second hop, and a reference read both through and as a plain value, are |
| 367 | + refused by the lint (`traversal-multi-hop`, `traversal-bare-and-traversed`) |
| 368 | + and read `null` with the same reason at run time. A related object's own |
| 369 | + formula that reads a hop of its own reads `null` when it is itself read as a |
| 370 | + hop. |
| 371 | +4. Field-level security on the carrying object's own reference column does not |
| 372 | + stop the hop: formulas are computed before the field mask is applied to the |
| 373 | + result, as for any own column a formula reads. The control for a related name |
| 374 | + a reader may not see is field-level security on the formula field itself. |
| 375 | + |
| 376 | +**Hook bodies** (`packages/runtime/src/sandbox/body-runner.ts`, the hook face): |
| 377 | + |
| 378 | +5. `ctx.title()` evaluates the object's title formula over the in-flight record |
| 379 | + state the hook fires on, and serves its one hop the way a read does: one |
| 380 | + batched read per reference field, on the body's own `ctx.api` channel. That |
| 381 | + is the channel's identity (SYSTEM under `runAs: 'system'`) and its open |
| 382 | + transaction's connection. So a title over lookups names the related records |
| 383 | + the in-flight foreign keys point at, and a related record that channel may |
| 384 | + not read, a missing one, or a second hop leaves the title `null`, as the REST |
| 385 | + read of the same record shows no title. |
| 386 | +6. A channel that cannot read (a `runAs: 'user'` hook whose trigger resolved no |
| 387 | + user) serves no hop: a title over the record's own columns resolves, and a |
| 388 | + title over a reference answers `null`. |
| 389 | +7. Field-level security on the title field itself is not applied to |
| 390 | + `ctx.title()`. |
| 391 | +8. `ctx.title('FIELD')` answers the related record's title exactly as its |
| 392 | + `findOne` served it, that record's own one-hop title included. |
| 393 | + |
| 394 | +**Sort and search — the ruling's "declared unsortable" arm:** |
| 395 | + |
| 396 | +9. A formula value, a composite title over lookups included, is stored nowhere; |
| 397 | + it is computed on each read. A sort by it is refused (`400 INVALID_SORT`). A |
| 398 | + search cannot reach it: a request's `searchFields` naming it is refused |
| 399 | + (`400 INVALID_FIELD`), a `searchableFields` entry naming it is dropped, and |
| 400 | + the auto-default search set never includes it. An object that needs to sort |
| 401 | + or search by its composite title must store the value in a field of its own, |
| 402 | + written when its sources change. |
| 403 | + |
| 404 | +**What this qualifies above, without editing it:** |
| 405 | + |
| 406 | +- The first bullet of [Alternatives considered](#alternatives-considered) says a |
| 407 | + text formula `nameField` is something the server *can* return and query. For |
| 408 | + a title formula over lookups, the server returns it (items 1 and 5) but cannot |
| 409 | + sort or search by it (item 9). |
| 410 | +- D1 lists a text `formula` among the "real STORED" title fields. It is a |
| 411 | + declared field of the object, but no column holds its value (item 9). |
| 412 | +- The surfaces this addendum describes are the engine read path and the |
| 413 | + hook-body title accessor. It makes no statement about any other consumer of |
| 414 | + a record's title. |
0 commit comments