Automation Design — ObjectStack Automation Protocol
When to Use This Skill
- You are building a visual flow (auto-launched, screen, or scheduled).
- You need a state machine or approval process for a business object.
- You are setting up event-driven triggers (record create/update/delete).
- You need scheduled automation (daily reports, data cleanup).
Predicates and conditions are CEL — every condition / guard /
entryCondition / filter value here is an Expression envelope evaluated
by @objectstack/formula. A slot takes a plain CEL string; the
P\...`/cel`...`` tags wrap the same string with author-time validation.
Both parse — pick one per file (the example apps use plain strings). See
objectstack-formula for the CEL contract, stdlib and legacy → CEL table.
Flows — Visual Logic Orchestration
A Flow is a directed graph of nodes that execute sequentially or in
parallel. Flows are the primary automation building block in ObjectStack.
Flow Types
| Type |
When to Use |
autolaunched |
Runs without user interaction — triggered by events, APIs, or other flows |
screen |
Interactive — presents UI screens to the user (wizards, forms) |
schedule |
Runs on a cron/interval cadence declared on the start node's config.schedule (daily cleanup, weekly reports) — or a per-record date sweep via config.timeRelative, see Time-relative triggers |
record_change |
Fires automatically on record create/update/delete (bind via the start node's triggerType). autolaunched + the same record-* binding behaves identically — the engine reads the start node either way; record_change also opts into the trigger-readiness lint |
api |
Invoked explicitly via the API / engine.execute(), or bound as an inbound webhook: POST /api/v1/automation/hooks/:flowName/:hookId (see Inbound webhook triggers below) |
Flow Node Types
Flows are built from 20 built-in node types (the FlowNodeAction seed set —
plugins register more via registerNodeExecutor, e.g. approval below):
Control Flow
| Node |
Purpose |
start |
Entry point — every flow has exactly one |
end |
Exit point — can have multiple (early exit, error exit) |
decision |
Conditional branching — routed by edge condition predicates, not node config (see the approval example below) |
loop |
Iterate a nested config.body region once per item of config.collection; iteratorVariable (default item) and optional indexVariable bind inside it, maxIterations caps it |
parallel |
Fan out into config.branches[] (≥ 2 regions) run concurrently, joined implicitly at block end — no split/join pair to mis-wire |
try_catch |
Run config.try; on failure run config.catch with the error in errorVariable (default $error); config.retry re-runs try with backoff first. No finally — the container's ordinary out-edges are the continuation |
map |
Sequential multi-instance — invoke a subflow once per item of a collection; each iteration may pause (batch approvals) |
wait |
Pause execution until a timer elapses or a named signal arrives |
subflow |
Invoke another flow (reusable composition) |
parallel_gateway / join_gateway / boundary_event |
Not author-facing — BPMN-interop forms the mapper lowers a parallel / try_catch container INTO (automation/control-flow.zod.ts). Author the container |
Data Operations
| Node |
Purpose |
assignment |
Set variable values |
create_record |
Insert a new record |
update_record |
Modify existing records |
delete_record |
Remove records |
get_record |
Fetch records with filters — there is no query_record node (that name has no executor and throws) |
External Integration
| Node |
Purpose |
http |
Call an external HTTP API — canonical since protocol 11.0; http_request survives only as a deprecation-window alias |
notify |
Send a notification through the messaging service (inbox channel by default) |
connector_action |
Invoke a pre-built integration connector |
script |
Call a registered function named by config.function (see Valid-but-silently-wrong #3) |
screen |
Display a UI form to the user (screen flows only) |
Human Decision
| Node |
Purpose |
approval |
Route a record for human sign-off — suspends the run until a decision, then continues down the approve / reject branch (contributed by plugin-approvals) |
notify — the most-used node type
NotifyConfigSchema (automation/io-node-config.zod.ts) is strictObject — an
undeclared key is a named parse error. RAW keys never interpolate: a
{token} in one is forwarded verbatim, never resolved.
{ id: 'tell_owner', type: 'notify', label: 'Notify Owner', config: {
recipients: '{record.assignee}', // REQUIRED — id, CSV, or string[]
title: 'Done: {record.title}', // inline path; XOR `template` (RAW, localizable)
message: 'Closed by {$User.Id}', // body; only with inline `title`
topic: 'task', // RAW; default 'notify'
severity: 'warning', // RAW; CLOSED enum info|warning|critical
channels: ['inbox'], // RAW; default inbox
sourceObject: 'task', // click-through: a PAIR, else dropped
sourceId: '{record.id}', // at execute time
actionUrl: 'https://…/tasks/123', // overrides the synthesized link
} }
Flow Variables
Every flow defines input/output variables. variables is an array of
{ name, type, isInput, isOutput } entries — not a name-keyed map, and there
is no label property on a variable:
variables: [
{
name: 'case_id',
type: 'text',
isInput: true, // passed in when flow is invoked
isOutput: false,
},
{
name: 'approval_result',
type: 'boolean',
isInput: false,
isOutput: true, // returned when flow completes
},
],
Flow Example — Auto-Escalate Overdue Cases
Nodes connect via edges, not a next property. The engine traverses
flow.edges ({ source, target }); a bare next: on a node is refused.
update_record selects rows with filter — an ObjectQL where map
of field → value / field → { $operator: value }, NOT the UI view-filter
[{ field, operator, value }] triples — and writes with fields
(a single call updates every matching row — no per-row loop needed).
label is required on the flow and on every node, and every path through
the graph must reach an end node.
import { defineFlow } from '@objectstack/spec';
export const EscalateOverdueCasesFlow = defineFlow({
name: 'escalate_overdue_cases',
label: 'Escalate Overdue Cases',
type: 'schedule',
status: 'active',
runAs: 'system', // a scheduled run has no trigger user — elevate explicitly
nodes: [
{
id: 'start',
type: 'start',
label: 'Daily at 09:00',
// The cadence lives HERE, on the start node's config — FlowSchema has NO
// top-level `schedule` key (one there is a named parse error, not a silent
// strip). A bare cron string also works: schedule: '0 9 * * *'. Do NOT use
// the cron`…` tagged template — its envelope is not a recognized shape.
config: { schedule: { type: 'cron', expression: '0 9 * * *' } },
},
{
id: 'escalate_overdue',
type: 'update_record',
label: 'Escalate Overdue Cases',
config: {
objectName: 'support_case',
// which rows to update — `filter` is a `where` map, not filter triples
filter: {
status: { $in: ['new', 'open'] },
due_date: { $lt: '{TODAY()}' }, // template token → today's date at run time
},
// what to write — `fields`, not `values`
fields: { status: 'escalated' },
},
},
{
id: 'notify_manager',
type: 'http',
label: 'Notify Manager',
config: {
url: 'https://hooks.slack.com/services/...',
method: 'POST',
body: { text: 'Escalated overdue support cases.' },
timeoutMs: 10000, // unset = NO timeout at all — always set one
},
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'escalate_overdue' },
{ id: 'e2', source: 'escalate_overdue', target: 'notify_manager' },
{ id: 'e3', source: 'notify_manager', target: 'end' },
],
});
Failure routing & runAs
Handling a failed node: a fault edge. { source, target, type: 'fault' }
routes a failed node to a handler instead of ending the run. type: 'fault'
is what routes — a label: 'error' alone does nothing: the edge stays
ordinary, and every unconditional out-edge traverses on SUCCESS, so the
handler would run when the node succeeds and never when it fails
(objectstack validate reports flow-error-label-not-fault).
A handled failure does NOT consume a flow-level errorHandling.retry, which
replays the flow from the start — prefer a fault edge when the failure is
local. The handler reads {<nodeId>.error} (or run-wide {$error}). The run
then reports success, and the failed step stays in the trace.
It is not a way past a guardrail.
| ROUTES (runtime failure) |
Does NOT route (fatal either way) |
| 404, rate-limit, rejected write, failed subflow |
missing required config key (objectName, url, flowName, connectorId/actionId); filter token that resolved to nothing; graph past the nesting ceiling; unscoped run |
Routing a guard refusal is worse than the failure: a dropped filter condition
widens the query, so a routed delete_record empties the object while the
run reports success. objectstack validate names the offending template.
Writing a readonly field? Set runAs: 'system'. readonly: true
governs the end-user surface: under the default runAs: 'user', the engine
strips a readonly field from any non-system write — create_record and
update_record alike — the step reports success but the value never lands,
and the drop is named in the step's warnings. A flow that
maintains a readonly field (approval stamps, conversion flags, SLA
markers, rollups) must run runAs: 'system', the trusted-writer channel.
os validate / os build fail a runAs:'user' update_record that writes
a readonly field, so the mismatch surfaces at build time, not as wrong data
days later. (readonlyWhen fields are the same story, per record state —
flagged as a warning.) Do not work around this by removing readonly;
that loses the field's edit protection.
Elevate the write, not the flow. A screen flow stays runAs: 'user'.
When one step in it must write a readonly field, move that step into a
dedicated runAs: 'system' flow and call it from a subflow node — raising
the whole flow silently elevates every other write in it.
A runAs: 'system' sweep must pin its organization. System context has no
trigger user, so nothing narrows the query: a scan or rollup with no
organization predicate reads and writes across every tenant. The tenant column
is platform-injected — filter on it, never re-declare it per object.
A hook elevates itself with runAs, never with sudo. An object hook
(objectstack-data) declares its own runAs: 'system' | 'user' | 'inherit' —
default 'inherit', the context of the write that fired it — scoping that
hook's ctx.api data operations only, on the in-process handler and the
sandboxed body alike. A 'user' hook whose trigger resolved no user has
nothing to scope to: its ctx.api data operations are refused
(HOOK_UNSCOPED_DATA_ACCESS, 403) rather than run unscoped — declare
runAs: 'system' when the elevation is intended. sudo is not a hook key.
Filter tokens (config.filter)
The one slot where two {…} dialects meet, and the one whose failure widens
a query instead of narrowing it.
- Precedence — flow variables win, placeholders pass through. The flow
template engine runs first. A whole-string token it resolves is a flow value;
one it does not resolve that IS a recognised filter placeholder
(
{current_user_id}, {current_year_start}) passes through verbatim for
the query engine to expand. So a flow variable named after a placeholder
shadows it. Only filter gets this hand-off — in title, message,
fields and url a bare {current_year_start} is a nonsense reference.
- Static checkability splits by position. A
{record.…} token inside a
filter naming an unknown field, or hopping a relation the start node does not
list in config.expand, is an ERROR at objectstack validate: it resolves
to nothing, the condition is DROPPED, and the node refuses to execute. The
same reference outside a filter (message body, http url, write payload)
only renders an empty string — a warning. A {var} naming a flow variable
or node output is not statically checkable at all.
Valid-but-silently-wrong (passes build, fails at runtime)
These are legal metadata that authors — AI especially — get wrong. Most are now
caught by objectstack build (a hard error, or an advisory warning), but write
them right the first time:
Flow node VALUE interpolation uses SINGLE braces. Value fields on a node's
config (fields, inputs, notify message/title, …) interpolate
{token}:
{var} / {record.title} — variable / record field
{record.tags.0} — array index (e.g. a multiple: true lookup, stored as an array)
{$User.Id} / {NOW()} / {TODAY() + 30} — current user / date macros
{round(x)} {floor(x)} {ceil(x)} {abs(x)} {min(a,b)} {max(a,b)} —
mirror the CEL stdlib 1:1. round is integer-only (no round(x, 2));
for N decimals write {round(x * 100) / 100} (scale 2)
- anything without
{…} is a literal
❌ body: '{{ai_reply}}' — double-brace is the formula / template-field dialect, not flow values
❌ ticket: '$source.id' — a bare $ref is a literal string, not interpolated
✅ body: '{ai_reply}', ticket: '{source.id}'
❌ '{ROUND(x, 2)}' / '{Math.round(x)}' / '{(x).toFixed(2)}' — any other
name in call position fails the node with a named error naming the
supported set. The build does not catch these (conditions are checked,
call-position names are not) and a fault edge cannot route it.
create_record's outputVariable holds the created RECORD, not its id.
Reference a field explicitly.
❌ update_record … fields: { ref: '{newRec}' } → yields the whole record object
✅ fields: { ref: '{newRec.id}' }
script nodes call a registered function — that is all they do. Set
config.function to a function registered via
defineStack({ functions: { my_fn: (ctx) => … } }). It is required: an
empty script node refuses at execute, and one pointing at an unregistered
function fails loudly.
There is no other dispatch form: use notify for delivery, a
connector_action or http webhook for Slack, a function for logic.
A flow function is a PURE compute step — it does NOT read/write the
database. It receives ctx.input and returns a value; config.outputVariable
exposes that value as a flow variable, and a later declarative node persists
it. Keep data effects on the flow graph (visible, governed, build-checkable):
// ❌ DON'T: expect the function to update the record itself (it has no data API)
// ✅ DO: function returns values → outputVariable → update_record persists
{ id: 'ai', type: 'script', config: {
function: 'helpdesk.aiTriageStub', // returns { ai_category, ai_sentiment, … }
inputs: { ticketId: '{record.id}' }, // inputs are interpolated
outputVariable: 'ai',
} },
{ id: 'apply', type: 'update_record', config: {
objectName: 'helpdesk_ticket',
filter: { id: '{record.id}' },
fields: { ai_category: '{ai.ai_category}', ai_sentiment: '{ai.ai_sentiment}' },
} },
defineStack({ functions: { 'helpdesk.aiTriageStub': (ctx) => ({ ai_category: 'other', … }) } }).
If you genuinely need data-lifecycle side effects (read/write other records),
that's an L2 hook (objectstack-data) — hooks get ctx.api; flow functions don't.
A function that writes where the platform cannot see declares it, so the
run reports "cannot say" rather than acted: 0:
defineStack({ functions: {
'helpdesk.aiTriageStub': (ctx) => ({ ai_category: 'other' }), // pure — the default
'billing.sync': { handler: syncBilling, effect: 'writes' }, // declared writer
} });
Conditions are bare CEL — the stdlib is what you may call bare. now(),
today(), daysFromNow(n), daysAgo(n), daysBetween(a, b), isBlank(v),
coalesce(a, b), abs/round/min/max, upper/lower/contains/matches, plus CEL
built-ins (has, size, int, string, …) — see objectstack-formula for that
table: it is CEL_STDLIB_FUNCTIONS, the bare-callable public subset, so receiver
methods (called on a value, never bare) are not in it.
An UNKNOWN function (PRIOR(), a typo'd name) and a {…}-wrapped field ref
both fail the build: a brace is a template, not CEL — write record.x,
not {record.x}.
notify reports SUCCESS when the messaging capability is absent. The
executor logs no messaging service registered and returns success with
output: { delivered: 0, failed: 0, skipped: true } and metrics.acted: 0 —
a green run that delivered nothing. Declare messaging in requires.
State Machines & Approvals
A record's state machine locks the legal transitions of its status field
so that automation — increasingly AI-generated — cannot drive a record into an
illegal state.
State Machine — a state_machine validation rule (ADR-0020)
Since ADR-0020 there is no workflow metadata type and no
object.stateMachines map. A record state machine is one state_machine
validation rule in the object's validations array: a flat field +
{ from: [allowedTo] } transition table. It is enforced on the write path —
an update whose field moves to a state not listed for the current state is
rejected with the rule's message. A from state mapped to [] is a declared
dead-end.
{
type: 'state_machine',
name: 'case_lifecycle',
label: 'Case Lifecycle',
field: 'status', // the field that holds the state
message: 'Invalid status transition.',
initialStates: ['new'], // states a record may be CREATED in
transitions: {
new: ['open'],
open: ['escalated', 'resolved'],
escalated: ['open', 'resolved'],
resolved: ['open', 'closed'],
closed: [], // final — no outgoing transitions
},
}
Notes:
- One rule per field. Parallel lifecycles (e.g.
status + payment_status)
are N separate state_machine rules, one per field.
initialStates (optional) gates INSERT: a record created with its
state field outside this list is rejected. transitions only governs
updates, so without it a record can be born mid-flow (e.g. created already
resolved). Omit to keep the legacy no-check-on-insert behavior.
- Conditional transitions / side effects are NOT part of the machine. A
guard is expressed as a sibling
script / conditional validation rule;
"do something when the state changes" is a record-triggered Flow
(ADR-0019) — a record_change flow whose start-node condition gates on the
transition, e.g. previous.status != 'escalated' && record.status == 'escalated'.
- Introspection:
GET /api/v1/meta/object/:name/state/:field?from=:state
returns the legal next states so UIs/agents can read the transition table
instead of hard-coding it (next: null = no FSM governs the field, or
?from= was omitted — always pass from).
- An unlisted
from state is NOT guarded. An update whose current state is
not a key of transitions is treated leniently (no lock) — list every state
you want guarded rather than relying on an implicit "any → any".
- Predicate conditions in sibling rules evaluate against the merged record in
the
record.<field> CEL scope (bare field names do not resolve).
Approvals (Flow Nodes)
Since ADR-0019 there is no standalone approval-process type. An approval is
authored as an Approval node (type: 'approval') on an ordinary flow — the
run suspends when it reaches the node and resumes down the node's
approve / reject out-edge once a decision is recorded. Multi-step review is
just successive Approval nodes wired together on the canvas, so the whole review
is one diagram a reviewer (or AI) can read end-to-end.
There is no approvals: [...] stack collection — approval flows live in your
normal flows: [...]. The approval state (sys_approval_request /
sys_approval_action, the record lock, the status mirror, approver
resolution) is owned by plugin-approvals.
// A record-triggered flow: high-value opportunities need manager sign-off,
// and director sign-off too when the amount clears 500k.
{
name: 'opportunity_discount_approval',
label: 'Opportunity Discount Approval',
type: 'record_change',
nodes: [
// Record-change flows bind via the START NODE's config — there is no
// separate top-level `trigger`. `triggerType` is one of
// `record-(before|after)-(create|update|delete)`; `condition` (bare CEL)
// gates whether the flow launches.
{
id: 'start',
type: 'start',
label: 'On Opportunity Update',
config: {
objectName: 'opportunity',
triggerType: 'record-after-update',
condition: cel`record.amount > 100000`,
},
},
{
id: 'manager_review',
type: 'approval',
label: 'Sales Manager Review',
config: {
approvers: [{ type: 'position', value: 'sales_manager' }],
behavior: 'first_response', // or 'unanimous' / 'quorum' / 'per_group'
lockRecord: true, // lock the record while pending
approvalStatusField: 'approval_status', // mirror pending|approved|rejected|recalled onto the row
},
},
// Decision routing lives on the OUT-EDGES, not in node config: the engine
// evaluates each out-edge's `condition` and follows every match — and an
// out-edge with NO condition ALWAYS runs (all such edges execute in
// PARALLEL). Guard every branch with a condition — see e4/e5 below.
{ id: 'needs_director', type: 'decision', label: 'Needs Director?' },
{
id: 'director_signoff',
type: 'approval',
label: 'Sales Director Sign-off',
config: {
approvers: [{ type: 'position', value: 'sales_director' }],
behavior: 'unanimous',
approvalStatusField: 'approval_status',
},
},
{ id: 'mark_won', type: 'update_record', label: 'Mark Won',
config: { objectName: 'opportunity', filter: { id: '{record.id}' }, fields: { stage: 'closed_won' } } },
{ id: 'approved', type: 'end', label: 'Approved' },
{ id: 'rejected', type: 'end', label: 'Rejected' },
],
edges: [
{ id: 'e1', source: 'start', target: 'manager_review',
// entry criteria re-homes onto the edge entering the approval node:
condition: cel`record.amount > 100000` },
{ id: 'e2', source: 'manager_review', target: 'needs_director', label: 'approve' },
{ id: 'e3', source: 'manager_review', target: 'rejected', label: 'reject' },
// Decision branches: mutually-exclusive edge `condition` predicates.
// Without them BOTH branches would execute (unguarded edges run in parallel).
{ id: 'e4', source: 'needs_director', target: 'director_signoff', label: 'true',
condition: cel`record.amount > 500000` },
{ id: 'e5', source: 'needs_director', target: 'mark_won', label: 'false',
condition: cel`record.amount <= 500000` },
{ id: 'e6', source: 'director_signoff', target: 'mark_won', label: 'approve' },
{ id: 'e7', source: 'director_signoff', target: 'rejected', label: 'reject' },
{ id: 'e8', source: 'mark_won', target: 'approved' },
],
}
Send-back for revision (ADR-0044)
Approval centers also model send back for revision (退回修改) — distinct from
reject (terminate) and from a comment thread (which keeps the request pending).
Send-back is a flow movement: the request finalizes as returned, the run
walks a revise out-edge to an approval_revise node (the revise
window) where the record unlocks and the submitter reworks it, and an explicit
resubmit re-enters the approval node over a declared back-edge, opening
round N+1 with a fresh approver slate.
approval ──approve──▶ …
──reject───▶ …
──revise───▶ approval_revise (record unlocked, submitter edits)
└──resubmit──[type:'back']──▶ approval (round N+1)
Three pieces author it:
revise out-edge — a third branch label alongside approve / reject,
targeting an approval_revise node. It must be that node type: the window
is a service-owned pause (resumeAuthority: 'service'), ended only by
POST /api/v1/approvals/requests/:id/resubmit; a wait is
resumeAuthority: 'any', so a raw run-resume would walk the back-edge
unchecked. The node takes no config — there is no signal to wait on.
type: 'back' resubmit edge — the edge from the revise window back into
the approval node MUST be typed 'back'. This is the only thing that
legalizes the cycle: registerFlow validates the graph minus back edges
as a DAG, so an unmarked cycle is rejected — you opt in, edge by edge. At
run time a back-edge traverses normally (it just re-enters the node).
maxRevisions on the approval config (default 3) — the budget of
send-backs per run; exceeding it auto-rejects (resumes down the reject
edge). maxRevisions: 0 disables send-back, so never pair 0 with a revise
edge.
{
id: 'manager_review', type: 'approval', label: 'Manager Review',
config: { approvers: [{ type: 'position', value: 'manager' }], lockRecord: true, maxRevisions: 2 },
},
// No config and no `waitEventConfig`: the window ends on the submitter's
// explicit resubmit, not on a signal or a timer.
{ id: 'wait_revision', type: 'approval_revise', label: 'Awaiting Revision' },
// …among the approval's edges…
{ id: 'rev', source: 'manager_review', target: 'wait_revision', label: 'revise' },
{ id: 'back', source: 'wait_revision', target: 'manager_review', label: 'resubmit', type: 'back' },
Three mistakes the compile-time flow lint flags: a revise edge into anything
but an approval_revise node (an error — sendBack refuses that metadata,
so the branch cannot run; flow-approval-revise-target-not-service-owned), a
revise edge whose window never loops back (a dead end registerFlow accepts
but that leaves the submitter nowhere to resubmit), and a resubmit edge left
without type: 'back' (an unmarked cycle registerFlow rejects). Resubmit
is an explicit verb (POST /api/v1/approvals/requests/:id/resubmit), never a
record-save. See the showcase_budget_approval flow in the showcase app in
the framework repo for the canonical shape.
Recording a decision
A decision is recorded through ApprovalService.decide() (or the REST routes
POST /api/v1/approvals/requests/:id/approve | /reject). That finalizes the
sys_approval_request and resumes the suspended run down the matching
branch — you never resume the flow by hand, and you cannot: the
approval node declares resumeAuthority: 'service', so
POST /api/v1/automation/:name/runs/:runId/resume answers 403 for a run
parked on one (including via a subflow pause) and changes nothing.
A decision may also carry structured outputs ({ outputs: { … } } in the
decide body) when the node declares the keys in decisionOutputs — the author
declares keys, approvers only fill values. Accepted outputs resume the run as
<nodeId>.<key> flow variables, so a LATER node reads them as
vars.<nodeId>.<key> — this is how "the previous approver picks the next
step's approvers" works without writing to a record field (see Dynamic
approvers below). A decision carrying an undeclared key is rejected;
decision / requestId are reserved. A declaration marked
required: true must carry a non-blank value to approve (never to
reject) — enforced before any write, with no elevation bypass, so the run
cannot resume past the node with the key a later expression approver reads
still missing.
Approver Types
type |
Resolves to |
user |
A specific user id (value = user id) |
position |
Holders of a position — value = the position machine name, resolved via sys_user_position (ADR-0090 D3) |
org_membership_level |
The org-membership tier — value is one of owner/admin/delegated_admin/member. NOT a position: { type: 'org_membership_level', value: 'sales_manager' } matches nobody; use position. Spelled role before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |
team |
Members of a flat sys_team |
department |
A department + all descendant departments |
manager |
The submitter's manager (sys_user.manager_id) |
field |
User id read from a record field (value = field name). Resolved against the record's live state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user |
queue |
⛔ Declared but never resolved — the slot routes to nobody. Do not author |
expression |
A CEL expression resolved at node entry (value = the expression) — see Dynamic approvers below. Only current.* / trigger.* / vars.* roots are available; the optional resolveAs: 'user'(default) | 'department' | 'position' | 'team' re-expands each resolved id through the graph |
Dynamic approvers (type: 'expression')
An expression approver computes WHO approves at the moment the node is
entered. Its CEL source sees exactly three roots — nothing else:
| Root |
Meaning |
Analog |
current.* |
The record's live state at node entry — fields written by earlier steps/approvers are visible |
ServiceNow current |
trigger.* |
The submit-time snapshot (what flow conditions call record) |
ServiceNow Flow Designer trigger.record, Power Automate triggerBody() |
vars.* |
Flow variables — node outputs (vars.<nodeId>.<key>), get_record results, vars.previous (the pre-update row) |
BPMN process variables |
record and bare field names are NOT available and fail the node loudly.
Everywhere else on this platform record means "the record at event time"
(flow conditions: the trigger snapshot; hook conditions: the stored record
overlaid with the write's payload) — at an
approval node that phrase is ambiguous between two different times, so you must
say which one: current.x or trigger.x. Do not carry the record.x habit
over from conditions.
Result contract: a user-id string, a CSV string, or an array of ids. An empty
result (present-but-empty field/variable) triggers onEmptyApprovers. A
missing key (vars.never_written) is a loud error, never a silent empty
slate — guard genuinely-optional inputs explicitly, e.g.
has(vars.picked) ? vars.picked : [].
// ① Route on a field an EARLIER approver filled in mid-flow (live value):
{ type: 'expression', value: cel`current.co_review_departments`, resolveAs: 'department' }
// ② The previous approval node's decision outputs pick this node's approvers:
{ type: 'expression', value: cel`vars.lead_review.next_reviewers` }
// ③ Dynamic co-sign (会签): expression yields department ids; resolveAs expands
// each into its members, and with behavior: 'per_group' EACH department is
// its own sign-off group:
{
approvers: [{ type: 'expression', value: cel`current.picked_departments`, resolveAs: 'department' }],
behavior: 'per_group',
onEmptyApprovers: 'fail',
}
The full "previous approver picks the next step's approvers" loop, end to end
(the shipped showcase_dynamic_approval flow in the showcase app is this shape):
import { defineFlow } from '@objectstack/spec';
export const DynamicApprovalFlow = defineFlow({
name: 'dynamic_approval',
label: 'Dynamic Approval',
type: 'autolaunched',
status: 'active',
nodes: [
{
id: 'start', type: 'start', label: 'On Submit',
config: { objectName: 'expense', triggerType: 'record-after-update', condition: "status == 'submitted'" },
},
{
// Node A declares what a decision may hand to the flow. The TYPED
// declaration renders a multi-select sys_user picker in the decision
// dialog; the lead approves with outputs:
// POST …/approve { outputs: { next_reviewers: ['u2', 'u3'] } }
// `required: true` is enforced by the runtime on APPROVE (never on
// reject) — node B below has nobody to route to without it.
id: 'lead_review', type: 'approval', label: 'Lead Review',
config: {
approvers: [{ type: 'org_membership_level', value: 'owner' }],
decisionOutputs: [{ key: 'next_reviewers', label: 'Next Reviewers', type: 'user', multiple: true, required: true }],
},
},
{
// Node B resolves them at entry from the lead's decision outputs.
id: 'co_sign', type: 'approval', label: 'Co-sign',
config: {
approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }],
behavior: 'unanimous',
onEmptyApprovers: 'fail',
},
},
{ id: 'approved', type: 'end', label: 'Approved' },
{ id: 'rejected', type: 'end', label: 'Rejected' },
],
edges: [
{ id: 'e1', source: 'start', target: 'lead_review' },
{ id: 'e2', source: 'lead_review', target: 'co_sign', label: 'approve' },
{ id: 'e3', source: 'lead_review', target: 'rejected', label: 'reject' },
{ id: 'e4', source: 'co_sign', target: 'approved', label: 'approve' },
{ id: 'e5', source: 'co_sign', target: 'rejected', label: 'reject' },
],
});
Time-word cheat sheet across surfaces (do not mix them up):
| Surface |
Event-time record |
Pre-event record |
Live record |
Flow condition / {…} template |
record (trigger snapshot) |
previous |
— (use a get_record node) |
Approval expression approver |
trigger.* |
vars.previous |
current.* |
Object-hook ctx is a different vocabulary — see objectstack-data
references/data-hooks.md.
Node Config (ApprovalNodeConfigSchema)
| Field |
Purpose |
approvers |
Who may act (≥ 1 — see Approver Types above). Each approver may carry an optional group label (e.g. { type: 'position', value: 'auditor', group: 'finance' }) — with behavior: 'per_group', approvers sharing a label form one group; unlabelled approvers each form their own |
behavior |
first_response (first approver decides), unanimous (all must approve), quorum (minApprovals of N — M-of-N collective sign-off), or per_group (EACH approver group must reach minApprovals — one-from-each-group sign-off, 会签). In every mode a single rejection finalizes the node as rejected. Default first_response |
minApprovals |
Approvals required — total for quorum, per group for per_group. Omitted ⇒ ALL resolvable approvers under quorum, 1 per group; clamped at runtime so a misconfiguration can never deadlock |
lockRecord |
Lock the triggering record from edits while pending. Default true |
approvalStatusField |
Business-object field to mirror pending/approved/rejected/recalled onto (should be readonly) |
onEmptyApprovers |
What an EMPTY resolved slate does: admin_rescue (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), fail (node fails — treat an empty slate as a config bug), auto_approve (skip the request, continue down approve with output.autoApproved = true — opt-in because it silently waves the record through). Declare it explicitly on any node with an expression approver (linted) |
decisionOutputs |
Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) or typed declarations { key, label?, type: 'text'|'user'|'department'|'position'|'team', multiple?, required? } — a typed entry renders the matching record picker in the decision dialog (multiple collects an id array). Accepted outputs resume the run as <nodeId>.<key> variables; undeclared keys reject the decision; decision/requestId reserved |
escalation |
Optional per-node SLA — { enabled, timeoutHours, action: reassign|auto_approve|auto_reject|notify, escalateTo?, notifySubmitter }. timeoutHours is calendar (wall-clock) hours — nights, weekends and holidays count; the platform ships no business-hours calendar. escalateTo is a position machine name (expanded to its holders via sys_user_position, ADR-0090 D3) or a specific user id — never a membership tier. reassign without escalateTo degrades to notify (linted) |
maxRevisions |
ADR-0044 — max send-backs-for-revision per run before auto-reject. Default 3; 0 disables send-back. Only meaningful when the node has a revise out-edge |
Branching, side-effects & rejection
These are wired on the graph, not in node config:
- Conditional step — put a
decision node before the Approval node, or a
condition on the edge entering it (the old per-step entryCriteria).
- On approve / on reject — wire downstream nodes (
update_record,
http, a notify node, …) to the approve / reject out-edge.
- Roll back on reject — route the
reject edge as a back-edge to an
earlier node so the submitter can revise (the old back_to_previous).
- Send back for revision (ADR-0044) — distinct from a plain reject: a
revise out-edge into an approval_revise window, closed by a
type: 'back' resubmit edge. See Send-back for revision above.
- Hard reject — route the
reject edge to an end node (the old
reject_process).
Approval Best Practices
- Gate entry on the edge (
condition into the Approval node) so the flow
only pauses for records that actually need sign-off.
- Set
approvalStatusField to mirror status onto the row — views and
formulas can then filter on it without joining sys_approval_request.
- Keep
lockRecord: true unless you have a strong reason to allow
edits while pending — otherwise approvers chase a moving target.
- Model rejection as a visible branch — a back-edge to revise, or an
end
node to terminate. The path is on the diagram, not hidden in config.
- Notify from downstream nodes wired to the
approve / reject edges
rather than expecting the node to send mail itself.
Triggers — Event-Driven Automation
A record_change flo
…(truncated)
1---2name: objectstack-automation3description: Design ObjectStack automation — Flows (visual logic), Triggers, Approvals, state machines, and the `jobs` (`defineJob`) / `webhooks` (`defineWebhook`) stack collections. Use when the user is adding `*.flow.ts`, wiring an event-driven rule, modelling an approval chain, or building an interactive screen flow / wizard (objectstack-ui routes those here). Do not use for data lifecycle hooks at the object layer (see objectstack-data) or for kernel / plugin events (see objectstack-platform). CEL expressions in flow conditions / edge guards: load objectstack-formula alongside.4license: Apache-2.05---67# Automation Design — ObjectStack Automation Protocol89## When to Use This Skill1011- You are building a **visual flow** (auto-launched, screen, or scheduled).12- You need a **state machine** or **approval process** for a business object.13- You are setting up **event-driven triggers** (record create/update/delete).14- You need **scheduled automation** (daily reports, data cleanup).1516> **Predicates and conditions are CEL** — every `condition` / `guard` /17> `entryCondition` / filter `value` here is an **Expression** envelope evaluated18> by `@objectstack/formula`. A slot takes a plain CEL string; the19> `P\`...\`` / `cel\`...\`` tags wrap the same string with author-time validation.20> Both parse — pick one per file (the example apps use plain strings). See21> **objectstack-formula** for the CEL contract, stdlib and legacy → CEL table.2223---2425## Flows — Visual Logic Orchestration2627A **Flow** is a directed graph of nodes that execute sequentially or in28parallel. Flows are the primary automation building block in ObjectStack.2930### Flow Types3132| Type | When to Use |33|:-----|:------------|34| `autolaunched` | Runs without user interaction — triggered by events, APIs, or other flows |35| `screen` | Interactive — presents UI screens to the user (wizards, forms) |36| `schedule` | Runs on a cron/interval cadence declared on the **start node's `config.schedule`** (daily cleanup, weekly reports) — or a **per-record date sweep** via `config.timeRelative`, see *Time-relative triggers* |37| `record_change` | Fires automatically on record create/update/delete (bind via the `start` node's `triggerType`). `autolaunched` + the same `record-*` binding behaves identically — the engine reads the start node either way; `record_change` also opts into the trigger-readiness lint |38| `api` | Invoked explicitly via the API / `engine.execute()`, **or** bound as an inbound **webhook**: `POST /api/v1/automation/hooks/:flowName/:hookId` (see *Inbound webhook triggers* below) |3940### Flow Node Types4142Flows are built from **20 built-in node types** (the `FlowNodeAction` seed set —43plugins register more via `registerNodeExecutor`, e.g. `approval` below):4445#### Control Flow4647| Node | Purpose |48|:-----|:--------|49| `start` | Entry point — every flow has exactly one |50| `end` | Exit point — can have multiple (early exit, error exit) |51| `decision` | Conditional branching — routed by **edge `condition` predicates**, not node config (see the approval example below) |52| `loop` | Iterate a **nested `config.body` region** once per item of `config.collection`; `iteratorVariable` (default `item`) and optional `indexVariable` bind inside it, `maxIterations` caps it |53| `parallel` | Fan out into `config.branches[]` (≥ 2 regions) run concurrently, **joined implicitly** at block end — no split/join pair to mis-wire |54| `try_catch` | Run `config.try`; on failure run `config.catch` with the error in `errorVariable` (default `$error`); `config.retry` re-runs `try` with backoff first. **No `finally`** — the container's ordinary out-edges are the continuation |55| `map` | Sequential multi-instance — invoke a subflow once per item of a collection; each iteration may pause (batch approvals) |56| `wait` | Pause execution until a timer elapses or a named signal arrives |57| `subflow` | Invoke another flow (reusable composition) |58| `parallel_gateway` / `join_gateway` / `boundary_event` | **Not author-facing** — BPMN-interop forms the mapper lowers a `parallel` / `try_catch` container INTO (`automation/control-flow.zod.ts`). Author the container |5960#### Data Operations6162| Node | Purpose |63|:-----|:--------|64| `assignment` | Set variable values |65| `create_record` | Insert a new record |66| `update_record` | Modify existing records |67| `delete_record` | Remove records |68| `get_record` | Fetch records with filters — there is **no `query_record`** node (that name has no executor and throws) |6970#### External Integration7172| Node | Purpose |73|:-----|:--------|74| `http` | Call an external HTTP API — canonical since protocol 11.0; `http_request` survives only as a deprecation-window alias |75| `notify` | Send a notification through the messaging service (inbox channel by default) |76| `connector_action` | Invoke a pre-built integration connector |77| `script` | Call a **registered** function named by `config.function` (see *Valid-but-silently-wrong* #3) |78| `screen` | Display a UI form to the user (screen flows only) |7980#### Human Decision8182| Node | Purpose |83|:-----|:--------|84| `approval` | Route a record for human sign-off — **suspends** the run until a decision, then continues down the `approve` / `reject` branch (contributed by `plugin-approvals`) |8586### `notify` — the most-used node type8788`NotifyConfigSchema` (`automation/io-node-config.zod.ts`) is `strictObject` — an89undeclared key is a named parse error. **RAW** keys never interpolate: a90`{token}` in one is forwarded verbatim, never resolved.9192```ts93{ id: 'tell_owner', type: 'notify', label: 'Notify Owner', config: {94 recipients: '{record.assignee}', // REQUIRED — id, CSV, or string[]95 title: 'Done: {record.title}', // inline path; XOR `template` (RAW, localizable)96 message: 'Closed by {$User.Id}', // body; only with inline `title`97 topic: 'task', // RAW; default 'notify'98 severity: 'warning', // RAW; CLOSED enum info|warning|critical99 channels: ['inbox'], // RAW; default inbox100 sourceObject: 'task', // click-through: a PAIR, else dropped101 sourceId: '{record.id}', // at execute time102 actionUrl: 'https://…/tasks/123', // overrides the synthesized link103} }104```105106### Flow Variables107108Every flow defines input/output variables. `variables` is an **array** of109`{ name, type, isInput, isOutput }` entries — not a name-keyed map, and there110is no `label` property on a variable:111112```typescript113variables: [114 {115 name: 'case_id',116 type: 'text',117 isInput: true, // passed in when flow is invoked118 isOutput: false,119 },120 {121 name: 'approval_result',122 type: 'boolean',123 isInput: false,124 isOutput: true, // returned when flow completes125 },126],127```128129### Flow Example — Auto-Escalate Overdue Cases130131> **Nodes connect via `edges`, not a `next` property.** The engine traverses132> `flow.edges` (`{ source, target }`); a bare `next:` on a node is refused.133> `update_record` selects rows with **`filter`** — an ObjectQL `where` **map**134> of `field → value` / `field → { $operator: value }`, NOT the UI view-filter135> `[{ field, operator, value }]` triples — and writes with **`fields`**136> (a single call updates *every* matching row — no per-row loop needed).137> `label` is **required** on the flow and on every node, and every path through138> the graph must reach an `end` node.139140<!-- os:check -->141```typescript142import { defineFlow } from '@objectstack/spec';143144export const EscalateOverdueCasesFlow = defineFlow({145 name: 'escalate_overdue_cases',146 label: 'Escalate Overdue Cases',147 type: 'schedule',148 status: 'active',149 runAs: 'system', // a scheduled run has no trigger user — elevate explicitly150 nodes: [151 {152 id: 'start',153 type: 'start',154 label: 'Daily at 09:00',155 // The cadence lives HERE, on the start node's config — FlowSchema has NO156 // top-level `schedule` key (one there is a named parse error, not a silent157 // strip). A bare cron string also works: schedule: '0 9 * * *'. Do NOT use158 // the cron`…` tagged template — its envelope is not a recognized shape.159 config: { schedule: { type: 'cron', expression: '0 9 * * *' } },160 },161 {162 id: 'escalate_overdue',163 type: 'update_record',164 label: 'Escalate Overdue Cases',165 config: {166 objectName: 'support_case',167 // which rows to update — `filter` is a `where` map, not filter triples168 filter: {169 status: { $in: ['new', 'open'] },170 due_date: { $lt: '{TODAY()}' }, // template token → today's date at run time171 },172 // what to write — `fields`, not `values`173 fields: { status: 'escalated' },174 },175 },176 {177 id: 'notify_manager',178 type: 'http',179 label: 'Notify Manager',180 config: {181 url: 'https://hooks.slack.com/services/...',182 method: 'POST',183 body: { text: 'Escalated overdue support cases.' },184 timeoutMs: 10000, // unset = NO timeout at all — always set one185 },186 },187 { id: 'end', type: 'end', label: 'End' },188 ],189 edges: [190 { id: 'e1', source: 'start', target: 'escalate_overdue' },191 { id: 'e2', source: 'escalate_overdue', target: 'notify_manager' },192 { id: 'e3', source: 'notify_manager', target: 'end' },193 ],194});195```196197### Failure routing & `runAs`198199> **Handling a failed node: a `fault` edge.** `{ source, target, type: 'fault' }`200> routes a failed node to a handler instead of ending the run. **`type: 'fault'`201> is what routes — a `label: 'error'` alone does nothing:** the edge stays202> ordinary, and every unconditional out-edge traverses on SUCCESS, so the203> handler would run when the node succeeds and never when it fails204> (`objectstack validate` reports `flow-error-label-not-fault`).205> A handled failure does NOT consume a flow-level `errorHandling.retry`, which206> replays the flow from the start — prefer a fault edge when the failure is207> local. The handler reads `{<nodeId>.error}` (or run-wide `{$error}`). The run208> then reports success, and the failed step stays in the trace.209>210> **It is not a way past a guardrail.**211212| ROUTES (runtime failure) | Does NOT route (fatal either way) |213|:--|:--|214| 404, rate-limit, rejected write, failed subflow | missing required config key (`objectName`, `url`, `flowName`, `connectorId`/`actionId`); filter token that resolved to nothing; graph past the nesting ceiling; unscoped run |215216Routing a guard refusal is worse than the failure: a dropped filter condition217**widens** the query, so a routed `delete_record` empties the object while the218run reports success. `objectstack validate` names the offending template.219220> **Writing a `readonly` field? Set `runAs: 'system'`.** `readonly: true`221> governs the end-user surface: under the default `runAs: 'user'`, the engine222> **strips** a `readonly` field from any non-system write — `create_record` and223> `update_record` alike — the step reports success but the value never lands,224> and the drop is named in the step's warnings. A flow that225> maintains a `readonly` field (approval stamps, conversion flags, SLA226> markers, rollups) must run `runAs: 'system'`, the trusted-writer channel.227> `os validate` / `os build` fail a `runAs:'user'` `update_record` that writes228> a `readonly` field, so the mismatch surfaces at build time, not as wrong data229> days later. (`readonlyWhen` fields are the same story, per record state —230> flagged as a warning.) Do **not** work around this by removing `readonly`;231> that loses the field's edit protection.232233> **Elevate the write, not the flow.** A `screen` flow stays `runAs: 'user'`.234> When one step in it must write a `readonly` field, move that step into a235> dedicated `runAs: 'system'` flow and call it from a `subflow` node — raising236> the whole flow silently elevates every other write in it.237>238> **A `runAs: 'system'` sweep must pin its organization.** System context has no239> trigger user, so nothing narrows the query: a scan or rollup with no240> organization predicate reads and writes across every tenant. The tenant column241> is platform-injected — filter on it, never re-declare it per object.242243> **A hook elevates itself with `runAs`, never with `sudo`.** An object hook244> (objectstack-data) declares its own `runAs: 'system' | 'user' | 'inherit'` —245> default `'inherit'`, the context of the write that fired it — scoping that246> hook's `ctx.api` data operations only, on the in-process `handler` and the247> sandboxed `body` alike. A `'user'` hook whose trigger resolved no user has248> nothing to scope to: its `ctx.api` data operations are refused249> (`HOOK_UNSCOPED_DATA_ACCESS`, 403) rather than run unscoped — declare250> `runAs: 'system'` when the elevation is intended. `sudo` is not a hook key.251252### Filter tokens (`config.filter`)253254The one slot where two `{…}` dialects meet, and the one whose failure **widens**255a query instead of narrowing it.256257- **Precedence — flow variables win, placeholders pass through.** The flow258 template engine runs first. A whole-string token it resolves is a flow value;259 one it does **not** resolve that IS a recognised filter placeholder260 (`{current_user_id}`, `{current_year_start}`) passes through **verbatim** for261 the query engine to expand. So a flow variable named after a placeholder262 **shadows** it. Only `filter` gets this hand-off — in `title`, `message`,263 `fields` and `url` a bare `{current_year_start}` is a nonsense reference.264- **Static checkability splits by position.** A `{record.…}` token **inside a265 filter** naming an unknown field, or hopping a relation the start node does not266 list in `config.expand`, is an **ERROR** at `objectstack validate`: it resolves267 to nothing, the condition is DROPPED, and the node refuses to execute. The268 *same* reference **outside** a filter (message body, `http` url, write payload)269 only renders an empty string — a **warning**. A `{var}` naming a flow variable270 or node output is **not statically checkable at all**.271272---273274## Valid-but-silently-wrong (passes build, fails at runtime)275276These are *legal* metadata that authors — AI especially — get wrong. Most are now277caught by `objectstack build` (a hard error, or an advisory warning), but write278them right the first time:2792801. **Flow node VALUE interpolation uses SINGLE braces.** Value fields on a node's281 `config` (`fields`, `inputs`, notify `message`/`title`, …) interpolate282 `{token}`:283 - `{var}` / `{record.title}` — variable / record field284 - `{record.tags.0}` — **array index** (e.g. a `multiple: true` lookup, stored as an array)285 - `{$User.Id}` / `{NOW()}` / `{TODAY() + 30}` — current user / date macros286 - `{round(x)}` `{floor(x)}` `{ceil(x)}` `{abs(x)}` `{min(a,b)}` `{max(a,b)}` —287 mirror the CEL stdlib 1:1. `round` is **integer-only** (no `round(x, 2)`);288 for N decimals write `{round(x * 100) / 100}` (scale 2)289 - anything without `{…}` is a **literal**290291 ❌ `body: '{{ai_reply}}'` — double-brace is the *formula / template-field* dialect, **not** flow values292 ❌ `ticket: '$source.id'` — a bare `$ref` is a literal string, not interpolated293 ✅ `body: '{ai_reply}'`, `ticket: '{source.id}'`294 ❌ `'{ROUND(x, 2)}'` / `'{Math.round(x)}'` / `'{(x).toFixed(2)}'` — any other295 name in call position **fails the node** with a named error naming the296 supported set. The build does **not** catch these (conditions are checked,297 call-position names are not) and a `fault` edge cannot route it.2982992. **`create_record`'s `outputVariable` holds the created RECORD, not its id.**300 Reference a field explicitly.301 ❌ `update_record … fields: { ref: '{newRec}' }` → yields the whole record object302 ✅ `fields: { ref: '{newRec.id}' }`3033043. **`script` nodes call a registered function — that is all they do.** Set305 `config.function` to a function registered via306 `defineStack({ functions: { my_fn: (ctx) => … } })`. It is **required**: an307 empty `script` node refuses at execute, and one pointing at an unregistered308 function fails loudly.309310 There is no other dispatch form: use **`notify`** for delivery, a311 **`connector_action`** or `http` webhook for Slack, a function for logic.312313 **A flow `function` is a PURE compute step — it does NOT read/write the314 database.** It receives `ctx.input` and **returns** a value; `config.outputVariable`315 exposes that value as a flow variable, and a later **declarative** node persists316 it. Keep data effects on the flow graph (visible, governed, build-checkable):317318 ```ts319 // ❌ DON'T: expect the function to update the record itself (it has no data API)320 // ✅ DO: function returns values → outputVariable → update_record persists321 { id: 'ai', type: 'script', config: {322 function: 'helpdesk.aiTriageStub', // returns { ai_category, ai_sentiment, … }323 inputs: { ticketId: '{record.id}' }, // inputs are interpolated324 outputVariable: 'ai',325 } },326 { id: 'apply', type: 'update_record', config: {327 objectName: 'helpdesk_ticket',328 filter: { id: '{record.id}' },329 fields: { ai_category: '{ai.ai_category}', ai_sentiment: '{ai.ai_sentiment}' },330 } },331 ```332333 `defineStack({ functions: { 'helpdesk.aiTriageStub': (ctx) => ({ ai_category: 'other', … }) } })`.334 If you genuinely need data-lifecycle **side effects** (read/write other records),335 that's an L2 **hook** (objectstack-data) — hooks get `ctx.api`; flow functions don't.336337 A function that writes where the platform cannot see **declares** it, so the338 run reports "cannot say" rather than `acted: 0`:339340 ```ts341 defineStack({ functions: {342 'helpdesk.aiTriageStub': (ctx) => ({ ai_category: 'other' }), // pure — the default343 'billing.sync': { handler: syncBilling, effect: 'writes' }, // declared writer344 } });345 ```3463474. **Conditions are bare CEL — the stdlib is what you may call bare.** `now()`,348 `today()`, `daysFromNow(n)`, `daysAgo(n)`, `daysBetween(a, b)`, `isBlank(v)`,349 `coalesce(a, b)`, `abs/round/min/max`, `upper/lower/contains/matches`, plus CEL350 built-ins (`has`, `size`, `int`, `string`, …) — see **objectstack-formula** for that351 table: it is `CEL_STDLIB_FUNCTIONS`, the bare-callable public subset, so receiver352 methods (called on a value, never bare) are not in it.353 An UNKNOWN function (`PRIOR()`, a typo'd name) and a `{…}`-wrapped field ref354 both **fail the build**: a brace is a template, not CEL — write `record.x`,355 not `{record.x}`.3563575. **`notify` reports SUCCESS when the `messaging` capability is absent.** The358 executor logs `no messaging service registered` and returns success with359 `output: { delivered: 0, failed: 0, skipped: true }` and `metrics.acted: 0` —360 a green run that delivered nothing. Declare `messaging` in `requires`.361362---363364## State Machines & Approvals365366A record's **state machine** locks the legal transitions of its status field367so that automation — increasingly AI-generated — cannot drive a record into an368illegal state.369370### State Machine — a `state_machine` validation rule (ADR-0020)371372Since **ADR-0020** there is **no `workflow` metadata type** and no373`object.stateMachines` map. A record state machine is **one `state_machine`374validation rule** in the object's `validations` array: a flat `field` +375`{ from: [allowedTo] }` transition table. It is **enforced on the write path** —376an update whose `field` moves to a state not listed for the current state is377rejected with the rule's `message`. A `from` state mapped to `[]` is a declared378dead-end.379380```typescript381{382 type: 'state_machine',383 name: 'case_lifecycle',384 label: 'Case Lifecycle',385 field: 'status', // the field that holds the state386 message: 'Invalid status transition.',387 initialStates: ['new'], // states a record may be CREATED in388 transitions: {389 new: ['open'],390 open: ['escalated', 'resolved'],391 escalated: ['open', 'resolved'],392 resolved: ['open', 'closed'],393 closed: [], // final — no outgoing transitions394 },395}396```397398Notes:399- **One rule per field.** Parallel lifecycles (e.g. `status` + `payment_status`)400 are N separate `state_machine` rules, one per field.401- **`initialStates`** (optional) gates INSERT: a record created with its402 state field outside this list is rejected. `transitions` only governs403 updates, so without it a record can be born mid-flow (e.g. created already404 `resolved`). Omit to keep the legacy no-check-on-insert behavior.405- **Conditional transitions / side effects are NOT part of the machine.** A406 guard is expressed as a sibling `script` / `conditional` validation rule;407 "do something when the state changes" is a **record-triggered Flow**408 (ADR-0019) — a `record_change` flow whose start-node condition gates on the409 transition, e.g. `previous.status != 'escalated' && record.status == 'escalated'`.410- **Introspection:** `GET /api/v1/meta/object/:name/state/:field?from=:state`411 returns the legal next states so UIs/agents can read the transition table412 instead of hard-coding it (`next: null` = no FSM governs the field, **or**413 `?from=` was omitted — always pass `from`).414- **An unlisted `from` state is NOT guarded.** An update whose current state is415 not a key of `transitions` is treated leniently (no lock) — list every state416 you want guarded rather than relying on an implicit "any → any".417- Predicate conditions in sibling rules evaluate against the merged record in418 the **`record.<field>`** CEL scope (bare field names do not resolve).419420### Approvals (Flow Nodes)421422Since **ADR-0019** there is no standalone approval-process type. An approval is423authored as an **Approval node** (`type: 'approval'`) on an ordinary flow — the424run **suspends** when it reaches the node and **resumes** down the node's425`approve` / `reject` out-edge once a decision is recorded. Multi-step review is426just successive Approval nodes wired together on the canvas, so the whole review427is one diagram a reviewer (or AI) can read end-to-end.428429> There is no `approvals: [...]` stack collection — approval flows live in your430> normal `flows: [...]`. The approval *state* (`sys_approval_request` /431> `sys_approval_action`, the record lock, the status mirror, approver432> resolution) is owned by `plugin-approvals`.433434```typescript435// A record-triggered flow: high-value opportunities need manager sign-off,436// and director sign-off too when the amount clears 500k.437{438 name: 'opportunity_discount_approval',439 label: 'Opportunity Discount Approval',440 type: 'record_change',441 nodes: [442 // Record-change flows bind via the START NODE's config — there is no443 // separate top-level `trigger`. `triggerType` is one of444 // `record-(before|after)-(create|update|delete)`; `condition` (bare CEL)445 // gates whether the flow launches.446 {447 id: 'start',448 type: 'start',449 label: 'On Opportunity Update',450 config: {451 objectName: 'opportunity',452 triggerType: 'record-after-update',453 condition: cel`record.amount > 100000`,454 },455 },456 {457 id: 'manager_review',458 type: 'approval',459 label: 'Sales Manager Review',460 config: {461 approvers: [{ type: 'position', value: 'sales_manager' }],462 behavior: 'first_response', // or 'unanimous' / 'quorum' / 'per_group'463 lockRecord: true, // lock the record while pending464 approvalStatusField: 'approval_status', // mirror pending|approved|rejected|recalled onto the row465 },466 },467 // Decision routing lives on the OUT-EDGES, not in node config: the engine468 // evaluates each out-edge's `condition` and follows every match — and an469 // out-edge with NO condition ALWAYS runs (all such edges execute in470 // PARALLEL). Guard every branch with a condition — see e4/e5 below.471 { id: 'needs_director', type: 'decision', label: 'Needs Director?' },472 {473 id: 'director_signoff',474 type: 'approval',475 label: 'Sales Director Sign-off',476 config: {477 approvers: [{ type: 'position', value: 'sales_director' }],478 behavior: 'unanimous',479 approvalStatusField: 'approval_status',480 },481 },482 { id: 'mark_won', type: 'update_record', label: 'Mark Won',483 config: { objectName: 'opportunity', filter: { id: '{record.id}' }, fields: { stage: 'closed_won' } } },484 { id: 'approved', type: 'end', label: 'Approved' },485 { id: 'rejected', type: 'end', label: 'Rejected' },486 ],487 edges: [488 { id: 'e1', source: 'start', target: 'manager_review',489 // entry criteria re-homes onto the edge entering the approval node:490 condition: cel`record.amount > 100000` },491 { id: 'e2', source: 'manager_review', target: 'needs_director', label: 'approve' },492 { id: 'e3', source: 'manager_review', target: 'rejected', label: 'reject' },493 // Decision branches: mutually-exclusive edge `condition` predicates.494 // Without them BOTH branches would execute (unguarded edges run in parallel).495 { id: 'e4', source: 'needs_director', target: 'director_signoff', label: 'true',496 condition: cel`record.amount > 500000` },497 { id: 'e5', source: 'needs_director', target: 'mark_won', label: 'false',498 condition: cel`record.amount <= 500000` },499 { id: 'e6', source: 'director_signoff', target: 'mark_won', label: 'approve' },500 { id: 'e7', source: 'director_signoff', target: 'rejected', label: 'reject' },501 { id: 'e8', source: 'mark_won', target: 'approved' },502 ],503}504```505506### Send-back for revision (ADR-0044)507508Approval centers also model **send back for revision** (退回修改) — distinct from509`reject` (terminate) and from a comment thread (which keeps the request pending).510Send-back is a **flow movement**: the request finalizes as `returned`, the run511walks a **`revise`** out-edge to an **`approval_revise`** node (the *revise512window*) where the record unlocks and the submitter reworks it, and an explicit513*resubmit* re-enters the approval node over a **declared back-edge**, opening514round N+1 with a fresh approver slate.515516```517approval ──approve──▶ …518 ──reject───▶ …519 ──revise───▶ approval_revise (record unlocked, submitter edits)520 └──resubmit──[type:'back']──▶ approval (round N+1)521```522523Three pieces author it:5245251. **`revise` out-edge** — a third branch label alongside `approve` / `reject`,526 targeting an **`approval_revise`** node. It must be that node type: the window527 is a *service-owned* pause (`resumeAuthority: 'service'`), ended only by528 `POST /api/v1/approvals/requests/:id/resubmit`; a `wait` is529 `resumeAuthority: 'any'`, so a raw run-resume would walk the back-edge530 unchecked. The node takes **no config** — there is no signal to wait on.5312. **`type: 'back'` resubmit edge** — the edge from the revise window back into532 the approval node MUST be typed `'back'`. This is the *only* thing that533 legalizes the cycle: `registerFlow` validates the graph **minus `back` edges**534 as a DAG, so an **unmarked** cycle is rejected — you opt in, edge by edge. At535 run time a back-edge traverses normally (it just re-enters the node).5363. **`maxRevisions`** on the approval `config` (default `3`) — the budget of537 send-backs per run; exceeding it **auto-rejects** (resumes down the `reject`538 edge). `maxRevisions: 0` disables send-back, so never pair `0` with a `revise`539 edge.540541```typescript542{543 id: 'manager_review', type: 'approval', label: 'Manager Review',544 config: { approvers: [{ type: 'position', value: 'manager' }], lockRecord: true, maxRevisions: 2 },545},546// No config and no `waitEventConfig`: the window ends on the submitter's547// explicit resubmit, not on a signal or a timer.548{ id: 'wait_revision', type: 'approval_revise', label: 'Awaiting Revision' },549// …among the approval's edges…550{ id: 'rev', source: 'manager_review', target: 'wait_revision', label: 'revise' },551{ id: 'back', source: 'wait_revision', target: 'manager_review', label: 'resubmit', type: 'back' },552```553554> Three mistakes the compile-time flow lint flags: a `revise` edge into anything555> but an `approval_revise` node (an **error** — `sendBack` refuses that metadata,556> so the branch cannot run; `flow-approval-revise-target-not-service-owned`), a557> `revise` edge whose window never loops back (a dead end `registerFlow` accepts558> but that leaves the submitter nowhere to resubmit), and a resubmit edge left559> **without** `type: 'back'` (an unmarked cycle `registerFlow` rejects). Resubmit560> is an explicit verb (`POST /api/v1/approvals/requests/:id/resubmit`), never a561> record-save. See the `showcase_budget_approval` flow in the showcase app in562> the framework repo for the canonical shape.563564### Recording a decision565566A decision is recorded through `ApprovalService.decide()` (or the REST routes567`POST /api/v1/approvals/requests/:id/approve` | `/reject`). That finalizes the568`sys_approval_request` and **resumes** the suspended run down the matching569branch — you never resume the flow by hand, and you *cannot*: the570`approval` node declares `resumeAuthority: 'service'`, so571`POST /api/v1/automation/:name/runs/:runId/resume` answers **403** for a run572parked on one (including via a `subflow` pause) and changes nothing.573574A decision may also carry **structured outputs** (`{ outputs: { … } }` in the575decide body) when the node declares the keys in `decisionOutputs` — the author576declares keys, approvers only fill values. Accepted outputs resume the run as577`<nodeId>.<key>` flow variables, so a LATER node reads them as578`vars.<nodeId>.<key>` — this is how "the previous approver picks the next579step's approvers" works without writing to a record field (see Dynamic580approvers below). A decision carrying an undeclared key is rejected;581`decision` / `requestId` are reserved. A declaration marked582`required: true` must carry a non-blank value to **approve** (never to583reject) — enforced before any write, with no elevation bypass, so the run584cannot resume past the node with the key a later `expression` approver reads585still missing.586587### Approver Types588589| `type` | Resolves to |590|:-------|:------------|591| `user` | A specific user id (`value` = user id) |592| `position` | Holders of a position — `value` = the position machine name, resolved via `sys_user_position` (ADR-0090 D3) |593| `org_membership_level` | The **org-membership tier** — `value` is one of `owner`/`admin`/`delegated_admin`/`member`. **NOT** a position: `{ type: 'org_membership_level', value: 'sales_manager' }` matches nobody; use `position`. Spelled `role` before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major |594| `team` | Members of a flat `sys_team` |595| `department` | A department + all descendant departments |596| `manager` | The submitter's manager (`sys_user.manager_id`) |597| `field` | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user |598| `queue` | ⛔ Declared but never resolved — the slot routes to nobody. Do not author |599| `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. Only `current.*` / `trigger.*` / `vars.*` roots are available; the optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph |600601### Dynamic approvers (`type: 'expression'`)602603An `expression` approver computes WHO approves at the moment the node is604entered. Its CEL source sees exactly **three roots** — nothing else:605606| Root | Meaning | Analog |607|:-----|:--------|:-------|608| `current.*` | The record's **live** state at node entry — fields written by earlier steps/approvers are visible | ServiceNow `current` |609| `trigger.*` | The **submit-time snapshot** (what flow conditions call `record`) | ServiceNow Flow Designer `trigger.record`, Power Automate `triggerBody()` |610| `vars.*` | Flow variables — node outputs (`vars.<nodeId>.<key>`), `get_record` results, `vars.previous` (the pre-update row) | BPMN process variables |611612**`record` and bare field names are NOT available and fail the node loudly.**613Everywhere else on this platform `record` means "the record at event time"614(flow conditions: the trigger snapshot; hook conditions: the stored record615overlaid with the write's payload) — at an616approval node that phrase is ambiguous between two different times, so you must617say which one: `current.x` or `trigger.x`. Do not carry the `record.x` habit618over from conditions.619620Result contract: a user-id string, a CSV string, or an array of ids. An **empty**621result (present-but-empty field/variable) triggers `onEmptyApprovers`. A622**missing** key (`vars.never_written`) is a loud error, never a silent empty623slate — guard genuinely-optional inputs explicitly, e.g.624`has(vars.picked) ? vars.picked : []`.625626```typescript627// ① Route on a field an EARLIER approver filled in mid-flow (live value):628{ type: 'expression', value: cel`current.co_review_departments`, resolveAs: 'department' }629630// ② The previous approval node's decision outputs pick this node's approvers:631{ type: 'expression', value: cel`vars.lead_review.next_reviewers` }632633// ③ Dynamic co-sign (会签): expression yields department ids; resolveAs expands634// each into its members, and with behavior: 'per_group' EACH department is635// its own sign-off group:636{637 approvers: [{ type: 'expression', value: cel`current.picked_departments`, resolveAs: 'department' }],638 behavior: 'per_group',639 onEmptyApprovers: 'fail',640}641```642643The full "previous approver picks the next step's approvers" loop, end to end644(the shipped `showcase_dynamic_approval` flow in the showcase app is this shape):645646<!-- os:check -->647```typescript648import { defineFlow } from '@objectstack/spec';649650export const DynamicApprovalFlow = defineFlow({651 name: 'dynamic_approval',652 label: 'Dynamic Approval',653 type: 'autolaunched',654 status: 'active',655 nodes: [656 {657 id: 'start', type: 'start', label: 'On Submit',658 config: { objectName: 'expense', triggerType: 'record-after-update', condition: "status == 'submitted'" },659 },660 {661 // Node A declares what a decision may hand to the flow. The TYPED662 // declaration renders a multi-select sys_user picker in the decision663 // dialog; the lead approves with outputs:664 // POST …/approve { outputs: { next_reviewers: ['u2', 'u3'] } }665 // `required: true` is enforced by the runtime on APPROVE (never on666 // reject) — node B below has nobody to route to without it.667 id: 'lead_review', type: 'approval', label: 'Lead Review',668 config: {669 approvers: [{ type: 'org_membership_level', value: 'owner' }],670 decisionOutputs: [{ key: 'next_reviewers', label: 'Next Reviewers', type: 'user', multiple: true, required: true }],671 },672 },673 {674 // Node B resolves them at entry from the lead's decision outputs.675 id: 'co_sign', type: 'approval', label: 'Co-sign',676 config: {677 approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }],678 behavior: 'unanimous',679 onEmptyApprovers: 'fail',680 },681 },682 { id: 'approved', type: 'end', label: 'Approved' },683 { id: 'rejected', type: 'end', label: 'Rejected' },684 ],685 edges: [686 { id: 'e1', source: 'start', target: 'lead_review' },687 { id: 'e2', source: 'lead_review', target: 'co_sign', label: 'approve' },688 { id: 'e3', source: 'lead_review', target: 'rejected', label: 'reject' },689 { id: 'e4', source: 'co_sign', target: 'approved', label: 'approve' },690 { id: 'e5', source: 'co_sign', target: 'rejected', label: 'reject' },691 ],692});693```694695Time-word cheat sheet across surfaces (do not mix them up):696697| Surface | Event-time record | Pre-event record | Live record |698|:--------|:------------------|:-----------------|:------------|699| Flow condition / `{…}` template | `record` (trigger snapshot) | `previous` | — (use a `get_record` node) |700| Approval `expression` approver | `trigger.*` | `vars.previous` | `current.*` |701702Object-hook `ctx` is a different vocabulary — see **objectstack-data**703`references/data-hooks.md`.704705### Node Config (`ApprovalNodeConfigSchema`)706707| Field | Purpose |708|:------|:--------|709| `approvers` | Who may act (≥ 1 — see Approver Types above). Each approver may carry an optional **`group`** label (e.g. `{ type: 'position', value: 'auditor', group: 'finance' }`) — with `behavior: 'per_group'`, approvers sharing a label form one group; unlabelled approvers each form their own |710| `behavior` | `first_response` (first approver decides), `unanimous` (all must approve), `quorum` (`minApprovals` of N — M-of-N collective sign-off), or `per_group` (EACH approver `group` must reach `minApprovals` — one-from-each-group sign-off, 会签). In every mode a single rejection finalizes the node as `rejected`. Default `first_response` |711| `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Omitted ⇒ ALL resolvable approvers under `quorum`, `1` per group; clamped at runtime so a misconfiguration can never deadlock |712| `lockRecord` | Lock the triggering record from edits while pending. Default `true` |713| `approvalStatusField` | Business-object field to mirror `pending`/`approved`/`rejected`/`recalled` onto (should be readonly) |714| `onEmptyApprovers` | What an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through). Declare it explicitly on any node with an `expression` approver (linted) |715| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple?, required? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `<nodeId>.<key>` variables; undeclared keys reject the decision; `decision`/`requestId` reserved |716| `escalation` | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\|auto_approve\|auto_reject\|notify, escalateTo?, notifySubmitter }`. `timeoutHours` is **calendar (wall-clock) hours** — nights, weekends and holidays count; the platform ships no business-hours calendar. `escalateTo` is a **position machine name** (expanded to its holders via `sys_user_position`, ADR-0090 D3) or a specific user id — never a membership tier. `reassign` without `escalateTo` degrades to notify (linted) |717| `maxRevisions` | ADR-0044 — max **send-backs-for-revision** per run before auto-reject. Default `3`; `0` disables send-back. Only meaningful when the node has a `revise` out-edge |718719### Branching, side-effects & rejection720721These are wired on the **graph**, not in node config:722723- **Conditional step** — put a `decision` node before the Approval node, or a724 `condition` on the edge entering it (the old per-step `entryCriteria`).725- **On approve / on reject** — wire downstream nodes (`update_record`,726 `http`, a `notify` node, …) to the `approve` / `reject` out-edge.727- **Roll back on reject** — route the `reject` edge as a **back-edge** to an728 earlier node so the submitter can revise (the old `back_to_previous`).729- **Send back for revision (ADR-0044)** — distinct from a plain reject: a730 `revise` out-edge into an **`approval_revise`** window, closed by a731 `type: 'back'` resubmit edge. See *Send-back for revision* above.732- **Hard reject** — route the `reject` edge to an `end` node (the old733 `reject_process`).734735### Approval Best Practices7367371. **Gate entry on the edge** (`condition` into the Approval node) so the flow738 only pauses for records that actually need sign-off.7392. **Set `approvalStatusField`** to mirror status onto the row — views and740 formulas can then filter on it without joining `sys_approval_request`.7413. **Keep `lockRecord: true`** unless you have a strong reason to allow742 edits while pending — otherwise approvers chase a moving target.7434. **Model rejection as a visible branch** — a back-edge to revise, or an `end`744 node to terminate. The path is on the diagram, not hidden in config.7455. **Notify from downstream nodes** wired to the `approve` / `reject` edges746 rather than expecting the node to send mail itself.747748---749750## Triggers — Event-Driven Automation751752A `record_change` flo753754…(truncated)