Repository navigation
fix(runtime,objectql): ctx.title() answers a title formula over lookups — the one hop is served on the body's own channel - #24307
Conversation
Claude-Session: https://claude.ai/code/session_01XpmVCa3CH5g9uCZLy3j587 Co-authored-by: Claude <noreply@anthropic.com>
… restate the cost docs Claude-Session: https://claude.ai/code/session_01XpmVCa3CH5g9uCZLy3j587 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpmVCa3CH5g9uCZLy3j587 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 53 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f59c7e5624b3f34f845666c21257cf9f3e9035eb && git checkout f59c7e5624b3f34f845666c21257cf9f3e9035eb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 21070a2f0ac944e930ab8c269e04d024d4a21eea 0d133459d7af4eac7b5ea7dbbdefb44ab21c698c && git checkout -B drift-repro 21070a2f0ac944e930ab8c269e04d024d4a21eea && git merge --no-ff 0d133459d7af4eac7b5ea7dbbdefb44ab21c698c
node scripts/docs-audit/affected-docs.mjs --json 21070a2f0ac944e930ab8c269e04d024d4a21eea
|
Contract reviewServed-tier: Inputs: card #24197 (the body with its Check-runs on the head, read at 2026-10-18T11:15Z: 47 runs.
① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
席位核对 ·
|
| Item | Reading |
|---|---|
| Declaration | Clause-②: yes (widening). The @objectstack/objectql index gains resolveServedRecordTitle, servedRecordTitle and the type FormulaServingChannel. ObjectQL and ScopedContext each gain a public serveFormulaField. The claim's original no became wrong when the index moved. The claim is re-declared, and the seat patched the PR body line through the relay (issue_patch): read back identical, and readClause2Line reads yes / widening. Nothing narrows. |
| Review face | Declaration face, TIER_DEFAULT, the lane seat's record of record: 6249342777, PASS. The seat re-verified its central claims in the tree at this head: the hop's query carries the caller's own context (resolveOneHopRelated, authority === 'caller' arm, no isSystem spread) and is marked in formulaHopReads (one hop); ScopedContext.serveFormulaField passes the channel's own executionContext; the VM title wire hands txState.api ?? ctx.api, and the seam Proxy's default branch forwards the method bound to that context; a channel without the method (UnscopedHookApi) takes the unchanged synchronous path; ctx.title('field') reads the served row through servedRecordTitle. |
| Prose face | Read sentence by sentence against the diff: both changesets and content/docs/automation/hook-bodies.mdx. The objectql changeset's three bullets (authority, transaction, null cases) match serveFormulaField and record-title.ts. The runtime changeset's three bullets match the pins (in-flight keys, the 0-read control, the hidden-reader and second-hop null). The page's two table rows, the formula bullet, the related-form bullet and the extractor row state the measured cost and the capability reasoning. No sentence is false. Two gaps the record names are accepted without an edit: the objectql changeset does not name the type FormulaServingChannel, and the runtime changeset leaves out that a related title hidden by field-level security now answers null (①.7, a fail-closed tightening). |
| Cross-lane | domain:cli: body-runner.ts 6248176531 / 6248182027, plus addendum 6249058767 / 6249065083 for three comment-only edits (quickjs-runner.ts, script-runner.ts, cli extract-hook-body.ts; the seat read each hunk as comment-only). domain:devx: hook-bodies.mdx, 6249072260 / 6249076041. No objection on this card at this read. |
| Gates | Dev report at this head: 102 derived, 102 run, every exit 0, 0 NOT MEASURED. Typecheck exit 0 for objectql, runtime and cli. Five ablations, each red on its own pin and restored to blob == HEAD. |
| CI | 48 check-runs: 41 success, 7 skipped, 0 failed. Test Core 3/6 and 4/6, still open when the record was taken, have since completed green. All seven skips are on the EXPECTED_SKIPS roster: five path or opt-in skips, plus the body-edit re-runs of Auto Label and Check PR Size, whose first runs on this head succeeded. Both Check Changeset runs succeeded, including the one on the re-declared body. Mergeable, clean. |
Disposition:
open_questions: A, A, A, A, all accepted as the record answers them.- The related form reads the served row; re-deriving it would recompute a title that field-level security removed.
- The bare form needs no capability.
- A channel that cannot read keeps today's answer.
- The
Clause-②line, as re-declared.
- Deviations: all accepted.
- The export widening is re-declared.
- The comment and doc rows were forced by the pin sweep.
ScopedContext.serveFormulaFieldis the minimal route: a read throughobject(…)would rebuild the query and lose the one-hop marker.- The runtime's full suite ran on the pre-merge head
650581b9. The seat checked that the merge0d133459d7brought noobjectql,runtimeorclisource (an empty diff over those paths), and CI's Test Core shards are green on this head. - The worktree, attribution and subscription notes are process.
out_of_scope_findings: filed as [security] Stored-metadata-body family: a formula field's one-hop read through a reference is an exit the family projection does not reach — reach not yet measured, detail withheld pending maintainer #24316, a security card that names only the category and the position (record ③.6). It is not this PR's to fix. The same value reaches a body today through its own read of the object that declares the formula.- Non-blocking, carried to seat post [PM seat] domain:engine — 🟢 os-project-manager #6367 for the next touch:
- no pin reaches the no-user
runAs: 'user'branch (③.3); ObjectQL.serveFormulaField's docblock does not describe its optionalcontext(①.1).
- no pin reaches the no-user
- ADR-0079 note (part 3): it takes part 1's
adr_note_text, this report'sadr_note_addendum, and the record's ③.7. That adds three things:- "the caller's permissions" means the body channel's identity (SYSTEM under
runAs: 'system'); - a channel that cannot read answers
nullfor a title over a reference; - field-level security on the title field itself does not apply to
ctx.title().
- "the caller's permissions" means the body channel's identity (SYSTEM under
Landing: ordinary queue (relay pr_ready, then automerge_enable). Not governed: check-governed-merges.mjs --pr 24307 reads 0 of 13 paths, 722 changed lines. The PR says Part of #24197, so the card stays open for part 3.
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>
Part of #24197 (part 2 of 3: the hook-body title accessor. The ADR-0079 note follows as its own Tier H PR.)
Clause-②: yes (widening) (re-declared by the seat in claim 6249052664: the
@objectstack/objectqlindex gainsresolveServedRecordTitle,servedRecordTitleand the typeFormulaServingChannel, and the exported classesObjectQLandScopedContexteach gain a publicserveFormulaField; nothing is removed or renamed. The accessor's own answer is a declared capability honoured: it names the record by its declared title.)Published export change, reported for the seat to re-declare the line above:
@objectstack/objectql'sindex.tsgainsresolveServedRecordTitle,servedRecordTitleand the typeFormulaServingChannel. The exported classesObjectQLandScopedContexteach gain one public method,serveFormulaField. Nothing is removed or renamed. The changeset grades objectqlminorand runtimepatch.What was wrong
PR #24274 made the read path serve one hop, so
GETof a junction object showsrecord.book.title + ' · ' + record.reader.name. A hook body'sctx.title()still answerednullfor it. Both forms of the accessor evaluated the title with the synchronousevaluateFormulaField, which preloads no hop.ctx.title('field')also discarded the related record's own one-hop title that itsfindOnehad already served, because it re-derived the title synchronously.What changed
ObjectQL.serveFormulaField(objectName, record, field, context)(engine.ts) evaluates one declared formula field over a record in hand, the way a read evaluates it. It uses the same plan builder, the same hop loader (resolveFormulaRelated→resolveOneHopRelated(…, 'caller'), including theformulaHopReadsone-hop guard), the samecan()permission map and the same fault sink. No second loader or evaluator is added anywhere, and runtime has no copy of either.ScopedContext.serveFormulaField(objectName, record, field)calls it with the context's ownExecutionContext. That is the body'sctx.apichannel, so its identity, its permissions and (inside a transaction) itstransactionhandle all carry over.record-title.tsaddsresolveServedRecordTitle(schema, record, channel), the async sibling ofresolveRecordTitle. It also addsservedRecordTitle(schema, row), which reads the title a read already served off the row.resolveRecordTitleis unchanged and still serves no hop. Its docblock now says so.body-runner.tsbuildTitleSurface:ctx.title()resolves overhookRecordState(the in-flight state) on the channel the installer hands in per call (txState.api ?? ctx.api).ctx.title('field')reads the title off the row itsfindOneserved.serveFormulaFieldfalls back to the synchronous path, which still resolves a title over the record's own columns. That is the unscoped api of arunAs: 'user'hook with no user, which cannot read.Which channel serves the hop, and why. The body's own
ctx.api, transaction-scoped when the body has one open. The hop is the engine's own loader (this.findon the target with the channel's context). It is not a repository read through the channel'sobject(...), because a repository rebuilds the query object, which would lose the identity-keyed one-hop marker and let the related object's formulas take a second hop.Hypotheses, measured
ctx.title()follows the in-flight keybeforeInsertnames the payload's book. AbeforeUpdatethat movesbookb1 to b2 namesEmma, notDune.afterUpdatenames the after-state.beforeUpdatewhere the body opensctx.api.transaction(the body owns it), both hop reads run on that transaction's handle. In anafterUpdate, the body JOINS the write's own unit of work (inAfterAbortUnit), and every read runs on that one handle. Ablation: when the hop is routed through the hook's baseapiinstead, the owned-transaction pin hangs (Acquire connection error: aborted, test timeout at 30 s), which is the deadlock.ctx.title('book')for a book whose title isrecord.title + ' @ ' + record.shelf.codeanswersnull. Reading the served value answersDune @ A-1, which equalsfindOnefor the same caller.ctx.title()answersnull, andfindOneof the junction row as the same caller answerstitle: null. Neither returns the readable half or the hidden name. SYSTEM getsDune · Hidden Personfrom both (control).record.book.label, where the book'slabelreads its shelf, answersnullfrom bothctx.title()and the read. Nolib_shelfread is issued.Cost (measured): a title over lookups costs one batched read per reference field it reads through: exactly +2 reads (
lib_book,lib_reader) against the same write with a body that does not ask, and the book read iswhere id $in [b1]with fields[id, title]. A title over the row's own columns still costs 0 reads; that pin is kept and renamed to say so. The related form costs onefindOne, plus whatever hop that record's own title formula reads.Verification
Head
0d133459d7(after mergingorigin/main):pnpm build --concurrency=2, 76/76 tasks, exit 0.pnpm --filter @objectstack/{objectql,runtime,cli} typecheckexit 0 each. Each script includescheck:test-typecheckovertsconfig.test.json, so the new tests are type-checked.@objectstack/objectqlpnpm test: 439 files, 8280 tests passed.@objectstack/runtimepnpm test: 418 files, 6574 passed and 19 skipped, run on the pre-merge head650581b9.hook-ctx-title-one-hop.integration.test.tsandhook-ctx-title.integration.test.ts20/20, andengine-formula-one-hop.test.tsandrecord-title.test.ts47/47.packages/runtime/src/sandbox/hook-ctx-title-one-hop.integration.test.ts: 11 tests (real engine, real SQLite, real QuickJS).engine-formula-one-hop.test.ts, coveringserveFormulaField: hop by the in-hand key, parity withfindOne, zero reads for own columns, the transaction handle, the caller-permission null, no second hop, and the served row title.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) derived 102 commands. All 102 ran, each exit captured before any pipe: 102 × exit 0.--ranoutput: "102 derived famil(ies) accounted for — 102 run, 0 NOT-MEASURED".scripts/ablation-replace.mjs. Each one landed (anchor 1 → 0, blob moved), and each restore was proven by blob == HEAD withgit diff HEADempty.resolveRecordTitle. 8 of the 11 runtime pins go red.api. The owned-transaction H2 pin deadlocks and times out.null).ScopedContextdropstransaction. Exactly the transaction pin goes red../engine.jsfrom source, so nodistleg was needed. A1 to A3 mutate runtime source, which that suite imports directly.Settled choices (see the report's open questions)
findOnedoes not compute formulas. Every in-repo channel (ScopedContext,ql.object,ql.createContext, the repo facade overql.find) computes them. Re-deriving also computed a title that field-level security had deleted from the row.ctx.title()still needs no capability, even when the title's hop reads. The hop reads are the declaration's, made on the body's own channel under the caller's permissions. Requiringapi.readonly when a title happens to traverse a lookup would make a body's grant depend on another object's metadata.Deviations from the claim's file surface
These are comment-only or documentation-only edits outside the fence. The pin sweep forced each one, because each stated the old cost:
content/docs/automation/hook-bodies.mdx: three rows and one bullet pair saidctx.title()performs no read.packages/runtime/src/sandbox/quickjs-runner.tsandscript-runner.ts: comments and a docblock.packages/cli/src/utils/extract-hook-body.ts: one comment.No behaviour changed in any of them. The two existing measurement pins in
hook-ctx-title.integration.test.tskeep their assertions and are renamed to the own-column case.Acceptance notes
engine.findOnewith the same context. That is the read the REST data route serves. No HTTP server was booted.refuseStoredMetadataBodyReads). It is still read under the caller's own permissions, and the REST read serves the same value. No in-repo object composes a title through a lookup to a family table. Noted here, not filed.packages/*/CHANGELOG.md) still says "no read at all". That text is release-owned, so it is left as is. This PR's changesets state the new cost.Generated by Claude Code