Repository navigation
Commit 68a090f
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
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| 10 | + | |
10 | 11 | | |
11 | 12 | | |
12 | 13 | | |
| |||
335 | 336 | | |
336 | 337 | | |
337 | 338 | | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
0 commit comments