import { HookSchema, HookContextSchema, HookEvent } from '@objectstack/spec/data';import type { Hook, HookContext } from '@objectstack/spec/data';// Validate dataconst result = HookSchema.parse(data);
Predicate (CEL); hook runs only when TRUE. e.g. Precord.status == "closed" && record.amount > 1000
description
string
optional
Human-readable description of what this hook does
retryPolicy
{ maxRetries?: number; backoffMs?: number }
optional
Retry policy for failed hook executions
timeoutMs
number
optional
Maximum execution time in milliseconds before the hook is aborted
timeout
never
optional
[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.
onError
Enum<'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
runAs
Enum<'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.
Server-stamped write provenance (never client-supplied, never an authorization input)
referentialFieldClear
boolean
optional
Engine-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.
referentialCascadeDelete
boolean
optional
Engine-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.
transaction
any
optional
Database transaction handle
ql
any
✅
ObjectQL Engine Reference
api
any
optional
Cross-object data access (IScopedContext — object(name) + transaction(cb))
Service-principal label for audit attribution when the caller is not a real user (e.g. svc:flow:<flowName>)
organizationId
string
optional
Active organization ID (blessed developer-facing name)
accessToken
string
optional
isSystem
boolean
optional
True when the call was made with an elevated system context (engine self-writes)
skipTriggers
boolean
optional
True when record-change automation (flow triggers) must be suppressed for this write — e.g. package seed replay. Lifecycle hooks still run.
skipAutomations
boolean
optional
True 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.
positions
string[]
optional
Position 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.
preserveAudit
boolean
optional
True 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.
roles
never
optional
[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.
Id 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.
attributedUserId
string
optional
The 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.
performedByClientId
string
optional
[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.