Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/hook-ctx-title-accessor.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
---
"@objectstack/objectql": minor
"@objectstack/runtime": minor
"@objectstack/cli": minor
---

**Feature:** a hook body can now name a record — `await ctx.title()` resolves the object's `nameField`, `await ctx.title('<lookup field>')` resolves a related record's, and a `formula` title is evaluated server-side (#11293).

A lowered hook body ships body-only and runs in QuickJS with no module scope, so it could reach neither a **formula** field (`ctx.previous` / `ctx.input` carry stored columns; a formula is computed on read) 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. Measured in the exemplar app: **five** inline reimplementations, and in **four of the five** the `nameField` is a formula (`display_title`, `full_name`) — only `crm_opportunity.name` is a real column. Each copy duplicates a formula declared once on the object and drifts from it in silence, which the app had to compensate for with a repo-local test and a repo-local hygiene check.

What it actually produced was worse than duplication. The cheap thing to write with no title accessor is `record.id` — the one identifier a body always holds — and that shipped: eight sites across four hooks put a raw primary key into user-facing prose, and a walkthrough found 15 of 31 tasks in a demo org titled by a 16-character key. An agent writing a hook reaches for `${record.id}` for exactly the same reason, so the fix is to put the correct answer **closer to hand than the wrong one**.

```js
// this record — nameField, formula or stored column alike
await ctx.api.object('sys_notification').insert({ subject: `${await ctx.title()} was closed` });
// a related record, through the lookup column that holds its id
const account = await ctx.title('account_id');
```

**Cost, measured rather than asserted.** `ctx.title()` performs **no read at all**, formula included: it resolves against the record state the hook is already firing on — the same stored ⊕ payload state the declarative `condition` gate evaluates — and evaluates the declared expression in process through the read path's own plan builder and evaluator, so a hook's title and a `GET`'s title cannot diverge. `ctx.title('<field>')` costs **exactly one `findOne`** and no more, because the read path already materializes the related object's formula fields onto the row it returns.

**Capabilities are per form, because the cost is.** The related form requires `api.read` — the same token the equivalent hand-written `ctx.api.object(...).findOne()` needs, gating the same read — and the CLI's extractor infers it from `ctx.title(<argument>)`. The no-argument form requires **nothing**, since it has no read to gate; taxing the majority case with a grant it never exercises would work against the one property this accessor exists for. The related read goes through the body's own `ctx.api`, so it obeys the caller's scope and joins an open `ctx.api.transaction` rather than asking the pool for a second connection.

**It never falls back to the id.** No resolvable title ⇒ `null` inside the VM. An id-shaped string is a perfectly plausible title to whatever renders it, so the platform will not manufacture one; a caller that wants a fallback writes it and owns it. A formula that cannot evaluate is likewise absence, never a half-composed value.

Scope is the ruled design and nothing beyond it: hook bodies only. Hydrating `nameField` into the hook pre-image, general formula-field readability from bodies, and an action-body counterpart are each separate calls and are deliberately not taken here.
36 changes: 36 additions & 0 deletions content/docs/automation/hook-bodies.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,10 +99,45 @@ The script sees only what the surrounding `ctx` object exposes:
| `ctx.user` / `ctx.session` | Identity context. | none |
| `ctx.api.object(name).find\|count\|aggregate` | Cross-object reads, scoped to current tenant. | `api.read` |
| `ctx.api.object(name).insert\|update\|delete` | Cross-object writes. | `api.write` |
| `ctx.title()` | This record's title — the object's `nameField`, **including when it is a `formula`** (evaluated server-side against the record already in hand, no extra read). | none |
| `ctx.title('<lookup field>')` | The related record's title, through a lookup / master_detail / user / tree column. Costs one `findOne`. | `api.read` |
| `ctx.crypto.randomUUID()` | UUID generation. | `crypto.uuid` |
| `ctx.log.{info,warn,error}` | Structured logging. | `log` |
| `ctx.connector(name).<method>(...)` _(planned)_ | Outbound HTTP / SaaS calls. **Not yet wired into the sandbox** — ships with the separate Connector spec. | (separate Connector spec) |

### Naming a record — `ctx.title()`

A body composing a message needs the record's **name**, and until this accessor
existed it could not get one: `ctx.input` / `ctx.previous` carry stored columns,
while a `nameField` is very often a `formula` computed on read. The result was
that every hook re-implemented the object's title inline — or, more often,
printed `record.id`, which is the one identifier always in scope and the one
string the UI never shows.

```ts
// this record — resolves `nameField`, formula or stored column alike
await ctx.api.object('sys_notification').insert({
subject: `${await ctx.title()} was closed`,
});

// a related record, through the lookup column that holds its id
const account = await ctx.title('account_id');
```

Three properties worth knowing:

- **A formula `nameField` costs nothing extra.** It is evaluated server-side
against the record the hook is already firing on — the same expression, the
same evaluator and the same rounding a `GET` of that record would use, so the
title a hook writes and the title the UI shows cannot drift.
- **The related form costs exactly one `findOne`**, through your body's own read
channel — so it obeys the caller's scope and joins an open
`ctx.api.transaction`. That is why it requires `api.read` while the bare form
requires nothing: the token gates the read, and there is no read to gate.
- **It never falls back to the id.** No title resolvable ⇒ `null`. An id-shaped
string is a plausible-looking title to whatever renders it, so the platform
will not manufacture one; write your own fallback if you want one.

<Callout type="warn">
**There is no hashing capability — `crypto.hash` was removed in spec 17.** Until
17 the `crypto.hash` token was declared in `HookBodyCapability`, listed in this
Expand DownExpand Up@@ -318,6 +353,7 @@ The extractor scans each body for known patterns and adds the matching capabilit
| `*.object(…).insert / update / upsert / delete / patch / remove / create` | `api.write` |
| `ctx.crypto.randomUUID` | `crypto.uuid` |
| `ctx.log.info / warn / error / debug` | `log` |
| `*.title(<argument>)` — the related-record form only; bare `ctx.title()` performs no read | `api.read` |

When inference does not derive what a body needs, declare the tokens yourself by
supplying `body` on the hook or action instead of a `handler`:
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/utils/extract-hook-body.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -101,6 +101,21 @@ const CAPABILITY_PATTERNS: Array<{ rx: RegExp; cap: 'api.read' | 'api.write' | '
// capability from a call that always threw is what let `os build` bless a
// dead body — the inference was the amplifier, not the safety net.
{ rx: /ctx\.log\.(?:info|warn|error|debug)\b/, cap: 'log' },
// [#11293] `ctx.title(field)` — the RELATED-record form, and only that form.
// `ctx.title()` resolves this record's title (formula included) from the
// state the hook is already firing on and performs no read at all, so it
// needs no capability and inferring one for it would tax the majority case
// with a grant it never exercises. The argument form costs exactly one
// `findOne` through the body's own read channel, which is the same read
// `ctx.api.object(...).findOne()` would do and gets the same token.
//
// `[^)\s]` after the paren is what distinguishes the two: `ctx.title()` and
// `ctx.title( )` do not match, `ctx.title('account_id')` does. Receiver-loose
// like the `.object(...)` patterns above, for the same reason — a local alias
// (`const t = ctx.title`) must not silently UNDER-infer, since that failure
// arrives as a sandbox refusal at run time, far from its cause. Over-inferring
// grants a token the body may not use, which the sandbox simply never checks.
{ rx: /\.\s*title\s*\(\s*[^)\s]/, cap: 'api.read' },
];

export interface ExtractedBody {
Expand Down
55 changes: 55 additions & 0 deletions packages/objectql/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1393,6 +1393,61 @@ function hydrateWriteFormulas(
applyFormulaPlan(plan, records, execCtx);
}

/**
* Materialize ONE declared `formula` field against a record already in hand —
* the read path's own evaluation, narrowed to a single field, with no round
* trip (#11293).
*
* ## Why this exists rather than a second evaluator
*
* A hook body cannot reach a formula field: `ctx.previous` / `ctx.input` carry
* STORED columns, and a formula is computed on read, so a body that wants the
* record's title has to rebuild the formula inline. Measured in the exemplar
* app: five inline reimplementations of a record title inside hook bodies, four
* of them re-composing a `nameField` that is a formula. Each copy can drift
* from the declaration it copies, silently — which is the whole defect, so the
* remedy must not itself be a copy. This calls
* {@link planFormulaProjection} + {@link applyFormulaPlan}: the same plan
* builder, the same `Expression` normalization (string shorthand → CEL
* envelope), the same `scale` rounding and the same evaluation scope the read
* and write paths use. One formula semantic, not a hook-path dialect (PD #12).
*
* ## Narrowed to one field ON PURPOSE
*
* `planFormulaProjection(schema, undefined)` — the shape `find` and
* {@link hydrateWriteFormulas} use — plans EVERY formula field on the schema
* and `ExpressionEngine.compile`s each one at planning stage. Asking for one
* title would then throw on an unrelated malformed formula elsewhere on the
* object. Passing `[field]` plans exactly the requested field, so the blast
* radius of a title lookup is the title's own declaration.
*
* ## Read-path parity, including how it fails
*
* Both failure modes are the read path's, unchanged: a formula that does not
* COMPILE throws (as it does on every `find` of the object), while a formula
* that compiles and does not EVALUATE yields `null` — `applyFormulaPlan`'s own
* `r.ok ? … : null`. A caller therefore cannot mistake "this title could not be
* computed" for a computed value.
*
* Returns `undefined` when `field` is not a declared formula field, which is
* how a caller tells "read the stored column instead" from "the formula
* produced nothing". Evaluates against a shallow COPY: `applyFormulaPlan`
* writes the value onto the record it is handed, and the records reaching here
* are the engine's own hook payloads, observed by everything downstream.
*/
export function evaluateFormulaField(
schema: unknown,
record: Record<string, unknown>,
field: string,
execCtx?: ExecutionContext,
): unknown {
const { plan } = planFormulaProjection(schema as any, [field]);
if (plan.length === 0) return undefined;
const scratch: Record<string, unknown> = { ...record };
applyFormulaPlan(plan, [scratch], execCtx);
return scratch[field];
}

/**
* A hook body, as registered through {@link ObjectQL.registerHook} or bound
* from metadata by `bindHooksToEngine`.
Expand Down
23 changes: 23 additions & 0 deletions packages/objectql/src/hook-wrappers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -746,6 +746,29 @@ function declaredFieldsFor(ctx: HookContext): Record<string, unknown> | undefine
* Copies, never mutates: `ctx.previous` and `ctx.input.data` are the engine's
* own objects, observed by the handlers that run after this gate.
*/
/**
* The record state THIS hook is firing for — stored ⊕ payload, materialized
* over the object's declared fields (#11293).
*
* The public name for {@link pickRecordPayload}, exported so a consumer that
* has to answer "what record is this?" gets the SAME answer the declarative
* `condition` gate gets. The runtime's `ctx.title()` seam is the first such
* consumer: a title composed from a different record state than the one
* `condition: "record.status == 'closed'"` evaluated would be two meanings of
* "this record" one line apart in the same hook — the drift PD #12 forbids,
* and precisely the drift this accessor exists to remove.
*
* A copy, never the engine's own object: {@link pickRecordPayload} builds a new
* record from `ctx.previous` and `ctx.input.data` rather than handing either
* out, so a caller cannot mutate the write through it.
*/
export function hookRecordState(ctx: HookContext): Record<string, unknown> {
const record = pickRecordPayload(ctx);
return record && typeof record === 'object' && !Array.isArray(record)
? (record as Record<string, unknown>)
: {};
}

function pickRecordPayload(ctx: HookContext): any {
const input: any = ctx.input ?? {};
const payload: Record<string, unknown> | undefined =
Expand Down
14 changes: 14 additions & 0 deletions packages/objectql/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,6 +146,20 @@ export { wrapDeclarativeHook, HookConditionError } from './hook-wrappers.js';
// see the note above `HookConditionError` in `hook-wrappers.ts`. Its two members
// described a batch-scoped `before*` dispatch that no longer exists.
export type { WrapDeclarativeOptions } from './hook-wrappers.js';
export { hookRecordState } from './hook-wrappers.js';

// Export record-title resolution (#11293) — "what is this record called?",
// answered from the object's own `nameField` declaration with a formula title
// evaluated server-side. The runtime's `ctx.title()` hook-body seam is built on
// exactly these; they are exported so it does not have to re-derive any of it.
export {
resolveRecordTitle,
resolveRelatedTitleTarget,
titleFieldOf,
RecordTitleFieldError,
} from './record-title.js';
export type { RelatedTitleTarget } from './record-title.js';
export { evaluateFormulaField } from './engine.js';

// Export Validation
export { ValidationError, validateRecord } from './validation/record-validator.js';
Expand Down
Loading
Loading