Skip to content

Commit cbf795b

Browse files
os-zhuangclaude
andauthored
docs(skills): document the sandboxed hook body ctx/capability contract (#3314)
The objectstack-data hooks docs covered events + registration but never the sandbox `body` form — the metadata-native shape a metadata-only runtime actually executes. A third-party evaluator on 15.1.x got hooks working only by reverse-engineering `@objectstack/runtime`'s dist (memory: project_thirdparty_eval_15_1_findings). Add a "Sandboxed Hook Bodies (`body`)" section to references/data-hooks.md, verified against runtime source AND a live `create-objectstack@15.1.1` repro: - `body` vs the deprecated inline `handler`; the `{ language, source, capabilities }` shape; `source` is the function body wrapped as `(async (ctx) => { … })(ctx)`. - the sandbox `ctx` (input/previous/result/user/session/event/object/api/log/ crypto); `!ctx.previous` distinguishes create. - `ctx.api.object(n)` repo — find/findOne/count/insert/update({id,...})/upsert/ delete; query key is `where` (object + $-operators), NOT `filter:[['id','=',x]]`. - the six legal `HookBodyCapability` tokens + call-time gating/throw behaviour. - sandbox globals (verified available vs absent) and build-rejected identifiers. - gotcha: afterUpdate `ctx.result` is a PARTIAL patch → re-query for lookup FKs. - gotcha: cross-object writes obey the TARGET's sharing model (public_read → `FORBIDDEN`, admin not exempt). - a copy-paste afterUpdate example (os:check-gated) + `[BodyRunner]` troubleshooting, including that `ctx.log` emission is best-effort. Also: a quick-ref `body` block in rules/hooks.md, a SKILL.md pointer, fix the stale "14 events" → 8, and switch the handler-form Cross-Object API example to the canonical `where`. Verified end-to-end: scaffolded `create-objectstack@15.1.1`, wrote the doc's hook verbatim, drove it over REST — hook fired, the cross-object update landed (`position.status = filled`), zero `[BodyRunner] threw`. Skill gates (skill-docs / skill-refs / skill-examples / doc-authoring) all green. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 158aa14 commit cbf795b

3 files changed

Lines changed: 297 additions & 9 deletions

File tree

skills/objectstack-data/SKILL.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ For comprehensive documentation with incorrect/correct examples:
209209
- **[Validation Rules](./rules/validation.md)** — All validation types, script inversion, severity levels
210210
- **[Index Strategy](./rules/indexing.md)** — btree/gin/gist/fulltext, composite indexes, partial indexes
211211
- **[Data Lifecycle & Retention](./rules/lifecycle.md)**`lifecycle` classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle *hooks* below)
212-
- **[Lifecycle Hooks](./rules/hooks.md)** — Hook quick reference (→ see [references/data-hooks.md](./references/data-hooks.md) for the full 14-event guide)
212+
- **[Lifecycle Hooks](./rules/hooks.md)** — Hook quick reference (→ see [references/data-hooks.md](./references/data-hooks.md) for the full 8-event guide + the sandboxed `body` ctx/capability contract)
213213
- **[Datasources & Federation](./rules/datasources.md)**`defineDatasource`, external/federated objects (`remoteName`/`columnMap`), auto-connect gating, credentials; ❌ no `field.columnName` on external objects
214214

215215
---
@@ -402,9 +402,13 @@ const accountHook: Hook = {
402402
export default accountHook;
403403
```
404404

405-
See [rules/hooks.md](./rules/hooks.md) for the quick reference, or
406-
[references/data-hooks.md](./references/data-hooks.md) for complete
407-
documentation of all 14 lifecycle events, registration modes, and patterns.
405+
The `handler` above is the inline (in-process) form. The **preferred**,
406+
metadata-native form is a sandboxed `body``{ language: 'js', source, capabilities }`
407+
run in an isolated VM, the shape that AI/Studio-authored hooks and every build
408+
artifact carry. See [rules/hooks.md](./rules/hooks.md) for the quick reference, or
409+
[references/data-hooks.md](./references/data-hooks.md) for complete documentation
410+
of all 8 lifecycle events, both registration forms, the **sandboxed `body` ctx +
411+
capability contract**, and patterns.
408412

409413
---
410414

skills/objectstack-data/references/data-hooks.md

Lines changed: 243 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# Data Lifecycle Hooks — Reference
22

33
Reference companion to `objectstack-data/SKILL.md`. Comprehensive guide to
4-
the 14 data lifecycle events, registration modes, the `HookContext` API,
5-
and common patterns (validation, defaults, audit logging, workflows).
4+
the 8 data lifecycle events, registration modes (inline `handler` **and**
5+
sandboxed `body`), the `HookContext` API, and common patterns (validation,
6+
defaults, audit logging, workflows).
67

78

89
# Writing Hooks — ObjectStack Data Lifecycle
@@ -234,8 +235,241 @@ onError: 'log'
234235

235236
---
236237

238+
## Sandboxed Hook Bodies (`body`) — What the Sandbox `ctx` Can Call
239+
240+
A hook can carry its logic in one of two shapes. Everything above this point
241+
showed the inline **`handler`** function; the section below documents the
242+
**`body`** form — the one a metadata-only runtime actually executes, and the one
243+
that was previously undocumented (you had to reverse-engineer
244+
`@objectstack/runtime` to use it).
245+
246+
### `body` (sandboxed) vs `handler` (inline) — pick one
247+
248+
| | **`body`** — sandboxed script | **`handler`** — inline function |
249+
|:--|:--|:--|
250+
| Shape | `body: { language, source, capabilities }` (pure metadata) | `handler: async (ctx) => { … }` |
251+
| Runs in | An isolated **QuickJS VM** (edge-safe, capability-gated) | The host process (**full Node**) |
252+
| Ships as | Plain JSON inside the build artifact — travels everywhere | Lowered at build to a string ref + a sibling `.mjs` runtime module |
253+
| Status | **Preferred for new code** | **Deprecated** (`HookSchema`: *"prefer `body`"*) |
254+
| Both present? | Runtime uses **`body`** and ignores `handler` ||
255+
256+
Because a `body` is pure metadata, it is what AI-authored hooks, Studio-authored
257+
hooks, and any `objectstack build` artifact carry. The rest of this section is
258+
the contract for that sandbox.
259+
260+
### The `body` shape
261+
262+
```jsonc
263+
body: {
264+
language: 'js', // 'js' = L2 sandboxed script | 'expression' = L1 pure formula
265+
source: "/* function body */", // the FUNCTION BODY only — not a module
266+
capabilities: ['api.read', 'api.write', 'log'], // default: []
267+
timeoutMs: 250, // optional, ≤ 30000 (hook default 250ms, action 5000ms)
268+
memoryMb: 32, // optional, ≤ 256 (best-effort under QuickJS)
269+
}
270+
```
271+
272+
- `source` is the **function body**, which the runtime wraps as
273+
`(async (ctx) => { <source> })(ctx)`. Write **statements** against `ctx`;
274+
`await` is allowed.
275+
- In a `before*` hook, change the write by assigning `ctx.input.x = …` **or**
276+
`return { x: … }` (a returned object is shallow-merged onto `ctx.input`). In an
277+
`after*` hook the body is for side effects (cross-object writes, logging).
278+
- `language: 'expression'` is a pure CEL formula for a computed value or
279+
predicate — no IO, no `ctx.api`.
280+
281+
### What lives on the sandbox `ctx`
282+
283+
The sandbox is handed a **JSON snapshot** of these (built by
284+
`buildSandboxContext`), not live engine objects:
285+
286+
| `ctx.*` | Shape | Notes |
287+
|:--|:--|:--|
288+
| `ctx.input` | object | The write payload (mutable). On update, only the **changed** fields plus `id`. |
289+
| `ctx.previous` | object \| `undefined` | Pre-write record on update/delete. **`undefined` on insert** → use `!ctx.previous` to detect *create*. |
290+
| `ctx.result` | object \| `undefined` | `after*` only. ⚠️ **partial** on afterUpdate — see gotcha 1. |
291+
| `ctx.user` | object \| `undefined` | `{ id, name, email, organizationId }`. `undefined` for system / unauthenticated writes. |
292+
| `ctx.session` | object \| `undefined` | `{ userId, organizationId, roles, … }`. |
293+
| `ctx.event` | string | e.g. `'afterUpdate'` — dispatch on it when one hook subscribes to several events. |
294+
| `ctx.object` | string | The target object name. |
295+
| `ctx.api` | object | Cross-object CRUD. Gated by `api.read` / `api.write` — see below. |
296+
| `ctx.log` | `{ info, warn, error }` | Gated by `log`. Call **`ctx.log.info(msg, data?)`**`ctx.log` is an **object, not** callable as `ctx.log(msg)`. Emission is **best-effort** (see Troubleshooting). |
297+
| `ctx.crypto` | `{ randomUUID }` | Gated by `crypto.uuid`. |
298+
299+
(Action bodies additionally receive `ctx.recordId` and `ctx.record`, and their
300+
wrap is `(async (input, ctx) => { … })(input, ctx)` — the action params arrive as
301+
the first arg.)
302+
303+
Because the VM only holds a snapshot, mutating `ctx.previous` / `ctx.result` is
304+
local and thrown away; the only way to change the persisted write is
305+
`ctx.input.x = …` or `return { … }`.
306+
307+
### `ctx.api.object(name)` — the cross-object repo
308+
309+
`ctx.api.object('<object>')` returns a repository bound to the current
310+
org / user / transaction. Methods:
311+
312+
| Method | Capability | Call |
313+
|:--|:--|:--|
314+
| `find(opts)` | `api.read` | `find({ where: { … }, fields, sort, limit })` → array |
315+
| `findOne(opts)` | `api.read` | `findOne({ where: { id } })` → record \| `null` |
316+
| `count(opts)` | `api.read` | `count({ where: { … } })` → number |
317+
| `insert(data)` | `api.write` | `insert({ … })` |
318+
| `update(data, opts?)` | `api.write` | **`update({ id, ...fields })`** — put the id **inside** `data` |
319+
| `upsert(data, opts?)` | `api.write` | `upsert({ … })` |
320+
| `delete(opts)` | `api.write` | `delete({ where: { id } })` |
321+
322+
**Query shape — the key is `where`.** It takes an object with `$`-operators, the
323+
same DSL as the [objectstack-query](../../objectstack-query/SKILL.md) skill:
324+
325+
```js
326+
await ctx.api.object('candidate').findOne({ where: { id: ctx.result.id } });
327+
await ctx.api.object('candidate').find({ where: { stage: 'hired' } });
328+
await ctx.api.object('invoice').count({ where: { amount: { $gte: 1000 } } });
329+
await ctx.api.object('task').find({ where: { $and: [{ done: false }, { owner: uid }] } });
330+
```
331+
332+
> `filter:` is tolerated as an **object-valued** alias (normalised to `where`),
333+
> but prefer `where`. Do **not** pass an array-of-triples such as
334+
> `[['id', '=', x]]` — that is not a supported value shape and silently matches
335+
> nothing.
336+
337+
**Update by id.** `update` reads the primary key out of `data`, so the
338+
single-record form is `update({ id, ...fieldsToChange })` — e.g.
339+
`update({ id: pos, status: 'filled' })`.
340+
341+
### Capabilities — the complete list
342+
343+
A body may only touch a `ctx` API it declared in `capabilities`. Calling an
344+
undeclared one **throws inside the VM** — `capability '<token>' not granted to
345+
hook '<name>' …` — which surfaces as a hook error (see Troubleshooting). The full
346+
set of legal tokens (`HookBodyCapability`) is exactly six:
347+
348+
| Token | Unlocks |
349+
|:--|:--|
350+
| `api.read` | `ctx.api.object(n).find` / `findOne` / `count` / `aggregate` |
351+
| `api.write` | `ctx.api.object(n).insert` / `update` / `delete` / `upsert` |
352+
| `api.transaction` | `ctx.api.transaction(async () => { … })` — runs the callback's `ctx.api` ops in **one driver transaction** (commit on return, rollback on throw). Pair it with `api.write`. |
353+
| `crypto.uuid` | `ctx.crypto.randomUUID()` |
354+
| `crypto.hash` | *declared token* for `ctx.crypto.hash(algo, data)` — the current WASM runner wires only `randomUUID`, so don't rely on `hash` yet |
355+
| `log` | `ctx.log.info` / `warn` / `error(msg, data?)` |
356+
357+
There is **no `http.fetch` capability** by design — outbound calls go through
358+
Connector recipes so they stay auditable and replayable.
359+
360+
### Sandbox restrictions
361+
362+
The body runs in an isolated QuickJS VM, **not** Node:
363+
364+
- **Available:** standard JS built-ins — `Date`, `Math`, `JSON`, `Object`,
365+
`Array`, `String`, `Number`, `RegExp`, `Map`, `Set`, `Promise`, `parseInt`,
366+
`encodeURIComponent`, … — plus `ctx.*` and `await`.
367+
- **Not available** (verified absent from the QuickJS heap): `console` (use
368+
`ctx.log`), `fetch` (use Connectors), `setTimeout` / `setInterval`, `URL`,
369+
`TextEncoder` / `TextDecoder`, `structuredClone`, `atob` / `btoa`, `require`,
370+
Node modules, the filesystem.
371+
- **Rejected by `objectstack build`:** `import` / `require` / dynamic `import()`,
372+
`process`, `globalThis`, `eval`, `new Function`, and any **free identifier**
373+
a name bound at module scope, e.g. a `const slugify = …` helper sitting next to
374+
the hook. A `body` must be **self-contained**: inline the helper, or keep that
375+
hook as a bundled `handler`.
376+
377+
### ⚠️ Gotcha 1 — `ctx.result` is a *partial* record on afterUpdate
378+
379+
On `afterUpdate`, both `ctx.result` and `ctx.input` carry only the fields this
380+
PATCH touched, plus `id`. A field you did **not** write — a lookup FK, a status
381+
you want to branch on — is **absent**, not stale. To read the whole record,
382+
re-query it:
383+
384+
```js
385+
// afterUpdate on `candidate`
386+
const full = await ctx.api.object('candidate').findOne({ where: { id: ctx.result.id } });
387+
// full.position_id is present even though this PATCH only set `stage`.
388+
```
389+
390+
(A declarative `condition` on an un-written field hits the same wall — guard it
391+
with the missing-key-safe `has(record.x)` macro.)
392+
393+
### ⚠️ Gotcha 2 — cross-object writes obey the *target's* sharing model
394+
395+
A hook's `ctx.api.object('other').update(…)` goes through the engine's normal
396+
write path, so it is gated by **`other`'s** permission / sharing rules — not by
397+
whoever is elevated. If the acting user cannot edit the target (e.g. it is
398+
`public_read`), the write throws:
399+
400+
```
401+
FORBIDDEN: insufficient privileges to update <object> <id>
402+
```
403+
404+
**An admin is not automatically exempt** — the gate is `canEdit`, driven by the
405+
sharing model, not a global admin bypass. If a hook must write a target, give the
406+
acting principal edit access to it (sharing rule / permission set), or drive the
407+
write from a system-context automation.
408+
409+
### Copy-paste example — afterUpdate + cross-object update + re-query + capabilities
410+
411+
When a `candidate` is marked `hired`, look up its `position` (a lookup FK that
412+
is **not** in the partial patch) and flip that position to `filled`:
413+
414+
<!-- os:check -->
415+
```typescript
416+
import type { Hook } from '@objectstack/spec/data';
417+
418+
const fillPositionOnHire: Hook = {
419+
name: 'fill_position_on_hire',
420+
object: 'candidate',
421+
events: ['afterUpdate'],
422+
body: {
423+
language: 'js',
424+
source: `
425+
// afterUpdate → ctx.result is the PARTIAL patch. Gate on the field this
426+
// write actually set, then re-query for the lookup FK it does NOT carry.
427+
if (!ctx.result || ctx.result.stage !== 'hired') return;
428+
const rec = await ctx.api.object('candidate').findOne({ where: { id: ctx.result.id } });
429+
if (!rec || !rec.position_id) return;
430+
// Cross-object write — needs the acting user to be able to edit 'position'.
431+
await ctx.api.object('position').update({ id: rec.position_id, status: 'filled' });
432+
ctx.log.info('position filled', { position: rec.position_id });
433+
`,
434+
capabilities: ['api.read', 'api.write', 'log'],
435+
},
436+
onError: 'log',
437+
};
438+
439+
export default fillPositionOnHire;
440+
```
441+
442+
Register it like any hook — add it to `defineStack({ hooks: [fillPositionOnHire] })`
443+
(the `AppPlugin` binds `body` hooks onto the engine automatically).
444+
445+
### Troubleshooting (`[BodyRunner]` log lines)
446+
447+
- **`[BodyRunner] invalid hook.body shape`** *(warn)*`body` failed
448+
`HookBodySchema`; the hook is skipped. Check `language` / `source` /
449+
`capabilities`.
450+
- **`[BodyRunner] sandboxed hook threw`** *(error)* — the body raised. The
451+
wrapped message names the hook; the usual causes are a missing capability, a
452+
`FORBIDDEN` cross-object write (gotcha 2), or a `ReferenceError` from a free
453+
identifier or an unavailable global (e.g. `console`).
454+
- **`ctx.log.*` produced no output** — two independent causes: (1) without the
455+
`log` capability the call **throws** (surfaces as a hook error); (2) **with** the
456+
capability it emits only when the runtime wired a logger into the hook context —
457+
otherwise it is a **silent no-op**. Treat `ctx.log` as best-effort diagnostics,
458+
not a reliable side-channel or proof a hook ran; to observe an effect, assert on
459+
the data it writes. (And call `ctx.log.info(msg)``ctx.log` is an object, not
460+
a function, so `ctx.log(msg)` is not callable.)
461+
462+
---
463+
237464
## Hook Context API
238465

466+
> **Sandbox vs in-process.** The `ctx` documented below is the **in-process
467+
> `handler`** context (full Node). A metadata-native **`body`** sees a
468+
> capability-gated *subset* of it inside an isolated VM — see
469+
> [Sandboxed Hook Bodies](#sandboxed-hook-bodies-body--what-the-sandbox-ctx-can-call)
470+
> for exactly what is and isn't available there, including the `where` query
471+
> shape and the `update({ id, … })` pattern.
472+
239473
The `HookContext` passed to your handler provides:
240474

241475
### Context Properties
@@ -402,16 +636,20 @@ ctx.previous: {
402636

403637
### Cross-Object API
404638

405-
Access other objects within the same transaction:
639+
Access other objects within the same transaction. `ctx.api.object(name)` is the
640+
same repository in both forms; the canonical query key is **`where`** (see
641+
[Sandboxed Hook Bodies](#sandboxed-hook-bodies-body--what-the-sandbox-ctx-can-call)
642+
for the full method + capability contract). In a sandboxed `body` these calls
643+
additionally require the `api.read` / `api.write` capabilities.
406644

407645
```typescript
408646
handler: async (ctx: HookContext) => {
409647
// Get API for another object
410648
const users = ctx.api?.object('user');
411649

412-
// Query users
650+
// Query users — `where` is canonical (`filter` is a tolerated object alias)
413651
const admin = await users.findOne({
414-
filter: { role: 'admin' }
652+
where: { role: 'admin' }
415653
});
416654

417655
// Create related record

skills/objectstack-data/rules/hooks.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,52 @@ const hook: Hook = {
4242
};
4343
```
4444

45+
### Logic: `body` (preferred) or `handler` (deprecated)
46+
47+
A hook's logic comes from **either** an inline `handler` function **or** a
48+
metadata-native `body`. Prefer **`body`** for new code — it is what a
49+
metadata-only runtime executes, and it ships as plain JSON inside the build
50+
artifact. `handler` (inline function) is deprecated; when both are present the
51+
runtime uses `body`.
52+
53+
```typescript
54+
// Sandboxed body: `source` is the function body, run in an isolated QuickJS VM.
55+
{
56+
name: 'fill_position_on_hire',
57+
object: 'candidate',
58+
events: ['afterUpdate'],
59+
body: {
60+
language: 'js', // 'js' (sandboxed) | 'expression' (pure CEL)
61+
source: `
62+
if (!ctx.result || ctx.result.stage !== 'hired') return;
63+
// afterUpdate ctx.result is PARTIAL — re-query for the lookup FK.
64+
const rec = await ctx.api.object('candidate').findOne({ where: { id: ctx.result.id } });
65+
if (rec && rec.position_id)
66+
await ctx.api.object('position').update({ id: rec.position_id, status: 'filled' });
67+
`,
68+
capabilities: ['api.read', 'api.write'], // declare every ctx API the body touches
69+
},
70+
}
71+
```
72+
73+
Sandbox essentials (full contract in
74+
[references/data-hooks.md → Sandboxed Hook Bodies](../references/data-hooks.md#sandboxed-hook-bodies-body--what-the-sandbox-ctx-can-call)):
75+
76+
- **`ctx`** exposes `input`, `previous` (`undefined` on insert → `!ctx.previous`
77+
detects *create*), `result` (⚠️ **partial** on afterUpdate — re-query for
78+
unwritten fields), `user`, `session`, `event`, `object`, `api`,
79+
`log` (`ctx.log.info(msg)`), `crypto` (`randomUUID`).
80+
- **`ctx.api.object(n)`** repo: `find` / `findOne` / `count` / `insert` /
81+
`update({ id, ...fields })` / `upsert` / `delete`. Query key is **`where`**
82+
(object + `$`-operators) — **not** `filter: [[…]]`.
83+
- **`capabilities`** (declare what the body uses, else it throws) — the six legal
84+
tokens: `api.read`, `api.write`, `api.transaction`, `crypto.uuid`,
85+
`crypto.hash`, `log`.
86+
- Cross-object writes obey the **target's** sharing model — a `public_read`
87+
target rejects the write with `FORBIDDEN`, and **admin is not exempt**.
88+
- No `console` (use `ctx.log`), no `fetch` (use Connectors), no `import` /
89+
`require` / module-scope helpers — a `body` must be self-contained.
90+
4591
### 8 Lifecycle Events
4692

4793
| Event | When Fires | Use Case |

0 commit comments

Comments
 (0)