ObjectStackObjectStack

Hook schema — Data Protocol property reference

Hook schemas of the ObjectStack Data Protocol: Hook, HookContext, HookEvent — each property with its type, default and a TypeScript example.

Source: packages/spec/src/data/hook.zod.ts

TypeScript Usage

import { HookSchema, HookContextSchema, HookEvent } from '@objectstack/spec/data';
import type { Hook, HookContext } from '@objectstack/spec/data';

// Validate data
const result = HookSchema.parse(data);

Hook

Properties

PropertyTypeRequiredDescription
namestring✅Hook unique name (snake_case)
labelstringoptionalDescription of what this hook does
objectstring | string[]✅Target object(s)
eventsEnum<'beforeFind' | 'afterFind' | 'beforeInsert' | 'afterInsert' | 'beforeUpdate' | 'afterUpdate' | 'beforeDelete' | 'afterDelete'>[]✅Lifecycle events
handlerstringoptionalHandler function name (string, post-build) or inline function (pre-build) — DEPRECATED, prefer body
body{ language: 'expression'; source: string } | { language: 'js'; source: string; capabilities?: Enum<'api.read' | 'api.write' | 'api.transaction' | 'crypto.uuid' | 'log'>[]; timeoutMs?: integer; … }optionalHook body — expression (L1) or sandboxed JS (L2)
prioritynumberoptional (default: 100)Execution priority
asyncbooleanoptional (default: false)Run specifically as fire-and-forget
conditionstring | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalPredicate (CEL); hook runs only when TRUE. e.g. Precord.status == "closed" && record.amount > 1000
descriptionstringoptionalHuman-readable description of what this hook does
retryPolicy{ maxRetries?: number; backoffMs?: number }optionalRetry policy for failed hook executions
timeoutMsnumberoptionalMaximum execution time in milliseconds before the hook is aborted
timeoutneveroptional[REMOVED] hook.timeout was removed in @objectstack/spec 17 — its unit (milliseconds) lived only in the description, beside a body-level timeoutMs and a retryPolicy.backoffMs that spell theirs, so the same number read as two conventions on one surface. Rename the key to timeoutMs; the value (milliseconds) is unchanged. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
onErrorEnum<'abort' | 'log'>optional (default: "abort")Error handling strategy. abort: refuse the operation; a blocking hook's abort rolls the write back inside a unit of work, which the write door opens for an after* hook on an object on the default datasource whose driver declares transactions (elsewhere the caller is refused and the row stays stored). log: log the error and continue
runAsEnum<'system' | 'user' | 'inherit'>optional (default: "inherit")Execution identity for the hook's ctx.api data operations: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting), inherit = the context of the write that fired the hook (the pre-runAs behaviour; the default). A hook with no trigger user has no identity to scope to, so under user its ctx.api data operations are REFUSED — declare system to make the elevation explicit. This covers any hook fired by a write that carried no user (an isSystem plugin/service write; a system-elevated flow node). Scope: ctx.api only — condition evaluation, the readonly strip on ctx.input, ctx.session and async are unchanged.
_lockEnum<'none' | 'no-overlay' | 'no-delete' | 'full'>optionalItem-level lock — controls overlay & delete (ADR-0010).
_lockReasonstringoptionalHuman-readable reason shown when a write is refused by _lock.
_lockSourceEnum<'artifact' | 'package' | 'env-forced'>optionalLayer that set _lock (artifact | package | env-forced).
_provenanceEnum<'package' | 'org' | 'env-forced'>optionalOrigin of the item (package | org | env-forced).
_packageIdstringoptionalOwning package machine id.
_packageVersionstringoptionalOwning package version.
_lockDocsUrlstringoptionalOptional documentation link surfaced next to _lockReason.

Nested Shape: Hook.body[language='expression']

L1 expression body — pure formula, no IO

PropertyTypeRequiredDescription
language'expression'✅
sourcestring✅Formula expression source

Nested Shape: Hook.body[language='js']

L2 sandboxed JS body — runs inside an isolated VM with declared capabilities

PropertyTypeRequiredDescription
language'js'✅
sourcestring✅Function body source
capabilitiesEnum<'api.read' | 'api.write' | 'api.transaction' | 'crypto.uuid' | 'log'>[]optional (default: [])Granted capability tokens
timeoutMsintegeroptionalPer-invocation timeout (ms)
memoryMbintegeroptionalPer-invocation memory cap (MB)

Nested Shape: Hook.retryPolicy

PropertyTypeRequiredDescription
maxRetriesnumberoptional (default: 3)Maximum retry attempts on failure
backoffMsnumberoptional (default: 1000)Backoff delay between retries in milliseconds

HookContext

Properties

PropertyTypeRequiredDescription
idstringoptionalUnique execution ID for tracing
objectstring✅
eventEnum<'beforeFind' | 'afterFind' | 'beforeInsert' | 'afterInsert' | 'beforeUpdate' | 'afterUpdate' | 'beforeDelete' | 'afterDelete'>✅
inputRecord<string, any>✅Mutable input parameters
resultanyoptionalOperation result (After hooks only)
previousRecord<string, any>optionalRecord state before operation
submittedRecord<string, any>optionalWhat the caller submitted, as sent (update only) — diagnostics only, never the persist image
dispatch{ mode: Enum<'record' | 'per-row'>; index: integer; scope: Record<string, any> }optionalHow this hook call relates to the caller's write (engine-produced)
session{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }optionalCurrent session context
provenance{ flowRunId?: string; attributedUserId?: string; performedByClientId?: string }optionalServer-stamped write provenance (never client-supplied, never an authorization input)
referentialFieldClearbooleanoptionalEngine-produced marker: true exactly when this write is the engine's own referential cleanup (the set_null cascade UPDATE clearing — or, for multiple: true, removing the deleted member from — a lookup that references a record being deleted). Absent on every other write, including a user hand-clearing the same lookup; read as ctx.referentialFieldClear === true. The declared projection of the operation-private __referentialFieldClear, carried across the sandbox boundary by contract; server-derived and never client-supplied.
referentialCascadeDeletebooleanoptionalEngine-produced marker: true exactly when this delete is one the engine's cascade issued (the child delete removing a row that references a record being deleted through a relation whose deleteBehavior resolves to cascade, at any depth), in both phases of that delete. Absent on every other delete, including a user hand-deleting the same child and a delete a hook issues through ctx.api; read as ctx.referentialCascadeDelete === true. The delete twin of referentialFieldClear, carried across the sandbox boundary by contract; server-derived, never client-supplied, and with no operation-private spelling to read instead.
transactionanyoptionalDatabase transaction handle
qlany✅ObjectQL Engine Reference
apianyoptionalCross-object data access (IScopedContext — object(name) + transaction(cb))
user{ id?: string; name?: string; email?: string; organizationId?: string }optionalCurrent user info shortcut

Nested Shape: HookContext.dispatch

PropertyTypeRequiredDescription
modeEnum<'record' | 'per-row'>✅'record' = this call is the caller's whole write; 'per-row' = one of N dispatches for one write
indexinteger✅0-based position in the per-row fan-out; always 0 when mode is "record"
scopeRecord<string, any>✅Scratch shared by every dispatch of one caller write, across both phases (same object identity)

Nested Shape: HookContext.session

PropertyTypeRequiredDescription
userIdstringoptional
actorstringoptionalService-principal label for audit attribution when the caller is not a real user (e.g. svc:flow:<flowName>)
organizationIdstringoptionalActive organization ID (blessed developer-facing name)
accessTokenstringoptional
isSystembooleanoptionalTrue when the call was made with an elevated system context (engine self-writes)
skipTriggersbooleanoptionalTrue when record-change automation (flow triggers) must be suppressed for this write — e.g. package seed replay. Lifecycle hooks still run.
skipAutomationsbooleanoptionalTrue when metadata-bound automation hooks must be suppressed for this write — e.g. data import with "run automations" unchecked, or import undo. Implies skipTriggers; code-registered system hooks (audit, security) still run.
positionsstring[]optionalPosition names held by the caller (ADR-0090 D3; formerly roles), copied from ExecutionContext.positions. For hook READS only — e.g. tailoring a message, or branching a business rule the hook runs through its own ctx.api channel. Authorization is decided by the security service on the ExecutionContext (permissions / positions / derived posture); this is NOT an authorization input and a hook must not gate a write by testing it. A hook context carries no services key, so the sharing service cannot be called from one either — the sharing gates already ran inside the engine before the hook chain.
preserveAuditbooleanoptionalTrue when this write is a historical import that must KEEP its caller-supplied updated_at/updated_by (and the readonly audit family) instead of being stamped with the import instant. Server-set, opt-in, absent on normal writes; read by the built-in audit hook. A stamping policy, not an authorization input.
rolesneveroptional[REMOVED] HookContext.session.roles was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — it was declared, read by two dead exemption branches, and never produced: ObjectQL's buildSession() builds the session field by field and has never written roles, so every read resolved undefined and a guard keyed on it was dead code that merely LOOKED like an authorization decision. Delete the key. To gate a hook on the caller, read ctx.session.userId / ctx.session.isSystem; to judge PRIVILEGE, ask the security service, which evaluates the ADR-0095 vocabulary on the execution context — capability grants (permissions), placements (positions) and the derived posture — never a role-name string comparison (ADR-0090 D3 bans the role spelling outright). Nothing to migrate: a HookContext is built per operation by the engine and never stored, so no metadata source carries this key. NOTE an ACTION body's ctx.session is a different object and still carries its own roles array today; that surface is tracked separately and is not what this key was.

Nested Shape: HookContext.provenance

PropertyTypeRequiredDescription
flowRunIdstringoptionalId of the automation flow run performing this write, when it originates from a flow data node. Lets a hook recognize the run that OWNS state that run itself opened — the approvals record lock exempts the run holding the pending request.
attributedUserIdstringoptionalThe real human credited for a write whose authorization subject was the SYSTEM — e.g. the admin whose better-auth update-member-role call the identity adapter executes as isSystem. ATTRIBUTION ONLY: the audit writer records it as sys_audit_log.user_id; no security middleware reads it, and it never becomes the subject the write is authorized as.
performedByClientIdstringoptional[ADR-0090 D10 rule 4] The AGENT that performed this write — the OAuth client (azp, a registered sys_oauth_application id) the access token was issued to, when an AI client acted on behalf of the human in session.userId. Copied from ExecutionContext.performedBy.clientId, which only the /mcp OAuth door sets. ATTRIBUTION ONLY, exactly like attributedUserId: the audit writer records it in sys_audit_log.metadata as performed_by beside on_behalf_of; no security middleware reads it and it never becomes the subject the write is authorized as. Absent on every non-delegated write, so a hook tells the two apart by the key being there rather than by guesswork.

Nested Shape: HookContext.user

PropertyTypeRequiredDescription
idstringoptional
namestringoptional
emailstringoptional
organizationIdstringoptionalActive organization ID of the acting user (equals session.organizationId)

HookEvent

Allowed Values

  • beforeFind
  • afterFind
  • beforeInsert
  • afterInsert
  • beforeUpdate
  • afterUpdate
  • beforeDelete
  • afterDelete

On this page