Skip to content

Latest commit

 

History

History
348 lines (262 loc) · 22.4 KB

File metadata and controls

348 lines (262 loc) · 22.4 KB
title Hook & Action Bodies (L1 / L2)
description How hook handlers and script-action bodies travel through ObjectStack as pure metadata, and the spec they must conform to.

Hook & Action Bodies

ObjectStack treats every hook handler and every type: 'script' action as pure metadata. In the self-contained (body-only) form there is no separate .mjs file shipped alongside the project artifact, no dynamic import() at runtime, and no filesystem dependency on the cloud — though today a legacy objectstack-runtime.{hash}.mjs back-compat bundle can still ship, and does get dynamically imported at boot, for any handler that hasn't been lowered to a metadata body yet (see Migration below). A body is either:

  • L1 — Expression    a formula-engine string, side-effect-free.
  • L2 — Sandboxed JS    a JavaScript source string executed inside an isolated VM with declared capabilities.

A third "compiled module" form (L3) was considered and explicitly disabled — it broke the cloud-parity guarantee that every artifact is a single self-contained JSON.

TL;DR

// Authoring (TS source — packages/myapp/objectstack.config.ts)
export default defineStack({
  hooks: [
    {
      name: 'normalize_account',
      object: 'account',
      events: ['beforeInsert'],
      handler: async (ctx) => {
        if (ctx.input.website) {
          ctx.input.website = ctx.input.website.toLowerCase();
        }
      },
    },
  ],
});
// Build artifact (excerpt from dist/objectstack.json's "hooks" array — what objectos actually loads)
{
  "name": "normalize_account",
  "object": "account",
  "events": ["beforeInsert"],
  "body": {
    "language": "js",
    "source": "if (ctx.input.website) ctx.input.website = ctx.input.website.toLowerCase();",
    "capabilities": []
  }
}

The CLI builder stringifies the inline handler, runs a regex allow-list over the source, and emits the metadata above. No runtimeModule, no bundle.functions[normalize_account] — the artifact is self-contained.

Why metadata-only?

Constraint Implication
Cloud parity. objectos in production receives projects through the cloud-artifact-api. The transport must be a single JSON.
Edge runtime support. objectos must run on Cloudflare Workers, Vercel Edge, Deno Deploy. No native modules, no Node-only filesystem APIs in the execution path.
Hot-reloadable. Studio in-browser editor must save handler edits and have them take effect on next request. Bodies must be data, not code that requires a build step.
Audit & multi-tenancy. Every body should be inspectable, sandboxable, and scoped per tenant. Bodies travel through the same RBAC pipe as data.

This is the same trade-off ServiceNow made (Business Rules), Salesforce made (Formulas + Apex Triggers stored as metadata), Retool made (transformer JS strings), and Airtable made (Scripting blocks). It's the standard low-code shape.

L1 — Expression bodies

Pure formula. No IO, no mutation. This is the body.language: 'expression' shape, used for:

  • Action body for trivial computed values
  • Validation rules
{ "language": "expression", "source": "input.amount > 1000 && input.status == 'open'" }

Hook condition is a separate field with a different envelope — a bare CEL string, or { dialect: 'cel', source } (ExpressionInputSchema in packages/spec/src/shared/expression.zod.ts), not a { language, source } body. e.g. condition: 'record.status == "open" && record.amount > 1000'.

Both forms are evaluated by the same formula engine that powers field formulas — see Formula Reference.

L2 — Sandboxed JS bodies

A JavaScript function body (not a full module) executed inside QuickJS.

{
  "language": "js",
  "source": "const total = await ctx.api.object('opportunity').count({ account_id: ctx.input.id }); ctx.input.opportunity_count = total;",
  "capabilities": ["api.read"],
  "timeoutMs": 250,
  "memoryMb": 32
}

Sandbox surface

The script sees only what the surrounding ctx object exposes:

Field Description Capability required
ctx.input Mutable record being inserted/updated/etc. none
ctx.previous Pre-update record (update events only). none
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.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)
**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 table, typed on `ScriptContext` and *auto-inferred by the build-time extractor* — but the sandbox never installed the function. Every call the token authorised threw inside the VM, while `os build` reported success precisely because writing `ctx.crypto.hash(...)` is what made the CLI grant the capability. All four declarations were removed together in #4391: declaring `crypto.hash` is now a parse error that explains this, and writing the call no longer earns a capability.

If you declared it: delete the token from capabilities and delete the ctx.crypto.hash(...) call — the call has never returned a value, so nothing that works today depends on it. os migrate meta --from 16 strips the token for you; the dead call is yours to remove. Hash in the host instead (a Connector recipe, or an engine-side hook). Hashing inside the sandbox comes back only with an implementation, via the capability admission process — the declaration follows the implementation, it never leads it.

What the sandbox forbids

The CLI builder rejects any source that uses:

  • import / require / dynamic import()
  • fetch
  • process, globalThis
  • eval, new Function
  • references to identifiers from value-only top-level imports

Need outbound HTTP? Define a Connector recipe as metadata and call it via ctx.connector(...). (Connector spec is tracked separately and ships after L1+L2 stabilises.)

Write-set checking

Static validation around a hook is asymmetric, and it is worth knowing exactly where the line is:

  • Checked — read side. hook.condition is validated at build time against the target object's fields by the expression validator (@objectstack/lint), including array-valued hook.object targets. A condition referencing a nonexistent field fails the lint.
  • Checked — capability side. body.capabilities gates which ctx APIs the body may call at all; the sandbox throws on an undeclared call.
  • Checked — write side, advisory and literal-only. Since #4271, body.source is parsed (never executed, never type-checked) and the field names it writes are resolved against the target object's declarations. An unknown field raises hook-body-write-unknown-field — a warning carrying a did-you-mean suggestion, which never blocks a build. Action bodies get the same check on their ctx.api writes (action-body-write-unknown-field). Both run under os validate, os lint and os compile.
  • Checked — writes that reach nothing at all. Since #4345, an action body assigning to ctx.record raises action-record-write-discarded, also a warning. This one is not a field-resolution question: an action's ctx.record is a snapshot the runtime never writes back, so the assignment is discarded whether or not the field is declared — see Signature conventions below.

Four literal write shapes are recognized, and only these:

Write shape Hook body Action body
ctx.input.<field> = … / ctx.input['<field>'] ⟨op⟩= … (including +=, ??=, …) checked not checked — an action's ctx.input is its params bag, not a record
Object.assign(ctx.input, { <field>: … }) checked not checked — same surface
ctx.api.object('<literal>').insert|create|update({ <field>: … }), .updateById(id, { <field>: … }) checked checked
ctx.record.<field> = … / ctx.record['<field>'] ⟨op⟩= … n/a — a hook context has no ctx.record (the expression throws) checked: warns as discarded, declared field or not

A missing warning is not a clean bill of health. The rule bails silently on everything it cannot resolve statically, deliberately preferring a missed finding to a false one — a false positive kills an advisory lint, while a miss just leaves the gap open a little longer:

  • computed keys (ctx.input[k] = …), spreads, and non-literal payloads;
  • dynamic object names (ctx.api.object(name));
  • ctx.input writes in a wildcard (object: '*') hook — there is no single target to resolve against;
  • multi-target hooks where the field exists on some target: a body may legitimately branch per object, so only a field missing on every named target is flagged;
  • objects declared by another package;
  • aliased input (const doc = ctx.input; doc.x = 1) — v1 does no data-flow analysis;
  • ctx.record writes in a body that hands ctx.record to anything — an argument, an assignment RHS, a spread, a return. Mutating the snapshot and then persisting it (ctx.record.stage = 'won'; await ctx.api.object('crm_deal').update(ctx.record)) is a live payload, so the whole body's record writes are skipped rather than guessed at. Truthiness and type guards (ctx.record && ctx.record.id, if (!ctx.record) …) are not escapes — they cannot persist anything.

System/audit columns and the flat-input envelope keys (id, options, ast, data) are never flagged.

A structured writes declaration was considered and dropped (#3700, closed as not planned) — but the gap it left was closed from the other end, by parsing the write set out of the source. The practical consequence of that route: coverage is bounded by what a parser can see, not by what an author remembered to declare.

What still happens at runtime

An unknown field is not caught at runtime, and it does not fail quietly either. The write-path validator walks the object's declared fields, so an undeclared key is neither rejected nor stripped, and the sandbox's mutations are copied back onto the payload verbatim. What happens next is the driver's call:

  • SQL drivers put the stray column into the statement, so the whole write fails with a driver-level error (table deal has no column named stagee) — nothing is stored, and the error surfaces far from the authoring mistake.
  • Schemaless drivers (memory, MongoDB) silently persist the stray key alongside the real ones.

Neither outcome is the one you wanted, and the advisory warning is the earliest signal you get.

Because the checking is advisory and literal-only:

  • Treat hook-body-write-unknown-field as a build failure by convention. It does not gate, but the rule is tuned for near-zero false positives — in practice a warning is a real typo.
  • Check by hand what the parser cannot see. Computed keys, spreads, aliased input and dynamic object names are invisible to the rule; for an array or "*" hook, every field must exist on every target.
  • Prefer a flow update_record node when the write set is fixed — and for this check most of all. A flow node's writes are structured config: they diff field-by-field, render in the Console designer, and a write to a readonly:true field is a gating error (flow-update-readonly-field) that hooks have no counterpart for. Since #4271 the field-existence check gates there too — flow-node-write-unknown-field is an error, not the advisory warning a body gets, because a node's fields is a literal map next to a literal objectName: there is no parser in between that could have mis-extracted it, so a finding is a certainty rather than a best effort.
  • Exercise the hook against a real object before shipping — on SQL drivers the mistake surfaces on the first write; schemaless drivers won't tell you.

Signature conventions

Surface TS authoring Sandbox invocation
Hook (ctx: HookContext) => Promise<void> (ctx) => Promise<void>
Action (input: I, ctx: ActionContext) => Promise<O> (input, ctx) => Promise<O>

Hooks mutate ctx.input/ctx.result; actions return their output value explicitly.

An action's ctx is not a hook's. ctx.input is the action's params bag — validated against its declared params, not a record. ctx.record is the record the dispatcher pre-fetched, and it is read-only in effect: the sandbox receives a plain snapshot and the runtime never writes it back, so ctx.record.<field> = … is discarded even for a perfectly valid field name. There is exactly one way an action body persists anything:

await ctx.api.object('crm_deal').updateById(ctx.recordId, { stage: 'won' });

Mutating the snapshot as a payload and then handing it to such a call is fine — that write is live, and the lint leaves it alone.

Engine

The sandbox engine is quickjs-emscripten — pure-WASM, runs on every JS host. We considered isolated-vm but its native dependency disqualifies edge targets. The choice is hidden behind the ScriptRunner interface in packages/runtime/src/sandbox/, so a node-only deployment can swap in a faster engine later without touching call sites.

Per-invocation budgets default to 250ms (hooks) / 5000ms (actions) of script CPU time — VM-active time, not wall clock (ADR-0102): time spent awaiting host calls, or running a nested hook, is not charged. A separate 30s wall-clock ceiling backstops a body stuck on a host call that never settles. Per-invocation memory caps at 32 MB. All are overridable per body and deployment-wide via OS_SANDBOX_HOOK_TIMEOUT_MS / OS_SANDBOX_ACTION_TIMEOUT_MS / OS_SANDBOX_WALL_CEILING_MS.

Nested cross-object writes

A body may write other objects — e.g. await ctx.api.object('parent').update({ ... }) from a child's afterInsert/afterUpdate (requires api.write). The target's own hooks fire too: the nested write runs in a fresh sandbox VM while the calling body is suspended, and this composes to any depth. This is the natural "when a child changes, roll the total up to the parent" automation — it does not need a denormalized, hand-maintained mirror field. Because each body's budget is CPU time (ADR-0102), the caller is not charged for the nested write's own run — so the stock 250ms default comfortably covers deep rollup chains, and you rarely need to raise timeoutMs (the spec still permits up to 30_000ms for a genuinely CPU-heavy body).

Errors from ctx.api

A rejected ctx.api call gives your body the host error's name and message, plus two structured properties when the host supplied them:

Property Meaning
e.code The semantic code, e.g. 'VALIDATION_FAILED'
e.fields Per-field validation envelopes — { field, code, message }[]
try {
  await ctx.api.object('invoice').update({ id: input.id, status: 'sent' });
} catch (e) {
  if (e.code === 'VALIDATION_FAILED') {
    // e.fields → [{ field: 'issued_on', code: 'required', message: 'issued_on is required' }]
    throw e;   // re-throwing keeps the payload; see below
  }
  throw e;
}

Nothing else crosses into the VM. That is a deliberate allowlist, not an oversight: host errors routinely carry driver state, connection details or whole record payloads, and anything reachable on a rejection is readable by body code.

An error your body lets escape — or re-throws — keeps code and fields on the way back out too, so an action's HTTP response can carry them (data.code / data.fields) and a form can highlight the offending input rather than only raising a toast. Errors you construct yourself are treated the same way: set e.code before throwing and it reaches the caller.

L3 — Compiled modules (intentionally disabled)

An earlier design allowed the CLI to emit a sibling objectstack-runtime.<hash>.mjs that objectos would import() at runtime. We removed that path because:

  1. It meant cloud-deployed objectos had to download a JS module out-of-band, adding another transport, another cache, another vector for tenant cross-talk.
  2. It bypassed the sandbox entirely — a misbehaving module could call any Node API on the host.
  3. It made hot-reload from Studio impossible; you cannot edit a baked .mjs from a browser.

If you have a body that genuinely cannot be expressed in L1+L2 (typically: it needs an npm package's behaviour), the right escape hatch is a plugin — install it on the host, register a service via DI, and call it from L2 with a capability-gated proxy. Bodies stay metadata; the heavy lifting moves to a place where it's auditable and shared.

Build pipeline

objectstack build (an alias for objectstack compile) loads your defineStack({...}) config and lowers every inline hook/action handler. It does not glob *.hook.ts / *.action.ts source files off disk — the body source comes from the live function objects in the loaded config:

  1. Load the defineStack config and normalise its shape.
  2. For each inline handler, take its source via String(fn) (the callable is already loaded by tsx/esbuild).
  3. Run a regex allow-list over the stringified body (see "What the sandbox forbids" above).
  4. Pass: emit body: { language: 'js', source: <body>, capabilities: <inferred> }.
  5. Forbidden token (default): extraction fails, a bodyExtractionWarning is recorded, and the callable still ships via the back-compat handler-ref bundle — the build does not abort. Pass objectstack compile --strict-body to turn extraction warnings into a hard build failure (exit 1) with a per-callable diagnostic, e.g. hook 'normalize_account': fetch() is not allowed in hook/action bodies — declare a Connector recipe instead.

Capabilities are inferred by matching known patterns in the body source (e.g. ctx.api.object(...).insert(...)api.write). A fuller AST-based analysis is planned for a later version. You can override with a directive comment when the inference is wrong.

Migration

If you have an existing project that uses the old handler: 'function_name' + bundle.functions[name] shape, both forms are accepted during the transition:

Phase Status
Phase 1 (now) Both handler and body accepted. Loader prefers body.
Phase 2 Build emits a deprecation warning when handler is present without body.
Phase 3 handler removed. body becomes the only accepted form.

The CLI extractor handles the conversion automatically — you don't need to rewrite TS source files. Run objectstack build and your project artifact is in the new shape.

Bundle format

The compiled artifact dist/objectstack.json carries every hook and type='script' action body inline. The shape is identical for both:

{
  "hooks": [
    {
      "name": "account_protection",
      "object": "account",
      "events": ["beforeInsert", "beforeUpdate"],
      "priority": 200,
      "handler": "account_protection",
      "body": {
        "language": "js",
        "source": "const { event, input } = ctx; if (event === 'beforeInsert' || event === 'beforeUpdate') { … }",
        "capabilities": ["api.read"]
      }
    }
  ],
  "actions": [
    {
      "name": "send_quote",
      "type": "script",
      "target": "global_send_quote",
      "body": {
        "language": "js",
        "source": "await ctx.api.object('quote').update(input.id, { sent_at: new Date().toISOString() }); return { ok: true };",
        "capabilities": ["api.write"]
      }
    }
  ]
}

handler / target strings still refer to entries in the sibling objectstack-runtime.{hash}.mjs bundle. That bundle is only used as a back-compat fallback for runtimes that haven't yet enabled the QuickJS interpreter — once the deprecation phase ends (Phase 3 above) the bundle disappears entirely and the artifact becomes a single self-contained JSON file. This is the cloud-deployable shape: cloud-artifact-api ships only the JSON; the QuickJS runtime in @objectstack/runtime rehydrates every body inside its sandbox at boot.

Capability inference

The extractor scans each body for known patterns and adds the matching capability tokens to body.capabilities:

Pattern in source Inferred capability
*.object(…).find / findOne / count / aggregate / get / list api.read
*.object(…).insert / update / upsert / delete / patch / remove / create api.write
ctx.crypto.randomUUID crypto.uuid
ctx.log.info / warn / error / debug log

To override the inference, add a directive comment as the first line of your handler body:

handler: async (ctx) => {
  // @capabilities api.read api.write log
  
}

Build pipeline at a glance

objectstack.config.ts
  └── defineStack({...})              ← functions live in JS
        └── normalizeStackInput()     ← shape normalisation only
              └── lowerCallables()    ← extracts body + builds fn map
                    ├── body:{...}    ← shipped in dist/objectstack.json
                    └── handler:"ref" ← bundled into objectstack-runtime.{hash}.mjs
                          └── ObjectStackDefinitionSchema.safeParse()
                                └── writeFile(dist/objectstack.json)

See also

  • Formula Reference — the L1 expression engine.
  • Hooks and Flows — broader patterns for hooks, actions, flows.
  • Cloud Deployment — how artifacts travel from Studio to objectos.
  • packages/spec/src/data/hook-body.zod.ts — canonical Zod schema.
  • packages/runtime/src/sandbox/script-runner.ts — engine decision rationale.
  • packages/cli/src/utils/extract-hook-body.ts — extractor + capability inference.