Skip to content

fix(runtime,objectql): ctx.title() answers a title formula over lookups — the one hop is served on the body's own channel - #24307

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24197-hook-title-one-hop
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24197-hook-title-one-hop

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

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/objectql index gains resolveServedRecordTitle, servedRecordTitle and the type FormulaServingChannel, and the exported classes ObjectQL and ScopedContext each gain a public serveFormulaField; 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's index.ts gains resolveServedRecordTitle, servedRecordTitle and the type FormulaServingChannel. The exported classes ObjectQL and ScopedContext each gain one public method, serveFormulaField. Nothing is removed or renamed. The changeset grades objectql minor and runtime patch.

What was wrong

PR #24274 made the read path serve one hop, so GET of a junction object shows record.book.title + ' · ' + record.reader.name. A hook body's ctx.title() still answered null for it. Both forms of the accessor evaluated the title with the synchronous evaluateFormulaField, which preloads no hop. ctx.title('field') also discarded the related record's own one-hop title that its findOne had 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 the formulaHopReads one-hop guard), the same can() 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 own ExecutionContext. That is the body's ctx.api channel, so its identity, its permissions and (inside a transaction) its transaction handle all carry over.
  • record-title.ts adds resolveServedRecordTitle(schema, record, channel), the async sibling of resolveRecordTitle. It also adds servedRecordTitle(schema, row), which reads the title a read already served off the row. resolveRecordTitle is unchanged and still serves no hop. Its docblock now says so.
  • body-runner.ts buildTitleSurface:
    • ctx.title() resolves over hookRecordState (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 its findOne served.
    • A channel without serveFormulaField falls back to the synchronous path, which still resolves a title over the record's own columns. That is the unscoped api of a runAs: '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.find on the target with the channel's context). It is not a repository read through the channel's object(...), 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

Hypothesis Result
H1 ctx.title() follows the in-flight key Confirmed. beforeInsert names the payload's book. A beforeUpdate that moves book b1 to b2 names Emma, not Dune. afterUpdate names the after-state.
H2 The hop rides the body's channel Confirmed on real SQLite (better-sqlite3, one pooled connection). In a beforeUpdate where the body opens ctx.api.transaction (the body owns it), both hop reads run on that transaction's handle. In an afterUpdate, 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 base api instead, the owned-transaction pin hangs (Acquire connection error: aborted, test timeout at 30 s), which is the deadlock.
H3 Re-deriving the related title discards a served one-hop value Confirmed. With the old re-derivation, ctx.title('book') for a book whose title is record.title + ' @ ' + record.shelf.code answers null. Reading the served value answers Dune @ A-1, which equals findOne for the same caller.
H4 A caller who may not read a related record gets the read's answer Confirmed. Row-level security hides one reader. ctx.title() answers null, and findOne of the junction row as the same caller answers title: null. Neither returns the readable half or the hidden name. SYSTEM gets Dune · Hidden Person from both (control).
H5 No second hop Confirmed. A copy titled record.book.label, where the book's label reads its shelf, answers null from both ctx.title() and the read. No lib_shelf read 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 is where 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 one findOne, plus whatever hop that record's own title formula reads.

Verification

Head 0d133459d7 (after merging origin/main):

  • Full build: pnpm build --concurrency=2, 76/76 tasks, exit 0.
  • Typecheck: pnpm --filter @objectstack/{objectql,runtime,cli} typecheck exit 0 each. Each script includes check:test-typecheck over tsconfig.test.json, so the new tests are type-checked.
  • Suites:
    • @objectstack/objectql pnpm test: 439 files, 8280 tests passed.
    • @objectstack/runtime pnpm test: 418 files, 6574 passed and 19 skipped, run on the pre-merge head 650581b9.
    • On the final head, rebuilt: hook-ctx-title-one-hop.integration.test.ts and hook-ctx-title.integration.test.ts 20/20, and engine-formula-one-hop.test.ts and record-title.test.ts 47/47.
  • New pins:
    • packages/runtime/src/sandbox/hook-ctx-title-one-hop.integration.test.ts: 11 tests (real engine, real SQLite, real QuickJS).
    • 8 tests in engine-formula-one-hop.test.ts, covering serveFormulaField: hop by the in-hand key, parity with findOne, zero reads for own columns, the transaction handle, the caller-permission null, no second hop, and the served row title.
  • Gates: 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. --ran output: "102 derived famil(ies) accounted for — 102 run, 0 NOT-MEASURED".
  • Ablations: made via scripts/ablation-replace.mjs. Each one landed (anchor 1 → 0, blob moved), and each restore was proven by blob == HEAD with git diff HEAD empty.
    • A1: served path → synchronous resolveRecordTitle. 8 of the 11 runtime pins go red.
    • A2: channel → the hook's base api. The owned-transaction H2 pin deadlocks and times out.
    • A3: related form re-derives. The H3 pin goes red (null).
    • A4: engine hop read as SYSTEM. Exactly the caller-permission pin goes red.
    • A5: ScopedContext drops transaction. Exactly the transaction pin goes red.
    • A4 and A5 ran on the objectql suite, which imports ./engine.js from source, so no dist leg was needed. A1 to A3 mutate runtime source, which that suite imports directly.

Settled choices (see the report's open questions)

  • The related form prefers the served value. It no longer re-derives. The old guard defended against a hypothetical embedder whose findOne does not compute formulas. Every in-repo channel (ScopedContext, ql.object, ql.createContext, the repo facade over ql.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. Requiring api.read only 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 said ctx.title() performs no read.
  • packages/runtime/src/sandbox/quickjs-runner.ts and script-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.ts keep their assertions and are renamed to the own-column case.

Acceptance notes

  • REST parity is pinned against engine.findOne with the same context. That is the read the REST data route serves. No HTTP server was booted.
  • A hop's target in the stored-metadata family is not refused for a body's title. The hop is the engine's read, so it does not pass through the body api's family-read refusal (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.
  • The changelog text of A lowered hook body cannot name a record: no nameField accessor, no formula fields — so every message hand-composes the title, or falls back to the id #11293 (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

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/objectql, @objectstack/runtime, touching 10 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-handling-server.mdx (via ScopedContext (symbol, a top-level class))
  • content/docs/automation/hook-bodies.mdx (via ScopedContext (symbol, a top-level class), ScriptContext (symbol, a top-level interface))
  • content/docs/permissions/system-context.mdx (via ScopedContext (symbol, a top-level class))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 53 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 21070a2f0ac944e930ab8c269e04d024d4a21eea → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f59c7e5624b3f34f845666c21257cf9f3e9035eb — the merge of head 0d133459d7af4eac7b5ea7dbbdefb44ab21c698c into base 21070a2f0ac944e930ab8c269e04d024d4a21eea, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 21070a2f0ac944e930ab8c269e04d024d4a21eea → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 0d133459d7af4eac7b5ea7dbbdefb44ab21c698c
Local-runs: none

Inputs: card #24197 (the body with its Ruled: line, and all 14 comments, among them claims 6248128513 and 6249052664, dispatch 6248210634, and the dev's os-dev-report 6248949940, which was read as claims to check); the PR #24274 record 6247399985, whose ③.6 is where this part comes from; PR #24307 (the body and the 13-file list); and the net diff ef6564684342..0d133459d7 (+670 / −52). ef6564684342 is the merge base of origin/main 30da04f8e0 and the head. The diff was read with git diff and git show. Nothing was built, run or re-run.

Check-runs on the head, read at 2026-10-18T11:15Z: 47 runs.

  • 38 succeeded. Among them: Build Core, Build Docs, both Check Changeset runs, Governed Surface Queue Guard, every Lint job, the four Type Check jobs, TypeScript Type Check, Test Core 1/6, 2/6, 5/6 and 6/6, Spec property liveness, and the claim and card guards.
    • The first Check Changeset run started at 10:48Z, on the body before the re-declaration.
    • The second started at 11:05Z, after the body edit, and ran on the Clause-②: yes (widening) body.
  • 7 were skipped by design: Console Pin Gate, the Dogfood shards, Dogfood Verify CLI, Temporal Conformance, Packed-tarball smoke, and the re-fired Auto Label and Check PR Size.
  • 2 were still in progress and not waited on: Test Core (3/6) and Test Core (4/6). Those two shards are part of the gate verdict on the new pins. This record does not stand in for them, and the adopting seat reads their conclusions before the queue.

① Derived judgments

  1. ObjectQL.serveFormulaField(objectName, record, field, context?) (engine.ts). NEEDED, and its contract is RIGHT.

    • Mechanism. It is the read path's own evaluation, call for call: planFormulaProjection(schema, [field]), then applyFormulaPlan with resolveFormulaPermissions(…, this.permissionResolution(context)), formulaFaultSink(objectName) and resolveFormulaRelated(schema, plan, [scratch], context). These are the four calls find and findOne make (engine.ts, about lines 14018 and 14307). They run over a shallow copy, so the hook payload is never written.
    • Inputs. For a field that is not a declared formula, the plan is empty and the method returns undefined. That is the same "read the stored column instead" signal resolveRecordTitle already uses.
    • Authority. resolveFormulaRelated calls resolveOneHopRelated(…, 'caller'). Its hop query carries context unchanged; the isSystem spread belongs to the 'predicate' arm only. The query goes through the public this.find, so every read layer applies.
    • Transaction. The hop reads with the channel's context, transaction included. Pinned: both hop driver calls carry the channel's handle.
    • One hop. resolveFormulaRelated is called without a query, so the record in hand serves its first hop. The hop query is then added to formulaHopReads, so the related object's own formulas serve no hop. Pinned: no lib_shelf read happens.
    • Faults. A related record that is missing or unreadable, and a second hop, leave the whole formula null, with the read's reason sent to the sink. There is never a partial string: formulaHopScope answers ok: false before anything is evaluated. A hop read that throws is caught as unreadable. A syntactically broken formula throws at planFormulaProjection's compile, as find and the synchronous evaluateFormulaField already do.
    • Why public. ScopedContext reaches the method through the engine type, and a TypeScript private would refuse that call.
    • One gap in the docblock, not the code. The optional context is not described. An omitted context reads the way an engine find with no context reads. Only in-process code that already holds the engine can call this method, and it grants nothing find does not.
  2. ScopedContext.serveFormulaField(objectName, record, field). NEEDED and RIGHT.

    • It passes this.executionContext. That is the same object ScopedContext.object(name) hands to ObjectRepository, which passes it as context on every find and findOne.
    • sudo(), withRunAs(…), transaction(…) and the begin trio each build a new ScopedContext over the same engine with the derived context, so the method follows every one of them.
    • It is the minimal route. The execution context is a private constructor field. A read through object(…) would rebuild the query object as { ...query, context } and lose the identity-keyed formulaHopReads marker. This is the dev's deviation 3, and it is verified.
    • Every new ScopedContext( in non-test source is in engine.ts, over an ObjectQL, so the cast to ObjectQL holds at this head.
  3. resolveServedRecordTitle(schema, record, channel), servedRecordTitle(schema, row) and the type FormulaServingChannel (record-title.ts, exported from the . index). Each is NEEDED and RIGHT.

    • The runtime is the consumer, and it is a separate package. Both helpers reuse the module-private toTitle, so runtime holds no second normalisation of a title.
    • resolveServedRecordTitle keeps resolveRecordTitle's shape: the formula leg first, the stored column when the pointer is not a formula, never the record's id.
    • For a formula pointer, applyFormulaPlan always writes the field (a value or null). So the stored-column fallback can never surface a stale stored value for a formula title.
    • FormulaServingChannel is a structural interface with one method. The body runner therefore tells a serving channel from UnscopedHookApi (hook-run-as.ts, which has no such method) with a typeof check, not by class.
    • resolveRecordTitle and evaluateFormulaField are unchanged apart from docblocks.
  4. The channel (body-runner.ts, buildTitleSurface). RIGHT: no privilege escalation and no second connection.

    • The VM title wire resolves txState.api ?? ctx.api at call time and hands it in (quickjs-runner.ts, the title install).
    • ctx.api is buildSandboxApi's seam Proxy over the hook's ScopedContext. The Proxy's default branch forwards serveFormulaField bound to that ScopedContext. txState.api is the ctx returned by the wrapped beginTransaction.
    • So the hop reads with exactly the identity, permissions and transaction the body's own ctx.api.object(…).find uses. It can wait on a connection only where that read would.
    • The VM cannot call serveFormulaField directly. Its ctx.api exposes object(name), with four read verbs, six write verbs and the transaction leaves. The method is reached only through ctx.title(), with the hook's own object and that object's title pointer.
    • "The caller" means the channel's identity. For an inheriting hook it is the triggering caller. For runAs: 'system' (sudo()) it is SYSTEM, which is what that body's own reads already get.
  5. What ctx.title() answers now, case by case:

    • Own-column formula title: computed over hookRecordState with 0 reads (pinned in both suites). It now also gets the can() map and whatever timezone the channel's context carries, as a read does. The synchronous path got neither. Not pinned.
    • Title over lookups: one batched read per reference field, through the in-flight foreign keys. That is the beforeInsert payload, a key moved in beforeUpdate, and the after-state in afterUpdate (all pinned). With the keys unchanged, it equals findOne for the same caller.
    • Related record hidden or missing: null, never the readable half and never the hidden name. findOne of the row answers title: null to the same caller (pinned H4, with the SYSTEM control). An empty reference is evaluated as on a read: the optional read gives its fallback, and a plain read faults to null.
    • Second hop: null, with no lib_shelf read, as on the read (pinned H5).
    • Channel without serveFormulaField (UnscopedHookApi, i.e. a runAs: 'user' hook with no user): the synchronous path. An own-column title resolves; a title over a reference answers null. This is exactly the base behaviour. No pin at this head reaches the branch, because every new and existing runtime pin runs on a ScopedContext.
    • ctx.title('field'): one findOne on the same channel, still gated on api.read. The title is read off the served row by servedRecordTitle, the related object's own one-hop title included (pinned H3, Dune @ A-1). If no row comes back, the answer is null.
  6. Parity with a REST read for the same caller. RIGHT for the hop. Two exceptions predate this PR, which neither opens nor closes them:

    • (a) Field-level security on this record's own title field. The read masks it (plugin-security field-masker.ts deletes the key). ctx.title() does not, and it did not before for an own-column title either. The hop's related columns are themselves read under the caller's FLS, so nothing is composed beyond what the caller can read.
    • (b) The foreign-key column posture recorded in 6247399985 ③.8b.

    Parity is pinned against engine.findOne with the same context, not against the HTTP route (the PR body's Acceptance notes say no server was booted). The route serves engine.findOne plus the door's projection, so the two agree except for (a).

  7. The related-form change is a fail-closed tightening. RIGHT. When FLS removes the related object's formula title from the served row, ctx.title('field') now answers null. Before, it re-derived the hidden value from the row's visible columns. Nothing that was accepted before is refused now.

  8. Gate-forced and doc rows. RIGHT.

    • quickjs-runner.ts, script-runner.ts and cli extract-hook-body.ts change comments only.
    • hook-bodies.mdx now states the cost and the capability reasoning truthfully.
    • The two pins renamed in hook-ctx-title.integration.test.ts keep their assertions.
    • All of these rows are re-declared in claim 6249052664.

② Semver level

  • @objectstack/objectql minor: RIGHT.
    • The . index gains two functions and one type. ObjectQL and ScopedContext, exported from both . and ./core, each gain one public method. Nothing is removed, renamed or narrowed.
    • The changeset names the four functions and methods, and it describes their authority, their transaction and their null cases accurately. It does not name the type FormulaServingChannel (cosmetic).
  • @objectstack/runtime patch: RIGHT.
    • No published shape moves; only the ScriptContext.title docblock changes. The accessor now does what its declaration already said.
    • The changeset leaves out the FLS consequence of ①.7 for the related form. That is worth one sentence, but it does not change the level.
  • @objectstack/cli: no changeset is owed. One comment changes in src/, and nothing published changes.
  • Clause-②: yes (widening) on the PR body: RIGHT.
    • readClause2Line reads it as declared, yes, arm widening: one key line, the arm parenthetical first, and the trailing reasoning ignored.
    • No narrowing arm is owed, because ①.7 refuses nothing.
    • The level axis's rule ("at least one moved package graded minor or above") holds through objectql. The Check Changeset run that re-fired on the edited body concluded success.
    • The claim's original no line (6248128513) became wrong once the index moved. Claim 6249052664 corrected it, as the dev's open question 4 asked.

③ Boundary flags

  1. Open question 1 (the related form reads the served value; A shipped). ANSWERED: A is right, see ①.7.

    • B doubles the hop cost for the same answer.
    • C would compute a title that FLS removed, because FLS deletes the key.
    • Every in-repo channel computes formulas on findOne. An embedder that did not would get null, which fails closed.
  2. Open question 2 (the bare form needs no capability; A shipped). ANSWERED: A is right.

    • The bare form's reads are bounded to the object's own declared title formula, one hop, on the body's channel (①.4). The body cannot point them anywhere else.
    • hook-bodies.mdx no longer says the bare form performs no read.
    • Consequence to record: a body with no api.read now learns the related columns its object's title composes. Those are exactly what any reader of the record sees in its title.
  3. Open question 3 (a channel without serveFormulaField; A shipped). ANSWERED: A is right, and it keeps the base behaviour unchanged (①.5). A pin on a runAs: 'user' hook with no user would stop that branch from going unguarded. Not blocking.

  4. Open question 4 (the Clause-② line). ANSWERED: yes (widening). It is re-declared in 6249052664 and now on the body; see ②.

  5. Deviations.

    • The export widening (②), the comment and doc rows (①.8) and the channel method (①.2) are all right, and all are re-declared.
    • The runtime's full suite ran on the pre-merge head 650581b9 only. The merge brought no objectql, runtime or cli source, and CI's Test Core shards are the verdict; two of them are still open.
    • The worktree, attribution and subscription notes are process, not contract.
  6. out_of_scope_findings (the stored-metadata-body family, reached through a formula's one-hop read). NOT below the filing gate. It is a possible disclosure path that has not been measured, it has been on main since PR feat(objectql,lint): a formula field reads one hop through a reference — record.book.title computes, a second hop is refused #24274, and it is ESCALATED for filing. It is not this PR's to fix: the fix belongs at the shared hop, and this PR adds no reach that a body's own read of the same object does not already have (see ③.2).

  7. The carried adr_note_addendum. True of this head. The ADR-0079 note should add three things:

    • "the caller's permissions" means the body channel's identity, which is SYSTEM under runAs: 'system';
    • a channel that cannot read (a runAs: 'user' hook with no user) answers null for a title over a reference;
    • field-level security on the title field itself is not applied to ctx.title() (①.6a).

Implemented-by: claude/issue-24197-hook-title-one-hop
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-18T11:26Z · ACCEPT

Head: 0d133459d7. Card #24197, part 2; the claim is re-declared as 6249052664. Dev report: 6248949940.

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.
    1. The related form reads the served row; re-deriving it would recompute a title that field-level security removed.
    2. The bare form needs no capability.
    3. A channel that cannot read keeps today's answer.
    4. 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.serveFormulaField is the minimal route: a read through object(…) 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 merge 0d133459d7 brought no objectql, runtime or cli source (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 optional context (①.1).
  • ADR-0079 note (part 3): it takes part 1's adr_note_text, this report's adr_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 null for a title over a reference;
    • field-level security on the title field itself does not apply to ctx.title().

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 18, 2026 11:27
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 18, 2026 11:27
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit 1bf5f6e Oct 18, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24197-hook-title-one-hop branch October 18, 2026 11:55
os-project-manager pushed a commit that referenced this pull request Oct 18, 2026
…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>
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants