Skip to content

feat(objectql,runtime,cli): a hook body can name a record — ctx.title() resolves nameField, formula included - #11447

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-11293-hook-ctx-title-accessor
Aug 23, 2026
Merged

feat(objectql,runtime,cli): a hook body can name a record — ctx.title() resolves nameField, formula included#11447
os-zhuang merged 1 commit into
mainfrom
claude/issue-11293-hook-ctx-title-accessor

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes#11293

A lowered hook body ships body-only — the CLI lowers handler to body.source and the runtime evaluates it in QuickJS with no module scope — so it could not name a record. ctx.previous / ctx.input carry stored columns, a formula is computed on read, and nothing on ctx answered "what is this record called?". The only way to put a record's name in a sentence was to re-implement the object's title inline, per hook.

This adds ctx.title(field?), the ruled design (a) and nothing beyond it.

// this record — the object's nameField, formula or stored column alikeawaitctx.api.object('sys_notification').insert({subject: `${awaitctx.title()} was closed`});// a related record, through the lookup column that hands the body only an idconstaccount=awaitctx.title('account_id');

⚠️ Angle-bracket placeholders are avoided throughout this body on purpose: GitHub's body sanitizer strips short <…> fragments even inside backticks, and the first version of this description had ctx.title('&lt;lookup field&gt;') silently rewritten to ctx.title('') — which inverted the capability rule below into its opposite. Placeholders are spelled as bare identifiers instead.

The formula half is the design centre, not an extension

Four of the five titles measured in the exemplar app have a nameField that is a formula (display_title, full_name); only crm_opportunity.name is a real column. An accessor written column-first would answer the wrong four of five, so the formula case is resolved first here and the column case falls out of it.

How a formula title is resolved, and what it costs — measured, not asserted:

formresolutioncost
ctx.title()The record state the hook is already firing on (hookRecordState — stored ⊕ payload, materialized over the declared fields: the very state the declarative condition gate evaluates). The declared expression is evaluated in-process by evaluateFormulaField, which drives the read path's ownplanFormulaProjection + applyFormulaPlan.zero extra reads, formula included
ctx.title('account_id') — any lookup / master_detail / user / tree columnOne findOne through the body's own ctx.api. The engine's read path already materializes the related object's formula fields onto the row it returns, so there is no second pass.exactly one extra read, of the target object

Both numbers are pinned as differential measurements in hook-ctx-title.integration.test.ts (the same update with a body that never calls ctx.title() reads exactly as much), rather than as absolute counts that would only pin the update path's own behaviour.

Reusing the read path's evaluator is the point, not an optimization: the defect being closed is a title re-composed per hook drifting from the declaration it copies, so a title composed here and a title read back from GET /data/:object/:id are the same expression, normalized the same way (string shorthand → CEL envelope), rounded by the same scale. evaluateFormulaField plans only the requested field, so a neighbouring malformed formula on the same object cannot break a title lookup — find's whole-schema plan compiles every formula at planning stage and would.

Capabilities are per form, because the cost is

The argument formctx.title('account_id') — requires api.read, the same token the equivalent hand-written ctx.api.object(...).findOne() needs, gating the same read; the CLI extractor infers it from any ctx.title( call that has an argument. The no-argument form — plain ctx.title() — requires nothing: it has no read to gate, and taxing the majority case with a grant it never exercises works directly against the one property this accessor exists for. The related read goes through ctx.api, resolved at call time, so it obeys the caller's scope and joins an open ctx.api.transaction instead of asking the pool for a second connection — a deadlock on pool max=1 (SQLite, the objectstack dev default).

It never falls back to the record id

No resolvable title ⇒ null inside the VM. That is deliberate and pinned: the measured consequence of the gap was not duplication but record.id in user-facing prose — eight sites across four hooks, and a walkthrough that found 15 of 31 tasks in a demo org titled by a 16-character key. An id-shaped string is a perfectly plausible title to whatever renders it, so the platform will not manufacture one. A formula that cannot evaluate is likewise absence (applyFormulaPlan's own r.ok ? … : null), never a half-composed value; a formula that cannot compile throws, exactly as it does on every find of that object. A typo'd field name is refused loudly — a typo and an empty lookup are opposite facts, and answering both with "no title" is how a body ends up silently unable to name anything.

Scope — the ruling, and what is deliberately NOT here

Maintainer ruling, 2026-08-23, live PM chat, quoted unaltered:

「10950 不考虑存量,其他接受你的建议」

design (a) only. Not built, not partially built: (b) hydrating nameField into the hook pre-image, and (c) general formula-field readability from hook bodies. Neither is needed by (a) — this reads one declared field, the title pointer, and evaluates it only when that pointer names a formula. So the card's consequence 2 (no title accessor) is closed outright; consequence 1 (a body cannot reach a formula field) is closed for the title and only the title, which is what the ruling approved. Also deliberately not taken: an action-body counterpart — the action face has its own record shape (ctx.record, a read-only pre-fetched snapshot) and its own dispatch sites, and widening to it is a separate capability call.

Verification

Ablations, direction predicted before each leg, mutation proven on disk (anchor grep counts, not an insertion count) and restore proven byte-identical (git hash-object vs the committed blob). Every mutation script carried trap … EXIT INT TERM.

legpredictedobserved
resolver made column-only → objectql suite (relative imports → source)REDRED — 4 failed / 18 passed, incl. the vacuity control
same mutation → runtime suite, no rebuildGREEN (stale dist/)GREEN — 9/9
same mutation → runtime suite, after rebuildREDRED — 3 failed / 6 passed; ablation-dist-preflight confirmed the marker live in 2 built files
VM install removed (installCtx) → runtime suiteREDRED — 9/9 failed, TypeError: not a function
VM install removed → objectql suiteGREENGREEN — 22/22

Rows 2 and 3 are the same mutation either side of one pnpm --filter @objectstack/objectql build, which is the empirical demonstration that a rebuild is load-bearing for the runtime suitepackages/runtime/vitest.config.ts aliases seven packages to source and @objectstack/objectql is not among them (it is a KNOWN_UNALIASED_TEST_IMPORTS entry). The objectql suite is the opposite regime and owes no rebuild. Both test files state which regime they are in, in their headers.

Vacuity control. Every formula fixture composes a title equal to no single stored column, and both suites assert that directly — so "the formula ran" and "a column was echoed back" cannot be confused. ctx.title() gating is likewise controlled: the refusal arm (no api.read, argument form) sits beside a control arm proving the same hook with no capabilities still resolves this record's title, so the refusal cannot be produced by a ctx.title that is simply broken.

Suites, at 7e91289506:@objectstack/objectql 230 files / 4099 tests · @objectstack/runtime 185 files / 2724 tests · @objectstack/cli 166 files / 1857 tests — all passing. pnpm lint (repo-wide, eslint . --no-inline-config) exit 0; no narrowing claimed. Gate union derived from the actual diff via node scripts/pm/dispatch-gates.mjs with no paths passed — all 34 path-matched families plus the 6 convention-triggered ones run, each exit status captured before any pipe, all 0. check:type-check-debt first refused as not measured (one dependency had no built type entry point); after building that closure it reports exit 0 with no ledger entry above its recorded number — @objectstack/runtime's TEST_DEBT reads 226 against a recorded 227, so the new test file adds no raw tsc error.

Docs corrected in the same PR (grepped for the claim rather than waiting for the drift bot, which anchors on symbols): content/docs/automation/hook-bodies.mdx — the ctx surface table, the capability-inference table, and a new section; skills/objectstack-data/references/data-hooks.md — the ctx.* table row and the api.read row (its "exactly five tokens" count is unchanged, so the note is folded into the existing row rather than added as a sixth).

Published-skill budget readings — changed file skills/objectstack-data/references/data-hooks.md: 1445 → 1446 lines (+1), 12489 → 12640 tokens (+151, the ratchet's own tokenizer). Whole published package skills/objectstack-data (12 .md files): 4933 → 4934 lines (+1), 46777 → 46928 tokens (+151). SKILL.md itself is untouched, so check-skills-token-ratchet is unmoved (13817 tokens, ceiling 13817).

Region discipline vs #11435 ("privileged driver-level reads join the ambient transaction", open, another seat): re-verified against that PR's current diff before starting — its engine.ts hunks are at 5937–5977, 5993–6004, 6062–6073, 6148–6162. This PR's engine.ts diff is a single hunk at 1396 (+55/−0), beside the existing formula helpers, ~4,500 lines away in a different method with no shared symbol. hook-wrappers.ts is untouched by #11435.


Generated by Claude Code

…() resolves nameField, formula included (#11293)
A lowered hook body ships body-only and runs in QuickJS with no module scope,
so it could reach neither a formula field nor any accessor answering "what is
this record called?". The only way to name a record in a sentence was to
re-implement the object's title inline, per hook — five times in the exemplar
app, four of them re-composing a nameField that is a FORMULA. The cheap
alternative a body reaches for instead is `record.id`, and that shipped into
user-facing prose across four hooks.
- `resolveRecordTitle` / `resolveRelatedTitleTarget` (objectql): the title
semantics, once. A formula nameField is evaluated through the read path's own
plan builder and evaluator (`evaluateFormulaField`), narrowed to the one
requested field so a neighbour's malformed formula cannot break a title.
- `hookRecordState` (objectql): the record state the hook is firing on, made
public so the accessor and the declarative `condition` gate cannot disagree
about what "this record" means.
- `ctx.title(field?)` (runtime): the VM seam. No argument resolves this record
with zero reads; an argument resolves a lookup-related record with exactly
one findOne through the body's own read channel, so it obeys the caller's
scope and joins an open ctx.api.transaction.
- Capability per form: the related form needs `api.read`, the bare form needs
nothing — the token gates a read, and the bare form has none. The CLI's
extractor infers `api.read` from `ctx.title(<argument>)`.
- Never falls back to the record id.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/objectql, @objectstack/runtime, touching 21 documentable anchor(s).

33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5a916c4d4dc57f09bc4cbd5e7fe33f3545b186c8.

3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

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
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 44 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 5a916c4d4dc57f09bc4cbd5e7fe33f3545b186c8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6c6cfa336f331632e4b0ff85d4983b343efe3249 — the merge of head 7e912895068c06ea9b06f2843a2681f221e5e149 into base 5a916c4d4dc57f09bc4cbd5e7fe33f3545b186c8, 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 6c6cfa336f331632e4b0ff85d4983b343efe3249 && git checkout 6c6cfa336f331632e4b0ff85d4983b343efe3249
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5a916c4d4dc57f09bc4cbd5e7fe33f3545b186c8 7e912895068c06ea9b06f2843a2681f221e5e149 && git checkout -B drift-repro 5a916c4d4dc57f09bc4cbd5e7fe33f3545b186c8 && git merge --no-ff 7e912895068c06ea9b06f2843a2681f221e5e149
node scripts/docs-audit/affected-docs.mjs --json 5a916c4d4dc57f09bc4cbd5e7fe33f3545b186c8

⚠️ 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 5a916c4d4dc57f09bc4cbd5e7fe33f3545b186c8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 23, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 23, 2026 20:05
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

ACCEPT — engine seat. Marked ready for review, then enqueued (that order deliberately: ready_for_review discards auto-merge and any queue slot).

Green read by job name

35 check runs, 0 running, none non-green; only Console Pin Gate skipped. Temporal Conformance (live PG + MySQL), Build Core, Test Core, TypeScript Type Check, Lint & Repo Gates, Check Changeset, Build Docs and Dogfood Regression Gate each read completed: success individually. Head unchanged at 7e9128950.

The cost question was the gate, and it came back as a measurement

I made this blocking: resolving a formulanameField server-side could plausibly cost a per-record round trip, which would make the accessor unusable in a hook body's hot path — and I said a well-evidenced stop there would be a good outcome. The answer is better than the stop:

  • ctx.title() — zero extra reads, formula included. It resolves over hookRecordState, the state the hook is already firing on, and evaluates through the read path's own planFormulaProjection / applyFormulaPlan.
  • ctx.title('lookup_field') — exactly one findOne, because the engine's read path already materializes the related object's formula fields onto the row it returns.

Both pinned as differential measurements — the same update with a body that never calls it reads exactly as much — rather than absolute counts, which would only have pinned the update path's own behaviour. That is a sharper instrument than I asked for.

Three things I did not think to require, and should have

  • Reusing the read path's evaluator is the design, not an optimization. The defect being closed is a title re-composed per hook drifting from the declaration it copies; composing it through the same expression, the same normalization and the same scale is what makes that drift impossible rather than merely unlikely.
  • evaluateFormulaField plans only the requested field, so a neighbouring malformed formula cannot break a title lookup — where find's whole-schema plan compiles every formula at planning stage and would.
  • The related read goes through ctx.api, so it joins an open ctx.api.transaction instead of asking the pool for a second connection. On pool max=1 — SQLite, the objectstack dev default — the alternative is a deadlock.

Refusing to fall back to the record id is the right call

null inside the VM when no title resolves, deliberately and pinned. The reasoning is exactly right: an id-shaped string is a perfectly plausible title to whatever renders it, so manufacturing one hides the defect this card exists to close — the measured symptom was record.id in user-facing prose, 15 of 31 tasks in a demo org titled by a 16-character key. And the three cases are kept distinct rather than collapsed: cannot evaluate ⇒ absence; cannot compile ⇒ throws, as every find on that object does; typo'd field name ⇒ refused loudly, because a typo and an empty lookup are opposite facts.

The rebuild regime was demonstrated, not asserted

I asked which regime applied. The answer is a five-leg table where legs 1b and 1c are the same mutation either side of one build — runtime suite green without a rebuild (the false green, reproduced deliberately), red after. Root cause named: packages/runtime/vitest.config.ts aliases seven packages to source and @objectstack/objectql is not among them. The objectql suite is the opposite regime and owes no rebuild. Both test files state which regime they are in, in their headers, so the next person does not re-derive it.

A discarded leg is disclosed too: the first attempt at 1c used a comment as the dist marker, which tsup strips — the preflight correctly reported 0 hits and that run was redone with a code-level marker. The marker choice was itself the defect, and catching it is the discipline working.

Docs — chased independently, nothing further owed

The advisory listed 33 hand-written pages (21 anchors across 3 packages) — the coarse-anchor pattern filed today as #11434. Measured rather than read:

  • No page states the limitation this PR removes.cannot name | no nameField accessor | hand-compose | re-implement the title over content/docs and skills → nothing, with a live control (nameField appears in 5 doc files).
  • The ctx surface table lives in automation/hook-bodies.mdx, which the PR corrected (+36/−0), plus the skills reference. automation/hooks.mdx uses ctx.input / ctx.previous / ctx.api in prose and examples only, and no page claims exhaustiveness (that grep is empty), so nothing there is stale.

The PR grepped for the claim rather than waiting for the bot, citing that it anchors on symbols — #11434 was filed this afternoon and applied the same day.

Scope

Design (a) only. (b) pre-image hydration and (c) general formula readability are not built and are not needed by (a) — this reads one declared field, the title pointer. An action-body counterpart was deliberately not taken, with its reasoning (ctx.record is a different shape with its own dispatch sites) — that is a separate capability call, correctly left alone.

⚠️ Noted for the record, and now in the seat post: the comment sanitizer ate angle-bracket placeholders inside backticks in this PR's body and inverted the capability rule into its opposite. Caught on read-back and corrected. That is a worse failure mode than the marker-eating seen earlier today — a missing marker is visible, an inverted rule is not.


Generated by Claude Code

@os-zhuang
os-zhuang added this pull request to the merge queueAug 23, 2026
Merged via the queue into main with commit 8542bd4Aug 23, 2026
36 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-11293-hook-ctx-title-accessor branch August 23, 2026 20:24
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

@os-zhuang@claude