| name | objectstack-automation |
| description | Design ObjectStack automation โ Flows (visual logic), Triggers, Approvals, state machines, scheduled jobs, and webhooks. Use when the user is adding `*.flow.ts`, wiring an event-driven rule, or modelling an approval chain. 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.
|
| license | Apache-2.0 |
| compatibility | Requires @objectstack/spec 16.x (Zod v4 schemas) |
| metadata | {"author":"objectstack-ai","version":"1.2","domain":"automation","tags":"flow, workflow, trigger, approval, state-machine, scheduled, webhook"} |
Automation Design โ ObjectStack Automation Protocol
Expert instructions for designing business automation using the ObjectStack
specification. This skill covers Flows (visual logic orchestration), state
machines & approvals, Triggers (event-driven automation), and ETL
pipelines.
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).
- You are designing an ETL pipeline for data synchronisation.
Predicates and conditions are CEL. Every condition / guard /
entryCondition / filter value in this skill is an Expression
envelope evaluated by @objectstack/formula. Use the P\...`andcel`...`tagged templates from@objectstack/spec. See the **objectstack-formula** skill for the full CEL contract, stdlib (now(), today(), daysFromNow(n), daysBetween(a, b), isBlank(v), coalesce(v, fb)`),
and the legacy โ CEL translation 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) |
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 bounded body region over a collection |
map | Sequential multi-instance โ invoke a subflow once per item of a collection; each iteration may pause (batch approvals) |
parallel_gateway | Fork execution into parallel branches |
join_gateway | Synchronise parallel branches back together |
wait | Pause execution until a timer elapses or a named signal arrives |
boundary_event | Attach to another node โ fires on timeout or error |
subflow | Invoke another flow (reusable composition) |
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 | Dispatch to a registered callable โ config.actionType (email/slack) or a registered config.function. Inline config.script JS is not executed (see pitfall 9) |
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) |
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,
isOutput: false,
},
{
name: 'approval_result',
type: 'boolean',
isInput: false,
isOutput: true,
},
],
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 ignored.
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.
Writing a readonly field? Set runAs: 'system'. readonly: true
governs the end-user surface: under the default runAs: 'user', the engine
silently strips a readonly field from an update_record payload
(#2948) โ the step reports success but the value never lands. 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.
{
name: 'escalate_overdue_cases',
label: 'Escalate Overdue Cases',
type: 'schedule',
runAs: 'system',
nodes: [
{
id: 'start',
type: 'start',
label: 'Daily at 09:00',
config: { schedule: { type: 'cron', expression: '0 9 * * *' } },
},
{
id: 'escalate_overdue',
type: 'update_record',
label: 'Escalate Overdue Cases',
config: {
objectName: 'support_case',
filter: {
status: { $in: ['new', 'open'] },
due_date: { $lt: '{TODAY()}' },
},
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,
},
},
{ 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' },
],
}
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',
message: 'Invalid status transition.',
initialStates: ['new'],
transitions: {
new: ['open'],
open: ['escalated', 'resolved'],
escalated: ['open', 'resolved'],
resolved: ['open', 'closed'],
closed: [],
},
}
Notes:
- One rule per field. Parallel lifecycles (e.g.
status + payment_status)
are N separate state_machine rules, one per field.
initialStates (optional, #3165) 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 /metadata/objects/: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 when no FSM governs the field).
- 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.
The old process-level concepts re-home onto the flow graph + node config โ see
the re-home table below. The approval state (sys_approval_request /
sys_approval_action, the record lock, the status mirror, approver
resolution) is unchanged and still owned by plugin-approvals.
{
name: 'opportunity_discount_approval',
label: 'Opportunity Discount Approval',
type: 'record_change',
nodes: [
{
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',
lockRecord: true,
approvalStatusField: 'approval_status',
},
},
{ 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',
condition: cel`record.amount > 100000` },
{ id: 'e2', source: 'manager_review', target: 'needs_director', label: 'approve' },
{ id: 'e3', source: 'manager_review', target: 'rejected', label: 'reject' },
{ 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 a wait node 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โโโโถ wait (signal; 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 ordinary wait node (signal flavour).
type: 'back' resubmit edge โ the edge from the wait node 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 },
},
{ id: 'wait_revision', type: 'wait', label: 'Awaiting Revision',
config: { eventType: 'signal', signalName: 'budget_revision' } },
{ id: 'rev', source: 'manager_review', target: 'wait_revision', label: 'revise' },
{ id: 'back', source: 'wait_revision', target: 'manager_review', label: 'resubmit', type: 'back' },
Two mistakes the compile-time flow lint flags: a revise edge whose wait node
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.
Re-homing the old process model
If you've seen the pre-ADR-0019 ApprovalProcess.create({...}) shape, every
concept maps onto the flow:
| Old process concept | Now |
|---|
steps: [...] (linear list) | successive Approval nodes joined by edges |
entryCriteria (process or step) | a condition on the edge entering the node |
onApprove / onReject actions | downstream nodes wired to the approve / reject out-edge |
rejectionBehavior: 'back_to_previous' | a back-edge to an earlier node |
rejectionBehavior: 'reject_process' | the reject edge routed to an end node |
approvers / behavior / lockRecord / approvalStatusField / escalation | the Approval node's config (ApprovalNodeConfigSchema) |
There is no approvals: [...] stack collection anymore โ approval flows live in
your normal flows: [...].
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.
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 better-auth org-membership tier โ value is one of owner/admin/member, and nothing else. 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) |
queue | A data-ownership queue |
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. Default 1; clamped at runtime to the resolvable approver count 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) |
escalation | Optional per-node SLA โ { enabled, timeoutHours, action: reassign|auto_approve|auto_reject|notify, escalateTo?, notifySubmitter }. 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: an
Approval node can emit a third decision
revise on a revise-labeled
out-edge that routes to a rework wait point. The submitter edits and
resubmits, re-entering the node via an edge type: 'back' (a declared
back-edge โ traversed at run time but excluded from DAG cycle validation).
maxRevisions (node config, default 3) caps the loop before auto-reject.
- 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 flow fires automatically on a data event. There is no
standalone trigger object and no top-level trigger / event key โ the
binding lives entirely in the flow's start node config, which the
automation engine parses (resolveTriggerBinding) and wires to the matching
ObjectQL lifecycle hook.
Prerequisite โ enable the triggers capability
Record-change, schedule, and inbound-webhook (api) triggers ship behind the
triggers capability. Without it the flows register but never fire. Add it
to the package config:
defineStack({
requires: ['automation', 'triggers'],
});
Inbound webhook (api) triggers (ADR-0041 Tier 1)
An api flow can be bound to an inbound HTTP endpoint:
POST /api/v1/automation/hooks/:flowName/:hookId. Configure it on the start
node config (the start config is a free-form record, so these keys are
read at runtime, not Zod-validated):
config key | Purpose |
|---|
hookId | URL path token (default 'default'). Rotate it to revoke a leaked endpoint |
secret | HMAC-SHA256 shared secret. Strongly recommended โ without it unsigned posts are accepted and a warning is logged |
- Signature: sender sends
x-objectstack-signature: sha256=<hex> (GitHub/Stripe style).
- Idempotency:
x-idempotency-key dedupes retries โ author the flow to be idempotent (delivery is at-least-once).
- Queue-backed: the endpoint ACKs
202 and enqueues; the flow runs on the consumer, never in-band. Requires the queue service (see prerequisite).
- The JSON body surfaces to the flow as the trigger record (
record.* / bare fields) plus params.
Trigger Types (start-node config.triggerType)
triggerType | Fires | ObjectQL hook |
|---|
record-before-create | before insert (can modify/reject) | beforeInsert |
record-after-create | after insert | afterInsert |
record-before-update | before update | beforeUpdate |
record-after-update | after update | afterUpdate |
record-before-delete | before delete | beforeDelete |
record-after-delete | after delete | afterDelete |
Trigger Configuration โ on the start node
{
name: 'notify_on_escalation',
label: 'Notify on Escalation',
type: 'record_change',
nodes: [
{
id: 'start',
type: 'start',
label: 'On Case Escalated',
config: {
objectName: 'support_case',
triggerType: 'record-after-update',
condition: cel`previous.status != 'escalated' && record.status == 'escalated'`,
},
},
],
}
previous and record are the CEL variables available in update
triggers โ previous.x is the value before the change, record.x is the
value after. (Salesforce-flavor OLD / NEW were removed in M9.5 and now
evaluate to null.) See objectstack-formula.
Time-relative triggers โ scheduled per-record date sweep
Don't express "act N days before/after a date" (renewal reminders, "expiring
soon", overdue sweeps) as a record_change flow gated on date-equality
(end_date == daysFromNow(60)) โ that predicate is only evaluated when the
record happens to change, so unattended it almost never fires. Use a
declarative time-relative trigger: a schedule-type flow whose start node
carries a timeRelative descriptor is swept on a schedule (daily by default)
and launched once per record whose date field falls in the window. The record
is on the context, so the start condition and {record.*} interpolation work
exactly as for a record-change flow โ and because the window is evaluated every
day, a threshold is never missed.
{
name: 'renewal_alert',
label: 'Renewal Alert',
type: 'schedule',
runAs: 'system',
nodes: [
{
id: 'start', type: 'start', label: 'Daily Sweep',
config: {
timeRelative: {
object: 'contracts',
dateField: 'end_date',
offsetDays: [60, 30, 7],
filter: { status: 'active' },
},
},
},
],
edges: [ ],
}
Exactly one of offsetDays (discrete T-minus days) or withinDays (a range;
negative = overdue) is required. Ships in @objectstack/trigger-schedule โ
needs requires: ['automation', 'triggers'] plus 'job' (the sweep cadence
runs on the job service). Full descriptor schema:
node_modules/@objectstack/spec/src/automation/time-relative-trigger.zod.ts.
Best Practices
Flow Design
- Keep flows small and composable. Use
subflow nodes to break complex
logic into reusable parts.
- Always handle errors. Add
boundary_event nodes for timeout and error
scenarios.
- Use variables for all dynamic values. Never hard-code record IDs or
API keys in node config.
- Prefer
get_record over multiple http calls when the data
lives in ObjectStack.
- Always set
timeoutMs on http nodes. Unset means no timeout at
all โ a hung endpoint stalls the run indefinitely.
State Machine Design (ADR-0020)
- Author it as a
state_machine validation rule on the object, not a
workflow metadata type (retired) โ one rule per state field.
- Define explicit transitions โ
{ from: [allowedTo] }. A state mapped to
[] is a final/dead-end state.
- Don't rely on implicit "any โ any" โ an update to a
from state not
listed as a key is treated leniently (no lock), so list every state you want
guarded.
- Put guards in a sibling
script / conditional rule, not in the
transition table (the machine stays a flat table).
- Put side-effects (emails, notifications, task creation) in a
record-triggered Flow (ADR-0019), not on the transition.
Trigger Design
- Prefer
record-after-* triggers unless you need to modify/reject the record.
- Avoid infinite loops: Do not update the same object in a
record-after-update trigger without a guard condition.
- Use the start-node
condition to narrow when the trigger fires โ avoid
running expensive logic on every save.
Common Pitfalls
- Circular flow references. Flow A calls Flow B which calls Flow A. Use
a depth counter or
visited set to detect cycles.
- Unmatched
parallel_gateway / join_gateway. Every fork must have a
corresponding join.
- Missing
end node. Every path through the flow must terminate.
record-before-* trigger throwing unhandled errors. This silently
prevents the record operation โ always provide a user-friendly error message.
- Scheduled flows without idempotency. If the flow runs twice
accidentally, the result should be the same.
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
- 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}'
-
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}' }
-
Time-relative rules ("alert N days before a date") are SCHEDULE flows, not
record-change date-equality. record.end_date == daysFromNow(60) on a
record-* trigger only fires if the record happens to be written on that exact
day โ unattended rules never run. And date EQUALITY never matches anyway: a
date field carries a time component, so field == daysFromNow(N) (or
{ $in: [daysFromNow(N), โฆ] }) compares two differently-timed timestamps and
silently returns nothing (build warns flow-date-equality-filter).
โ
A daily schedule flow whose get_record filters each tier as a one-day
window ($gte/$lt), never an equality:
filter: { status: 'active', $or: [
{ end_date: { $gte: '{TODAY() + 7}', $lt: '{TODAY() + 8}' } },
{ end_date: { $gte: '{TODAY() + 30}', $lt: '{TODAY() + 31}' } },
{ end_date: { $gte: '{TODAY() + 60}', $lt: '{TODAY() + 61}' } },
] }
(Use {TODAY() + N} template tokens in CRUD-node filter values โ a
cel\โฆ`envelope is not evaluated there and would be compared as a literal object.) Abutting windows tile the timeline so each record matches exactly one tier โ fires once, idempotent, no guard field. For "days remaining" in the message,daysBetween(today(), record.end_date)`.
-
script nodes must name a callable. Set config.actionType to a built-in
side-effect (email / slack) or config.function to a function
registered via defineStack({ functions: { my_fn: (ctx) => โฆ } }). An empty
script node โ or one pointing at an unregistered function โ fails loudly.
Inline config.script JS is not executed by the built-in runtime (no
server-side sandbox) โ move logic into a registered function.
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):
{ id: 'ai', type: 'script', config: {
function: 'helpdesk.aiTriageStub',
inputs: { ticketId: '{record.id}' },
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.
-
Conditions are bare CEL โ only the stdlib is callable. 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 the full table.
An UNKNOWN function (PRIOR(), a typo'd name) fails the build. And never
wrap a field reference in {โฆ} inside a condition โ that's a template brace
and fails as CEL: write record.x, not {record.x}.
CRM Automation Blueprint
For enterprise automation design, align with this CRM-style structure:
| Automation Type | Typical Location | Pattern |
|---|
| Screen flow | src/flows/*.flow.ts | Use explicit variables, node graph (nodes + edges), and decision branches |
| Approval flow | src/flows/*.flow.ts | A flow with approval node(s); set approvers / behavior / lockRecord / approvalStatusField in node config, branch on approve / reject edges |
| Flow registry | src/flows/index.ts | Export allFlows: Flow[] and register centrally in defineStack({ flows }) |
| Action-to-flow bridge | src/actions/*.actions.ts | Trigger screen flows via Action.type = 'flow' for user-driven automation entry |
Default approach for metadata apps: model business lifecycle in Flow/Approval
metadata first; reserve custom code for edge-case integrations.
Verify your work
Flow predicates fail silently at runtime when malformed: a typo'd field
name, an unknown function, or a {โฆ}-wrapped reference in a condition
evaluates to null/false, so the flow "fires" but does nothing โ and nothing
errors at edit time. (Bare field refs like status == 'open' DO resolve in
start/decision/edge conditions โ the engine flattens the trigger record's
fields into scope โ but record.status == 'open' remains the canonical style.)
Catch it at author time before reporting a flow done:
os validate
This runs the ADR-0032 expression gate over every flow condition, edge guard,
validation rule and sharing rule, exiting non-zero with a
located, corrective message. Remember conditions are bare CEL
(record.status == 'x'); only string node fields use {โฆ} templates โ see
objectstack-formula. In a scaffolded project this is npm run validate.
References
See references/_index.md for the full list of Zod
schemas (with one-line descriptions) โ pointers into
node_modules/@objectstack/spec/src/. Always Read the source for exact field
shapes; do not rely on memory of property names.