Uh oh!
There was an error while loading. Please reload this page.
feat(objectql,runtime,cli): a hook body can name a record — ctx.title() resolves nameField, formula included - #11447
Conversation
…() 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
📓 Docs Drift CheckThis PR changes 3 package(s): 33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 44 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 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
|
os-zhuang
commented
Aug 23, 2026
ACCEPT — engine seat. Marked ready for review, then enqueued (that order deliberately: Green read by job name35 check runs, 0 running, none non-green; only The cost question was the gate, and it came back as a measurementI made this blocking: resolving a formula
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
Refusing to fall back to the record id is the right call
The rebuild regime was demonstrated, not assertedI 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: A discarded leg is disclosed too: the first attempt at 1c used a comment as the dist marker, which Docs — chased independently, nothing further owedThe advisory listed 33 hand-written pages (21 anchors across 3 packages) — the coarse-anchor pattern filed today as #11434. Measured rather than read:
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. ScopeDesign (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 ( Generated by Claude Code |
Fixes#11293
A lowered hook body ships body-only — the CLI lowers
handlertobody.sourceand the runtime evaluates it in QuickJS with no module scope — so it could not name a record.ctx.previous/ctx.inputcarry stored columns, a formula is computed on read, and nothing onctxanswered "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.The formula half is the design centre, not an extension
Four of the five titles measured in the exemplar app have a
nameFieldthat is a formula (display_title,full_name); onlycrm_opportunity.nameis 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:
ctx.title()hookRecordState— stored ⊕ payload, materialized over the declared fields: the very state the declarativeconditiongate evaluates). The declared expression is evaluated in-process byevaluateFormulaField, which drives the read path's ownplanFormulaProjection+applyFormulaPlan.ctx.title('account_id')— any lookup / master_detail / user / tree columnfindOnethrough the body's ownctx.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.Both numbers are pinned as differential measurements in
hook-ctx-title.integration.test.ts(the same update with a body that never callsctx.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/:idare the same expression, normalized the same way (string shorthand → CEL envelope), rounded by the samescale.evaluateFormulaFieldplans 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 form —
ctx.title('account_id')— requiresapi.read, the same token the equivalent hand-writtenctx.api.object(...).findOne()needs, gating the same read; the CLI extractor infers it from anyctx.title(call that has an argument. The no-argument form — plainctx.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 throughctx.api, resolved at call time, so it obeys the caller's scope and joins an openctx.api.transactioninstead of asking the pool for a second connection — a deadlock onpool max=1(SQLite, theobjectstack devdefault).It never falls back to the record id
No resolvable title ⇒
nullinside the VM. That is deliberate and pinned: the measured consequence of the gap was not duplication butrecord.idin 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 ownr.ok ? … : null), never a half-composed value; a formula that cannot compile throws, exactly as it does on everyfindof 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:
⇒ design (a) only. Not built, not partially built: (b) hydrating
nameFieldinto 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-objectvs the committed blob). Every mutation script carriedtrap … EXIT INT TERM.dist/)ablation-dist-preflightconfirmed the marker live in 2 built filesinstallCtx) → runtime suiteTypeError: not a functionRows 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 suite —packages/runtime/vitest.config.tsaliases seven packages to source and@objectstack/objectqlis not among them (it is aKNOWN_UNALIASED_TEST_IMPORTSentry). 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 (noapi.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 actx.titlethat is simply broken.Suites, at
7e91289506:@objectstack/objectql230 files / 4099 tests ·@objectstack/runtime185 files / 2724 tests ·@objectstack/cli166 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 vianode scripts/pm/dispatch-gates.mjswith no paths passed — all 34 path-matched families plus the 6 convention-triggered ones run, each exit status captured before any pipe, all0.check:type-check-debtfirst 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— thectxsurface table, the capability-inference table, and a new section;skills/objectstack-data/references/data-hooks.md— thectx.*table row and theapi.readrow (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 packageskills/objectstack-data(12.mdfiles): 4933 → 4934 lines (+1), 46777 → 46928 tokens (+151).SKILL.mditself is untouched, socheck-skills-token-ratchetis 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.tshunks are at 5937–5977, 5993–6004, 6062–6073, 6148–6162. This PR'sengine.tsdiff 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.tsis untouched by #11435.Generated by Claude Code