Skip to content

Commit 68a090f

Browse files
docs(adr-0079): addendum — a formula title reads one hop through a reference, and is not sortable or searchable (#24323)
Fixes #24197 Clause-②: no (an ADR note records what shipped; it moves no accept set and no public surface. The governed path owes one `CONTRACT_REVIEW_TIER` review, and the PR lands only on a governed approver's approval: Tier H.) ## Summary The third and last of card #24197's three parts: the ruling's documentation leg, "written as an ADR-0079 note". One appended section in `docs/adr/0079-record-display-name.md`, `## Addendum (2026-10-18, #24197) — a formula title reads one hop through a reference, and is not sortable or searchable`, plus one `- **Addenda**:` pointer line in the header block (the spelling ADR-0048 uses). Docs-only: `skip-changeset`. The addendum names ruling 6245544505 (letter A on objectstack-ai/cloud#2925) and the two PRs that shipped it, quotes the ruling verbatim, then states nine facts about `main` in three groups (read path, hook bodies, sort and search), and ends with what it qualifies above. It qualifies the `## Alternatives considered` first bullet ("the server *can* return and query") and D1's "real STORED" in its own text; neither is edited. No edit to Provenance, Context, D1–D10, the status table, the open questions or the alternatives. Sources combined, as the dispatch named them: part 1's `adr_note_text` (report 6247217617), amended with record 6247399985's ③.4 (`user` and `tree` are references) and ③.8b (FLS on the carrying reference column); part 2's `adr_note_addendum` (report 6248949940), amended with record 6249342777's ③.7 (the channel's identity, the channel that cannot read, FLS on the title field). "Shown on every surface" is not carried. The addendum names the two surfaces verified (the engine read path and the hook-body title accessor) and says it makes no statement about any other consumer of a record's title. ## Verification record Every statement checked in code on `main` at the merge `da22e1db` (merge base `2b7fc52c3`). The rework head `73fd87521c` (report 6250259693) changes two addendum lines only, the hook-bodies heading and the provenance sentence, and none of the files below. *(Note added by the seat.)* The merge brought `#24305`, `#24308`, `#24312`; `git diff --stat 2950277 da22e1d` over every file below is empty. `d537a318b` and `1bf5f6e17c` are ancestors of the head (`git merge-base --is-ancestor`: exit 0 for both). | # | Statement | File · symbol | | --- | --- | --- | | 1 | One hop through `lookup` / `master_detail` / `user` / `tree`; one batched read per reference field per page; the reader's own permissions; on `find`, `findOne` and the insert and update responses | `packages/objectql/src/engine.ts` · `resolveFormulaRelated` → `resolveOneHopRelated(…, 'caller')` (hop query carries the caller's `context`, no `isSystem` spread; `where id $in`), call sites in `find`, `findOne` and the two `hydrateWriteFormulas` callers; `packages/spec/src/data/field-value.zod.ts` · `REFERENCE_VALUE_TYPES` = lookup, master_detail, user, tree, read through `referenceTargetOf` | | 2 | Missing or unreadable related record → `null` with a reason, never a fallback, the reason not saying which; an empty reference is evaluated | `engine.ts` · `formulaHopScope` (`ok: false` before evaluation; `no-reference` → evaluated), `formulaHopRefusal` (docblock: never says which of not-found / not-readable held), `applyFormulaPlan` | | 3 | Lint refuses a second hop and a bare-and-traversed reference; run time reads `null` with the same reason; a related object's own hop is not served when read as a hop | `packages/lint/src/validate-expressions.ts` · `judgeFieldFormula` passes `traversalHydration: true`; `packages/formula/src/validate.ts` · `TRAVERSAL_CONFLICT_CODE` (`traversal-multi-hop`, `traversal-bare-and-traversed`); `engine.ts` · `hopConflict` in `applyFormulaPlan`, `formulaHopReads` in `resolveFormulaRelated` | | 4 | FLS on the carrying reference column does not stop the hop; formulas computed before the mask; control is FLS on the formula field | `engine.ts` · `find` evaluates `applyFormulaPlan` on the driver rows inside the operation; `packages/plugins/plugin-security/src/security-plugin.ts` · `maskOperationResult` masks `opCtx.result` afterwards | | 5 | `ctx.title()` over the in-flight state, one batched read per reference field on the body's `ctx.api` channel, its identity (SYSTEM under `runAs: 'system'`) and transaction; null cases | `packages/runtime/src/sandbox/body-runner.ts` · `buildTitleSurface` (`hookRecordState`, `resolveServedRecordTitle`); `packages/runtime/src/sandbox/quickjs-runner.ts` · title wire hands `txState.api ?? ctx.api`, `value ?? null`; `engine.ts` · `ScopedContext.serveFormulaField` (its `executionContext`), `ScopedContext.withRunAs` (`'system'` → `sudo()`); `packages/objectql/src/record-title.ts` · `resolveServedRecordTitle` | | 6 | A channel that cannot read (`runAs: 'user'`, no user) serves no hop: own-column title resolves, title over a reference `null` | `engine.ts` · `withRunAs` (no `userId` → `UnscopedHookApi`); `packages/objectql/src/hook-run-as.ts` · `UnscopedHookApi` (no `serveFormulaField`); `body-runner.ts` · `buildTitleSurface` falls to `resolveRecordTitle` | | 7 | FLS on the title field is not applied to `ctx.title()` | `engine.ts` · `ObjectQL.serveFormulaField` returns `scratch[field]` with no mask; `record-title.ts` · `resolveRecordTitle` / `resolveServedRecordTitle` apply none | | 8 | `ctx.title('FIELD')` answers the title as `findOne` served it, the related record's own one-hop title included | `body-runner.ts` · `buildTitleSurface` → `source.object(…).findOne` → `servedRecordTitle` | | 9 | Stored nowhere; sort `400 INVALID_SORT`; request `searchFields` `400 INVALID_FIELD`; a `searchableFields` entry dropped; never in the auto-default set | `engine.ts` · `assertOrderByIsMaterializable` (formula in `orderBy`, `find` and `findOne`); `packages/metadata-protocol/src/protocol.ts` · `assertSearchFieldsAreSearchable` (`virtual` branch, `INVALID_FIELD`, 400); `packages/spec/src/data/search-fields.ts` · `resolveSearchFieldResolution` (declared entries filtered by `isVirtualSearchField`), `SEARCH_VIRTUAL_TYPES` = formula, `autoDefaultFields` (formula in neither positive list) | The header pointer's fragment equals `github-slugger`'s slug of the new heading (computed, identical). ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) on `da22e1db`: 19 commands, the same 19 as on the pre-merge head `2950277`, and the dispatch's count of 19. All 19 ran on `da22e1db`, each exit captured before any pipe: 19 × exit 0, among them `pnpm check:adr-anchors`, `node scripts/check-adr-links.mjs`, `node scripts/check-adr-symbol-anchors.mjs`, `pnpm check:doc-authoring` and `pnpm check:nul-bytes`. `--ran`: "19 derived famil(ies) accounted for — 19 run, 0 NOT-MEASURED (a DERIVED zero — all 19 recorded an exit code and none of them is 3)". On the first sweep (`2950277`), `pnpm --filter @objectstack/lint run check:doc-formula-expressions` exited 3 (PREREQUISITE NOT MET: `@objectstack/formula` and `@objectstack/lint` not built in the new worktree). Not counted as a measurement; after `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` (under the verify lock, exit 0) it exited 0, and 0 again on `da22e1db`. No package is touched, so no build closure, test suite or typecheck is owed. CI check-runs: not read at this writing; CI's verdict is the record. ## Acceptance notes - The addendum does not describe any in-flight change: it names no open card and describes no unlanded path. - Out of scope, noted only: other consumers of a record's title (global search hits, approvals messages) were not verified for a title over lookups, and the addendum makes no statement about them. ## 维护者速读(草稿) **改了什么** 在 ADR-0079(记录标题契约)末尾追加一节附录,并在文件头加一行指向它。附录只陈述已经上线的事实:公式字段可以穿过一个引用字段读取关联记录(一跳),读取时用读者自己的权限;看不到或不存在的关联记录让公式为空,不给回退值;两跳被 lint 拒绝;钩子里的 `ctx.title()` 也按同样方式取标题;由多个查找字段拼成的标题不落库,所以不能按它排序、不能按它搜索。 **为什么改** 裁决 6245544505(cloud#2925 字母 A)要求这条决定「写成 ADR-0079 的一则说明」。代码部分已由 #24274 和 #24307 落地,本 PR 是这张卡的最后一块:让 AI 和开发者读 ADR 时就知道「拼接标题」能显示、不能排序搜索,需要排序搜索就另存一个字段。 **风险与代价(含回滚)** 只改一份文档,不动代码、不动契约、不发布任何包。风险是文字与实现不符;每句话都在 PR 正文里对应到了具体文件和符号。回滚就是 revert 这一个提交。 **席位意见** **你要做的(一个动作)** 读完附录,无异议就批准本 PR(Tier H,需维护者批准后由席位落地)。 --- _Generated by [Claude Code](https://claude.ai/code/session_01RVjnmZ6Jv6NcLDwow6RpNi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 370833a commit 68a090f

1 file changed

Lines changed: 77 additions & 0 deletions

File tree

‎docs/adr/0079-record-display-name.md‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- **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)
88
- **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)
99
- **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)
1011

1112
---
1213

@@ -335,3 +336,79 @@ the 2026-06-28 discussion.
335336
gate and a query engine disagreeing about what is searchable.
336337
- **Accept `primaryField` as an additional tolerated alias.** Rejected
337338
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

Comments
 (0)