Skip to content

docs(adr-0079): addendum — a formula title reads one hop through a reference, and is not sortable or searchable - #24323

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-24197-adr-0079-note
Oct 18, 2026
Merged

os-zhuang merged 4 commits into
mainfrom
claude/issue-24197-adr-0079-note

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

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

…ference, and is not sortable or searchable

Records ruling 6245544505 (letter A on objectstack-ai/cloud#2925) as it
shipped in #24274 and #24307: the read path's one-hop preload under the
reader's permissions, the lint's two refusals, the hook-body title
accessor's channel, and the declared-unsortable answer with its remedy.
One appended section and one header pointer line; nothing above edited.

Claude-Session: https://claude.ai/code/session_01RVjnmZ6Jv6NcLDwow6RpNi
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: da22e1db2a63451221fbab6ad0635ee859ea2396
Local-runs: none
Scope: docs/adr/0079-record-display-name.md + packages/objectql/src/engine.ts, packages/objectql/src/validation/rule-validator.ts, packages/objectql/src/record-title.ts, packages/objectql/src/hook-run-as.ts, packages/objectql/src/hook-wrappers.ts, packages/spec/src/data/field-value.zod.ts, packages/spec/src/data/search-fields.ts, packages/lint/src/validate-expressions.ts, packages/formula/src/validate.ts, packages/formula/src/relationship-traversal.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/field-masker.ts, packages/runtime/src/sandbox/body-runner.ts, packages/runtime/src/sandbox/quickjs-runner.ts, packages/runtime/src/sandbox/script-runner.ts, packages/runtime/src/stored-metadata-reader-seam.ts, packages/metadata-protocol/src/protocol.ts, docs/adr/0048-cross-package-metadata-collision.md, scripts/check-partof-closing-keyword.mjs, scripts/pm/check-half-states.mjs, scripts/pm/clause2-line.mjs

Inputs: card #24197 (the body with its Ruled: line, and all 19 comments: the two seat notes, the three claims per part, the three dispatches, the four os-dev-report comments read as claims, the ACCEPTs and the two landing comments), the earlier records 6247399985 (PR #24274) and 6249342777 (PR #24307), PR #24323 (body, the one-file list, zero review comments), the net diff 2b7fc52c36..da22e1db2a (merge base 2b7fc52c36, +77/−0, read with git diff and git show pr/24323:PATH), and the head's check-runs. Every cited file is byte-identical between pr/24323 and origin/main 1ef0ee89d4 (blob ids compared), so what is read below is main. d537a318b (#24274) and 1bf5f6e17c (#24307) are ancestors of the head. Nothing was built, run or re-run; the dev's gate claims were not reproduced.

Check-runs on da22e1db2a, read at 2026-10-18T12:44Z: 43, all completed, none in progress. 31 success — among them Lint & Repo Gates, TypeScript Type Check, Test Core (and its six shards), Governed Surface Queue Guard, every Lint and Type Check lane, Check Changeset, Check Documentation Links, Check PR Size, the three claim and card guards and Dogfood Regression Gate. 11 skipped by path or opt-in (Build Core, Build Docs, Console Pin Gate, Dogfood Verify CLI, the Dogfood matrix placeholder, Temporal Conformance, both Packed-tarball smoke, and the first runs of Auto Label, Check PR Size and Check Changeset, each re-run green). 1 failure: Part-of PR must not also close its card — see ③.6. That red is a PR-body verdict, not a diff verdict, and it is not the ground of the FAIL below.

① Derived judgments

The diff is two hunks in one governed file: one - **Addenda**: pointer line in the header block (the spelling ADR-0048 line 11 uses) and one appended section after the last bullet of ## Alternatives considered. Provenance, Context, D1–D10, the status table, the open questions and the alternatives are untouched (the diff adds 77 lines and deletes none, and every added line is in those two hunks). The pointer's fragment is github-slugger's slug of the new heading (derived by hand, and Check Documentation Links is green). The ruling is quoted verbatim: the blockquote, unwrapped, equals the Ruled: line's sentence byte for byte (the line's trailing board-state words, "p1 target:v18 stands …", are not part of the ruling and are rightly left out). Neither 24284 nor 24316 appears in the addendum, and neither card's path is described: item 9 states today's refusals and the auto-default set, not the silent-widening defect of #24284; item 4 says nothing about stored-metadata family tables. It changes no recorded decision: the two qualifications (the Alternatives bullet's "can return and query"; D1's "real STORED") are made in the addendum's own text, as the claim fence allows, and neither reverses D1's structural invariant — a formula title is still the one declared title field; what is corrected is the word "stored".

Each behavioural statement, judged against the code at this head:

  1. One hop through lookup / master_detail / user / tree; preloaded once per page, one batched read per reference field, never per row; the reader's own permissions; on find, findOne and the insert and update responses — RIGHT. planFormulaProjection (engine.ts 1674) plans hops off collectReferenceTraversals, whose arbiter is referenceTargetOf (field-value.zod.ts 207) over REFERENCE_VALUE_TYPES = lookup, master_detail, user, tree (154). resolveFormulaRelated (6194) folds a plan's hops into one wanted map and calls resolveOneHopRelated(…, 'caller') (10054) once over the rows it is handed; that loader collects the page's distinct FK ids and issues one find per reference field with where id $in and fields [id, ...named]; for authority === 'caller' bound is false, so no second own-read and no SYSTEM spread — the hop query carries context unchanged (10180–10185). Call sites: find passes the driver page result after limit/offset (14017–14022), findOne (14306–14310), and hydrateWriteFormulas (2794) with a relatedFor from both of its callers, inside insert (16047, method at 15059) and update (17845, method at 16239).

  2. Missing or unreadable related record leaves the formula null with a reported reason, never a computed fallback; the reason does not say which of the two held; an empty reference is evaluated — RIGHT. formulaHopScope (2646) answers ok: false before any evaluation for a binding with no row, and applyFormulaPlan (2695) then writes null and hands the reason to the fault sink (formulaFaultSink 6281 → reportFormulaFault, warn, once per object-field pair). formulaHopRefusal (2676) words unresolved as "it does not exist, or this reader may not read it". A no-reference binding (the per-row closure at 10210–10233 answers it for a null, array or object FK value) is pushed to empty and the formula IS evaluated over the stored empty value, the fault on a plain read naming the optional-read repair (2744–2750). One precision note in ③.8.

  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 it is itself read as a hop — RIGHT. judgeFieldFormula (validate-expressions.ts 2707) passes traversalHydration: true (2750); validate.ts 1131 judges the value role with subject: 'formula' and refuses with TRAVERSAL_CONFLICT_CODE (235: traversal-bare-and-traversed, traversal-multi-hop). The engine judges the same expression with the same function — referenceTraversalConflicts(expr, schema.fields, 'formula') (rule-validator.ts 645 → findTraversalConflicts → findFormulaTraversalConflicts, relationship-traversal.ts 334/402) — and stores its sentences as hopConflict (1701), which applyFormulaPlan reports with null (2713–2716). formulaHopReads (6169) marks every caller-authority hop query (10187); a find handed one gets the second-hop sentence from resolveFormulaRelated (6211–6215), so the related object's own traversing formula reads null.

  4. FLS on the carrying object's own reference column does not stop the hop; formulas compute before the field mask; the control is FLS on the formula field — RIGHT. applyFormulaPlan runs inside the operation on the driver rows (14017), while plugin-security's middleware masks opCtx.result in step 4 after await next() (security-plugin.ts 4267 → 4313 → maskOperationResult 9467), whose fieldMasker.maskResults deletes a non-readable key by name (field-masker.ts 108/184), a formula key included. The related object's own FLS does apply to the hop, because the hop is the caller's find.

  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 its open transaction's connection; null for a hidden or missing related record or a second hop — RIGHT, for hook bodies. buildTitleSurface (body-runner.ts 243) reads hookRecordState(engineCtx) (hook-wrappers.ts 1134: stored ⊕ payload, materialised) and, when the handed-in channel has serveFormulaField, calls resolveServedRecordTitle (record-title.ts 173) → ScopedContext.serveFormulaField (engine.ts 21780, passing this.executionContext) → ObjectQL.serveFormulaField (6255), the read path's own four calls. The channel is txState.api ?? ctx.api, resolved at call time (quickjs-runner.ts 900); the seam Proxy's default branch forwards the method bound to the ScopedContext (stored-metadata-reader-seam.ts 439–440). withRunAs('system') is sudo() (21439). The host answers undefined and the VM wire hands the body value ?? null (903), so a body sees null.

  6. A channel that cannot read serves no hop: own-column title resolves, title over a reference is null — RIGHT. withRunAs('user') with no userId returns UnscopedHookApi (21440; hook-run-as.ts 141), which has no serveFormulaField; buildTitleSurface falls to the synchronous resolveRecordTitle (273) → evaluateFormulaField (2863), which passes no related, so formulaHopScope answers FORMULA_HOP_NOT_SERVED and the formula is null; an own-column formula evaluates.

  7. FLS on the title field itself is not applied to ctx.title() — RIGHT. ObjectQL.serveFormulaField returns scratch[field] with no middleware pass over the own record, and resolveServedRecordTitle / resolveRecordTitle fall back to record[field] off the hook state unmasked.

  8. ctx.title('FIELD') answers the related title as its findOne served it, one-hop title included — RIGHT. buildTitleSurface 283–288: source.object(target.object).findOne({ where: { id } }) on the same channel, then servedRecordTitle (record-title.ts 200) reads the row's title key, never re-derives. The related findOne is not a marked hop query, so its own one hop is served.

  9. Stored nowhere; sort refused 400 INVALID_SORT; request searchFields refused 400 INVALID_FIELD; a searchableFields entry dropped; never in the auto-default set — RIGHT, and pre-existing. planFormulaProjection keeps formula names out of the driver projection (1716–1724); assertOrderByIsMaterializable (1848; code INVALID_SORT, status 400 at 1901–1909) runs on find (13924) and findOne (14242); assertSearchFieldsAreSearchable (protocol.ts 13539) refuses a virtual entry at 13642–13658 with INVALID_FIELD 400 (13702–13703); resolveSearchFieldResolution (search-fields.ts 242) filters declared entries by isVirtualSearchField (110, over SEARCH_VIRTUAL_TYPES = formula, 96); autoDefaultFields (127) admits SEARCHABLE_TEXTUAL_TYPES and SEARCHABLE_ENUM_TYPES only (167), neither naming formula.

  10. The group heading "Hook and action bodies" over items 5–8 — WRONG. ctx.title() is a hook-face accessor only. buildTitleSurface is called once, on the hook face (body-runner.ts 371); the hook context object carries title with the comment "Hook face only. The action face has its own record shape … deliberately not taken here — the ruling names hook bodies" (1168); the action context object (about 1230–1247) carries no title key; quickjs-runner.ts 845–848 installs ctx.title only when the host built the seam ("hook bodies; the action face does not"). The heading tells a reader that ctx.title() exists in action bodies, where it is undefined. It descends from part 2's carried adr_note_addendum ("In a hook or action body, ctx.title() …"), which record 6249342777 ③.7 passed unremarked; items 5–8 themselves say "the hook fires on" and "a runAs: 'user' hook". This is the one statement the addendum makes that is not true of main, and it is a declared-not-delivered statement of exactly the kind this card closed. Fix: the heading reads "Hook bodies (packages/runtime/src/sandbox/body-runner.ts, the hook face)". Two words; it needs a new head.

  11. Provenance sentence "It states what those two changes put on main" — a nit, not a behavioural claim: item 9's three refusals predate both PRs (INVALID_SORT from engine.find() still drops a formula ORDER BY silently — decide whether the engine refuses or keeps its internal-caller tolerance #7095, the virtual-search refusal from A virtual formula field declared in searchableFields clears lint and the #4254 gate, then silently matches nothing — the same fail-open shape #4254 closed one axis over #6674); feat(objectql,lint): a formula field reads one hop through a reference — record.book.title computes, a second hop is refused #24274 pinned the engine sort refusal and wrote the describe strings. "states what is on main after them" would be exact. Not blocking; take it in the same re-push if one happens.

② Semver level

Docs-only: one file under docs/adr/, no package, no .changeset. skip-changeset is the right label (AGENTS.md: the label is for a diff that publishes nothing from any released package), and Check Changeset concluded success. The PR body's Clause-②: no (an ADR note records what shipped; …) reads as declared, value no, no arm, through readClause2Line (clause2-line.mjs 418: matchValueToken takes no; readArmToken 280 sees a parenthetical whose first word an is neither an arm nor an arm-family word, so arm: null and not malformed). RIGHT: nothing widens and nothing narrows. The tier is CONTRACT_REVIEW_TIER by the rules face — docs/adr/** is a Tier H register row (check-governed-merges.mjs 1177) — so this record is owed, and the landing is a governed approver's word plus the owning seat.

③ Boundary flags

The dev's report is 6249874213; the dispatch is 6249693922; the claim is 6249662765.

  1. Open question 1 ("shown on every surface": name the two verified surfaces; A shipped). ANSWERED: A is right, and the closing bullet does it — but the heading in ①.10 undoes the restraint on the other side by naming a surface (action bodies) that has no accessor. The fix in ①.10 restores it.

  2. Open question 2 (also qualify D1's "real STORED"; A shipped). ANSWERED: A is right. D1's quoted sentence says a formula title is a "real STORED field" and item 9 says a formula is stored nowhere; both are now on the page, and the addendum says which is true while editing neither. It is a qualification of a descriptive word, not a reversal of D1's decision, so PD [WIP] Add Chinese version of the documentation #13 asks for no superseding ADR and no status-line edit.

  3. Open question 3 (leave out the save door's 422 and the optional-read split; A shipped). ANSWERED: A is acceptable. Both omitted facts are true at this head (the lint runs in the metadata save door, measured 422 INVALID_METADATA in report 6247217617; formulaHopScope's empty branch and the repair-first fault at engine.ts 2744–2750), and leaving them out makes nothing false.

  4. Deviations (five). All process, accepted: the recreated worktree and its removal; the PR bodies of feat(objectql,lint): a formula field reads one hop through a reference — record.book.title computes, a second hop is refused #24274 / fix(runtime,objectql): ctx.title() answers a title formula over lookups — the one hop is served on the body's own channel #24307 not read in full (this record likewise verified in code, not in those bodies); the model-free attribution trailer; no PR-activity subscription.

  5. out_of_scope_findings (other title consumers — searchAll hit titles in metadata-protocol, plugin-approvals recordTitleOf over ctx.previous — not verified for a title over lookups). ACCEPTED as noted, not escalated: no defect is asserted, and the addendum's last bullet disclaims those consumers. It stays on the seat's list for the next engine or approvals touch; nothing is owed on this PR.

  6. The red check-run Part-of PR must not also close its card (partof-closing-keyword-guard.yml → scripts/check-partof-closing-keyword.mjs → H7). Cause, read from the body and the predicate: partOfRe() (check-half-states.mjs 2426) is case-insensitive, so the Summary's "Part 3, the last part of A formula field that reads through a reference (record.book.title) passes validateExpression, then reads null on every row: the read path projects only the row's own columns #24197" is read as a Part of #24197 declaration, while the body's first line Fixes #24197 — which dispatch 6249693922 asked for, this being the last part — is a closing keyword on the same number. Remedy: a body edit by the seat (the dev's rule 3 hands body edits to the seat), rewording the Summary so the two words "part of" do not precede #24197 — for example "Part 3, the last of the three parts of card A formula field that reads through a reference (record.book.title) passes validateExpression, then reads null on every row: the read path projects only the row's own columns #24197" — keeping Fixes #24197; the workflow subscribes edited, so the check re-fires on the edit with no push. The verdict below does not rest on this red; it must be green before the approval request either way.

  7. Carried from the earlier records, checked here: 6247399985 ③.4 (user and tree are references) is in item 1; ③.8b (FLS on the carrying FK column) is item 4; 6249342777 ③.7's three additions (the channel's identity, the channel that cannot read, FLS on the title field) are items 5, 6 and 7. All carried, all true.

  8. Reviewer's own notes, none blocking: (a) item 2's "the reason does not say which of the two held" holds for the response (null in every case) and for the not-found / RLS-hidden pair (unresolved); a hop read that throws — an object-level PERMISSION_DENIED from the related object's middleware included — reports a third sentence, "that read failed", to the log (formulaHopRefusal unreadable), so an operator reading logs can tell a thrown read from a not-found; the reader cannot. (b) Items 5 and 6 say null; the host seam answers undefined and the VM wire converts (quickjs-runner.ts 903) — right for bodies, which is what the items address. (c) The record's Scope includes script-runner.ts, stored-metadata-reader-seam.ts, hook-wrappers.ts and field-masker.ts beyond the PR body's table, because items 4–6 lean on them.

Implemented-by: claude/issue-24197-adr-0079-note
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: FAIL

One WRONG statement (①.10): the heading "Hook and action bodies" places ctx.title() in action bodies, which have no such accessor at this head. Items 1–9 are each RIGHT, the fence holds, the ruling is quoted verbatim, no unlanded behaviour is described, and #24284 / #24316 are neither named nor described — so the fix is the two-word heading change (and, optionally, the ①.11 provenance word), then a fresh record on the new head. Separately, the PR body needs the ③.6 rewording before the approval request.

ctx.title() is installed on the hook face alone (body-runner.ts
buildTitleSurface; the action-face context carries no title), so the
group heading no longer says "and action bodies". The provenance
sentence now says what is on main after the two changes, since the
sort and search refusals predate both.

Claude-Session: https://claude.ai/code/session_01RVjnmZ6Jv6NcLDwow6RpNi
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 73fd87521c8e5898b93c08aab6ab71deb28fc3d0
Local-runs: none
Scope: docs/adr/0079-record-display-name.md + packages/runtime/src/sandbox/body-runner.ts, packages/runtime/src/sandbox/quickjs-runner.ts, packages/objectql/src/engine.ts, packages/objectql/src/validation/rule-validator.ts, packages/objectql/src/record-title.ts, packages/objectql/src/hook-run-as.ts, packages/objectql/src/hook-wrappers.ts, packages/spec/src/data/field-value.zod.ts, packages/spec/src/data/search-fields.ts, packages/lint/src/validate-expressions.ts, packages/formula/src/validate.ts, packages/formula/src/relationship-traversal.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/field-masker.ts, packages/runtime/src/sandbox/script-runner.ts, packages/runtime/src/stored-metadata-reader-seam.ts, packages/metadata-protocol/src/protocol.ts, docs/adr/0048-cross-package-metadata-collision.md, scripts/check-partof-closing-keyword.mjs, scripts/pm/check-half-states.mjs, scripts/pm/clause2-line.mjs, scripts/pm/check-governed-merges.mjs, .github/workflows/pr-automation.yml

Delta review on top of record 6250184223 (head da22e1db2a, FAIL on ①.10 alone, ①.11 a non-blocking nit). Inputs: that record in full; card #24197 (the body with its Ruled: line and all 21 comments — the claims, dispatches, the four os-dev-report comments and the rework report 6250259693 read as claims, the ACCEPTs, the two landing comments, the seat's rework comment 6250198140); PR #24323 (body, the one-file list, zero review comments, one issue comment — the prior record); the interdiff git diff da22e1db2a 6d6ecfe5a5 -- docs/adr/0079-record-display-name.md; the merge commit 73fd87521c (parents 6d6ecfe5a5 and 1ef0ee89d4, the merge base); the net diff 1ef0ee89d4..73fd87521c; the head's check-runs. Blob ids of every Scope file were compared across da22e1db2a, 73fd87521c and origin/main ec749fbb44. Nothing was built, run or re-run; the dev's gate claims were not reproduced.

What the delta is: one file, two hunks, three lines (--numstat 3 3). Lines 345–346, the provenance sentence, now read "It states what is on main after those two changes and decides nothing more"; line 376, the group heading over items 5–8, now reads "Hook bodies (packages/runtime/src/sandbox/body-runner.ts, the hook face):". No other line of the file moved between da22e1db2a and 6d6ecfe5a5. The merge commit changes nothing in the file (git diff 6d6ecfe5a5 73fd87521c -- docs/adr/0079-record-display-name.md is empty); it brought four paths (.changeset/24287-hono-stream-string-verbatim.md, two hono adapter files, one http-conformance test), none a file the addendum rests on, and all 21 cited files carry the same blob at da22e1db2a and at 73fd87521c. The net diff against the merge base is still docs/adr/0079-record-display-name.md +77/−0 in the same two hunks (the - **Addenda**: pointer line in the header block; the appended section at 342–414). main's copy of the file is 337 lines, unchanged since the merge base; the head's is 414.

Check-runs on 73fd87521c, read at 2026-10-18T13:21Z: 39, all completed, 31 success, 8 skipped, 0 failure. The five queue contexts: Lint & Repo Gates, TypeScript Type Check, Test Core (and its six shards) and Governed Surface Queue Guard success; Build Core skipped by path, as on the prior head. Part-of PR must not also close its card is now success (13:04:34Z) — ③.2. Check Changeset is skipped on this head, by design — ②. The other skips are the path and opt-in lanes the prior record listed.

① Derived judgments

  1. The group heading over items 5–8 — now RIGHT. It names hook bodies and the hook face, which is where ctx.title() exists and the only place it does, read in the code at this head (body-runner.ts blob 6b7ebfb70c, quickjs-runner.ts blob ce00ed6dac, both identical to main): buildTitleSurface is called once, at body-runner.ts 371, inside the hook's boundBodyHandler with { kind: 'hook', name: hook.name }; the hook context object carries title (1175) under the comment at 1171–1174, "Hook face only. The action face has its own record shape … deliberately not taken here — the ruling names hook bodies"; buildActionSandboxContext (1197–1247) assembles input, previous, user, session, object, recordId, record, recordLoadDenied, api, log, crypto and no title; quickjs-runner.ts 845–848 says "Installed only when the host built the seam (hook bodies; the action face does not)" and 876 installs the VM function only when typeof ctx.title === 'function'. Items 5–8 (378–392) say nothing about action bodies: the only case-insensitive occurrence of "action" in the whole addendum is "transaction's connection" at 382, and each item names its surface as a hook ("the hook fires on", "a runAs: 'user' hook"). The heading now states exactly the surface the closing bullet (412–414) claims was verified.

  2. The provenance sentence — now exact. "It states what is on main after those two changes" no longer attributes item 9's pre-existing refusals (INVALID_SORT, the virtual-search INVALID_FIELD) to feat(objectql,lint): a formula field reads one hop through a reference — record.book.title computes, a second hop is refused #24274 / fix(runtime,objectql): ctx.title() answers a title formula over lookups — the one hop is served on the body's own channel #24307; it dates the statements without crediting them. "decides nothing more; no section above is edited" is unchanged and still true (+77/−0 in the two hunks).

1–9. Each RIGHT, carried from record 6250184223 by interdiff, not re-derived. The lines carrying items 1–4 (357–374), 5–8 (378–392), 9 (396–402) and the three qualification bullets (406–414) are byte-identical to da22e1db2a, and every file the prior readings cite has the same blob id at da22e1db2a and at this head, so the prior record's readings (symbols and line numbers included) are readings of this head. Two of those files have since moved on origin/main (ec749fbb44, two commits past the merge base: #24317 and #24320): engine.ts +52/−1 and protocol.ts +14/−1, both from #24320. Read against those hunks, since the addendum claims to state main: engine.ts adds emptySearchSetError (2033 on main) and one throw of it in the search expansion (13768–13781) when a non-blank search resolves to an empty field set; protocol.ts excludes internal: true columns from searchAll's per-object skip set (15862). Neither touches planFormulaProjection, resolveFormulaRelated, resolveOneHopRelated, formulaHopScope, applyFormulaPlan, formulaHopRefusal, formulaHopReads, hydrateWriteFormulas, assertOrderByIsMaterializable, serveFormulaField, withRunAs or assertSearchFieldsAreSearchable. Item 9 in particular stays RIGHT on current main: "a searchableFields entry naming it is dropped" is still resolveSearchFieldResolution over isVirtualSearchField (search-fields.ts, unchanged); what #24320 adds is a refusal AFTER the drop, when nothing searchable remains, which item 9 neither states nor contradicts. Omitting it makes nothing false — ③.6.

Fence, quotation, exclusion. The fence holds: the diff adds and deletes nothing outside the two hunks; Provenance, Context, D1–D10, the status table, the open questions and the alternatives are untouched; the two qualifications (the Alternatives bullet's "can return and query"; D1's "real STORED") are still made in the addendum's own text. The ruling is quoted verbatim: lines 349–352, unwrapped, equal the card's Ruled: sentence byte for byte (compared programmatically against the card body's second line; the trailing board-state words "p1 target:v18 stands …" are rightly left out), and the interdiff did not touch the blockquote. Neither 24284 nor 24316 appears anywhere in the file at this head, and neither path is described: item 9 does not describe the empty-set answer (#24284, now #24320 on main), item 4 says nothing about stored-metadata family tables (#24316). The pointer line (9) and the heading (342) are unchanged from the prior head, so the slug equality carries; Check Documentation Links is green on this head.

② Semver level

Docs-only: one file under docs/adr/, no package, no .changeset. skip-changeset is the right label (AGENTS.md: the label is for a diff that publishes nothing from any released package). Check Changeset is skipped on this head because the job-level if: (pr-automation.yml 289–290, !contains(labels, 'skip-changeset')) saw the label on the synchronize event's payload — the exemption firing whole, not a gate left unmeasured; the prior head's green run was the labeled event's re-run after a first skipped. The PR body's Clause-②: no (an ADR note records what shipped; …) is byte-identical to the prior head's and sits at the start of body line 3; through readClause2Line (clause2-line.mjs 418, blob unchanged): matchValueToken takes no; readArmToken (280) sees a parenthetical whose first word an is neither an arm nor an arm-family word, so arm: null and not malformed; no no (widening) contradiction. RIGHT: nothing widens and nothing narrows. The tier is CONTRACT_REVIEW_TIER by the rules face — docs/adr/** is register row adr, GOVERNED_TIER_H (check-governed-merges.mjs 1177) — so this record is owed, and the landing is a governed approver's APPROVED review plus the owning seat; no seat approves, queues or arms.

③ Boundary flags

The dev's rework report is 6250259693; the seat's rework comment is 6250198140; the dispatch is 6249693922; the claim is 6249662765.

  1. The rework report raises no flags (deviations, open_questions, out_of_scope_findings all empty). Its claims, checked: fix commit 6d6ecfe5a5 then merge 73fd87521c — the graph agrees; "the merge touched none of the files the addendum's statements rest on" — blob ids agree (the merge brought four hono-side paths); "none of items 5–8 says 'action' (the only substring hit is 'transaction')" — agrees; "PR body not touched" by the dev — consistent with the seat's Summary edit in 6250198140 and the dev's rule 3. One miscount, not a flag: line_budget says "this round +2/−2"; git diff --numstat reads 3/3, because the provenance sentence re-wraps across two lines. Two statements changed, three lines.

  2. Prior ③.6 (the red Part-of PR must not also close its card) — CLOSED. The Summary's first sentence now reads "The third and last of card A formula field that reads through a reference (record.book.title) passes validateExpression, then reads null on every row: the read path projects only the row's own columns #24197's three parts"; partOfRe() (check-half-states.mjs 2427, \bPart[ \t-]of\b[ \t]*:?\s*#(\d+)\b, case-insensitive) has no match over the body (the substring "part of" no longer occurs in it at all), Fixes #24197 stays on line 1 as the dispatch asked for the last part, and the check concluded success at 13:04:34Z on this head.

  3. PR body, two stale phrases — not blocking; the seat's body edit, no push. The Summary's second paragraph still says the nine facts come "in three groups (read path, hook and action bodies, sort and search)", so the body's description of the groups now lags the diff it describes; and the Verification record's lead-in still says "on main at the merge da22e1db", true of when that table was built (the rework report covers the new head). Neither is in the governed file, and the 维护者速读 says "钩子里的 ctx.title()" — hook only — so the maintainer's summary already agrees with the addendum. Reword the first before the approval request so the body and the addendum say the same thing.

  4. Prior ③.1–③.3 (the three open questions, A/A/A) — not reopened; ③.1's residual (a heading naming a surface with no accessor) is what ①.10 fixed, so the addendum now names exactly the two surfaces it verified.

  5. Prior ③.4 (process deviations), ③.5 (unverified title consumers, disclaimed in the last bullet), ③.7 (items carried from records 6247399985 and 6249342777) — not reopened; the interdiff touches none of the lines they concern.

  6. main moved past the merge base by feat(platform-objects): sys_organization declares a name/logo-only rename action at the record header #24317 and fix(objectql)!: a search over an object with no searchable field is refused with 400 INVALID_FIELD, not answered as every row #24320 — the latter the fix of metadata-protocol / objectql: a search term on an object with no searchable field is dropped silently, so the read answers every row (an empty auto-default search set is not refused) #24284, the finding filed from this card's part 1 (ACCEPT 6247729361), landed at 12:43Z before this head's 12:58Z merge of a 1ef0ee89d4 origin/main. Not a conflict: main's copy of the ADR is unchanged and the queue rebuilds onto current main. Not a reopening: ① shows no statement becomes false, and the claim's fence ("not metadata-protocol / objectql: a search term on an object with no searchable field is dropped silently, so the read answers every row (an empty auto-default search set is not refused) #24284's answer while it is unlanded") has lapsed as a prohibition without creating a duty — whether fix(objectql)!: a search over an object with no searchable field is refused with 400 INVALID_FIELD, not answered as every row #24320's refusal earns an ADR note of its own is metadata-protocol / objectql: a search term on an object with no searchable field is dropped silently, so the read answers every row (an empty auto-default search set is not refused) #24284's lane's call, and the addendum's last bullet keeps other search behaviour out of this note's scope. No merge of main is owed before the approval request.

  7. Reviewer's own notes, none blocking: (a) buildTitleSurface still types origin.kind as 'hook' | 'action' and labels its errors with it — a signature's generality, not an installed accessor; the single call site passes 'hook', and the addendum speaks of behaviour, so the heading is right as written. (b) The seat's correction in 6250198140 of its own landing comment 6249626252 ("In a hook or action body" → "In a hook body") is right and worth having made: that comment is a record of what shipped and would otherwise seed the same wrong group heading again. (c) Items 5 and 6 say null; the host seam answers undefined and the VM wire converts (quickjs-runner.ts 903, value ?? null) — right for bodies, as the prior record noted.

Implemented-by: claude/issue-24197-adr-0079-note
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS

The one WRONG statement of record 6250184223 (①.10) is corrected: the heading places ctx.title() in hook bodies only, where the code installs it, and items 5–8 say nothing about action bodies. The ①.11 provenance wording is now exact. Items 1–9 stand on this head by interdiff and blob identity, and item 9 is also RIGHT against the main that has since landed #24320. The fence holds, the ruling is quoted byte for byte, and #24284 / #24316 are neither named nor described. All 39 check-runs are green or skipped by design, the Part-of guard included. Landing is Tier H: the seat requests the governed approvers' review and lands only on their APPROVED word; the ③.3 body rewording goes first.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读 · Tier H · PR #24323(#24197 第三部分,ADR-0079 附录)

改了什么
只改一份文档 docs/adr/0079-record-display-name.md:文件头加一行指向附录,文末追加一节附录 ## Addendum (2026-10-18, #24197),共 +77 行,不删任何一行。附录只陈述已经上线的事实,分三组:

  • 读路径:公式字段可以穿过一个引用字段(lookup / master_detail / user / tree)读关联记录,只读一跳,每页按每个引用字段批量读一次,用读者自己的权限;看不到或不存在的关联记录让公式为空,并报原因,不给回退值;两跳被 lint 拒绝,运行时也读空。
  • 钩子体:ctx.title() 按同样方式取标题,走钩子体自己的通道和事务。
  • 排序与搜索:由查找字段拼成的标题不落库,所以不能按它排序、也不能按它搜索;需要的话另存一个字段。
    附录也在自己的文字里修正了 D1 的 "STORED" 和"备选方案"第一条里的 "can return and query" 两处措辞;上面的原文一个字都没改。

为什么改
cloud#2925 的裁决 6245544505(字母 A)要求这条决定"写成 ADR-0079 的一则说明"。代码部分已由 #24274(d537a318b)和 #24307(1bf5f6e17c)落地,本 PR 是这张卡的最后一块。目的是让 AI 和开发者读 ADR 时就知道:拼接标题能显示,但不能排序搜索。

风险与代价(含回滚)
不动代码、不动契约、不发布任何包。风险只在文字与实现不符:第一轮评审(6250184223)抓到一处小标题把 ctx.title() 说成"钩子和动作体都有",实际只有钩子体有;已返工修正,新 head 的评审见下方席位核对。回滚:revert 这一个合并即可。

席位意见
建议批准。每条陈述都由契约评审逐条对照 main 上的代码核过;返工只改了两行文字;附录没有越出约定范围(只追加一节加一行指针);裁决原文逐字引用;不涉及在途的 #24284 和安全卡 #24316。

你要做的(一个动作)
在 PR #24323 上点 Approve(os-zhuang 或 hotlong 任一账户即可)。批准后由席位落地(转 ready、开自动合并入队),之后 #24197 关闭。


席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-18T13:34Z · ACCEPT (Tier H: lands only on a governed approver's approval)

Head: 73fd87521c (rework round 1: fix 6d6ecfe5a5, then a merge of main). Card #24197, part 3, the last part; claim 6249662765. Dev reports: 6249874213 (the first round) and 6250259693 (the rework).

Item Reading
Shape One file, docs/adr/0079-record-display-name.md, +77 / −0: one header pointer line and one appended addendum. The seat read the net diff: no line above the addendum is edited. The rework interdiff is exactly two lines: the group heading now names hook bodies only, and the provenance sentence now says "what is on main after those two changes". The merge 73fd87521c changes nothing in the file and none of the files the addendum's statements rest on (it brings Hono adapter files and one changeset).
Review face Governed rules text, CONTRACT_REVIEW_TIER. Record 6250184223 on da22e1db2a was FAIL on ①.10: the heading placed ctx.title() in action bodies. The seat verified that in code: buildTitleSurface is called only on the hook face (body-runner.ts), and quickjs-runner.ts installs ctx.title for hook bodies only. The delta record on this head is 6250527790, PASS.
Declaration Clause-②: no, no arm. An ADR note records what shipped; it moves no accept set and no public surface. skip-changeset: docs-only, nothing published.
Prose The seat read the addendum sentence by sentence against the record's per-statement verdicts. The ruling is quoted verbatim. Neither #24284 nor #24316 is named or described. "Every surface" is not claimed: two surfaces are named, the read path and hook bodies.
PR body The seat re-worded one Summary line through the relay (issue_patch). The case-insensitive Part of #N reader matched "the last part of #24197" beside Fixes #24197. Part-of PR must not also close its card is green on this head. After record 6250527790 (③.3), the seat also corrected two stale phrases in the Summary and the verification lead-in: "hook and action bodies" now reads "hook bodies", and a note names the rework head.
Gates Dev reports: 19 derived, 19 run, every exit 0, on both rounds.
CI On this head, before the seat's second body edit: 39 check-runs, 31 success, 8 skipped, 0 failed. The skips are on the EXPECTED_SKIPS roster: path or opt-in skips (Build Core, Build Docs, Console Pin Gate, Dogfood Verify CLI, the Dogfood shards, Temporal Conformance, Packed-tarball smoke) and Check Changeset, which the skip-changeset label skips by design. The body edit re-fires the three claim and card guards; their earlier runs on this head succeeded. Lint & Repo Gates, TypeScript Type Check, every Test Core shard and Governed Surface Queue Guard are green.
Cross-lane domain:skills (the governed surface): 6249668097 / 6249673718. No objection on the card at this read.

Disposition:

  • open_questions: A, A, A, accepted as record 6250184223 answers them.
    1. Name the two verified surfaces, not "every surface".
    2. Qualify D1's "STORED" in the addendum's own text.
    3. Leave out the two details that were not re-verified.
  • Deviations: process only (the worktree, attribution and subscription notes), all accepted.
  • out_of_scope_findings: other title consumers (searchAll hit titles, plugin-approvals' recordTitleOf over ctx.previous) were not verified for a title over lookups. Noted on seat post [PM seat] domain:engine — 🟢 os-project-manager #6367 for the next engine or approvals touch. No defect is asserted.

Landing (Tier H): check-governed-merges.mjs --pr 24323 reads governed (docs/adr/**). In this same act the seat puts needs-user-decision on the PR and requests reviews from os-zhuang and hotlong. The seat never approves or merges. After a governed approver's APPROVED, the seat lands it: pre-landing checks, clear the label, ready, then auto-merge. Fixes #24197 then closes the card.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review October 18, 2026 13:40
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit 68a090f Oct 18, 2026
54 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-24197-adr-0079-note branch October 18, 2026 13:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants