Skip to content

Latest commit

 

History

History
1264 lines (1073 loc) · 55.2 KB

File metadata and controls

1264 lines (1073 loc) · 55.2 KB
title Flow Metadata
description Build visual automation with decision trees, data operations, HTTP requests, and screen interactions

Flow Metadata

A Flow is a visual automation that orchestrates business logic through connected nodes. Flows support decision branching, data operations (CRUD), HTTP integrations, script execution, user screens, and subflow composition.

Basic Structure

{/* os:check */}

const approvalFlow = {
  name: 'order_approval',
  label: 'Order Approval Flow',
  type: 'record_change',
  version: 1,
  status: 'active',
  runAs: 'system',

  variables: [
    { name: 'order_amount', type: 'number', isInput: true, isOutput: false },
    { name: 'approved', type: 'boolean', isInput: false, isOutput: true },
  ],

  nodes: [
    { id: 'start', type: 'start', label: 'Start' },
    {
      id: 'check_amount',
      type: 'decision',
      label: 'Check Amount',
      config: {
        // decision expressions use {braces} around variables — see Expressions in flows
        conditions: [
          { label: 'High Value', expression: '{order_amount} > 10000' },
          { label: 'Standard', expression: '{order_amount} <= 10000' },
        ],
      },
    },
    {
      id: 'auto_approve',
      type: 'update_record',
      label: 'Auto Approve',
      config: { objectName: 'order', fields: { status: 'approved' } },
    },
    {
      id: 'request_approval',
      type: 'approval',
      label: 'Manager Approval',
      config: { approvers: [{ type: 'position', value: 'manager' }] },
    },
    { id: 'end', type: 'end', label: 'End' },
  ],

  edges: [
    { id: 'e1', source: 'start', target: 'check_amount', type: 'default' },
    { id: 'e2', source: 'check_amount', target: 'auto_approve', type: 'default', condition: 'order_amount <= 10000' },
    { id: 'e3', source: 'check_amount', target: 'request_approval', type: 'default', condition: 'order_amount > 10000' },
    { id: 'e4', source: 'auto_approve', target: 'end', type: 'default' },
    { id: 'e5', source: 'request_approval', target: 'end', type: 'default' },
  ],
};

Flow Properties

Property Type Required Description
name string Machine name (snake_case)
label string Display label
description string optional Flow description
version number optional Version number (defaults to 1)
status enum optional 'draft', 'active', 'obsolete', 'invalid' (defaults to 'draft')
type FlowType Flow trigger type (see below)
runAs enum optional 'system' (elevated, bypasses RLS) or 'user' (the triggering user, RLS-respecting; the default). A 'user' run that resolved no trigger user has nothing to scope to, so its data operations are refused — declare 'system' for schedule / time-relative / API triggers and for record-change flows fired by a write that carried no user
variables FlowVariable[] optional Input/output variables
nodes FlowNode[] Flow nodes
edges FlowEdge[] Connections between nodes
errorHandling object optional Error handling strategy
successMessage string optional Toast a screen-flow runner shows on terminal success (plain string — {var} is not interpolated)
errorMessage string optional Toast a screen-flow runner shows on failure, instead of the raw error

Flow Types

Type Description Trigger
autolaunched Runs automatically without user interaction Invoked by other flows or API
record_change Triggered by record create/update/delete Data mutation events
schedule Runs on a schedule (cron) Time-based
screen Interactive flow with user screens User clicks a button
api Exposed as an API endpoint HTTP request

Nodes

Each node performs a specific action in the flow.

Node Types

Type Description
start Flow entry point
end Flow termination
decision Conditional branching (if/else)
assignment Set variable values
loop Structured iteration container — runs a body region once per item (ADR-0031)
parallel Structured parallel block — runs N branch regions concurrently, implicit join (ADR-0031)
try_catch Structured try/catch/retry error handling (ADR-0031)
create_record Create a new record
update_record Update existing records
delete_record Delete records
get_record Query records
http Make an HTTP API call
notify Send an outbound notification via the messaging service
script Call a named callable — a registered function (config.function) or a built-in side-effect marker (config.actionType)
screen Display a user form/screen (durable pause)
wait Pause for a timer or named signal (durable pause; timers auto-resume)
subflow Invoke another flow — a pause inside the child suspends both runs as a linked chain
map Sequential multi-instance — run a per-item subflow for each element, one at a time (each may pause); batch approval (ADR-0039)
connector_action Execute an external connector action

connector_action dispatches against the runtime connector registry, which connector plugins populate. The three generic executorsrest, openapi, and mcp — double as provider factories: with them in your app's plugins:, a declarative connectors: entry that names a provider is materialized into a live, dispatchable connector at boot with no plugin code (ADR-0097). Scaffolded projects (npm create objectstack) ship all three executors by default — paired with the automation capability that performs the materialization — and the showcase wires them too. Brand connectors (Slack, …) are installed separately. One security note: a declarative mcp instance using a stdio transport spawns a local process from metadata and is therefore denied by default — the host opts in with new ConnectorMcpPlugin({ declarativeStdio: ['<trusted-command>'] }); http transports need no opt-in. Connectors is the full authoring guide — provider config contracts, credentialRef auth, and failure modes.

Node Structure

{
  id: 'unique_node_id',
  type: 'decision',
  label: 'Check Priority',
  config: { /* type-specific configuration */ },
  position: { x: 200, y: 100 },    // Canvas position (optional)
}
Property Type Required Description
id string Unique node identifier
type string Node type — a built-in id from the table above or a plugin-registered one. Per ADR-0018 the spec does not gate this with a closed enum; it is checked against the live action registry at registerFlow()
label string Display label
config object optional Type-specific configuration — the registered executor's configSchema owns its shape. Keys that schema does not declare are rejected at registerFlow(), and the built-in executors parse() the value against their Zod contract before running (#4277)
connectorConfig object optional { connectorId, actionId, input } for a connector_action node
position { x, y } optional Visual position on canvas
timeoutMs number optional Per-node execution timeout
inputSchema object optional Declared input parameter types, for Studio form generation and runtime validation
waitEventConfig object optional wait-node event descriptor (eventType, timerDuration, signalName, timeoutMs, onTimeout)
boundaryConfig object optional BPMN boundary-event descriptor (interop)
The flow, node, edge, and variable **shells are `.strict()`** — a key they do not declare is a parse error naming the likely intended key, not a silent strip. A node's `config` is open at the flow-parse level (plugin node types keep their own vocabulary), but it is not unchecked: `registerFlow()` rejects config keys the node type's published `configSchema` does not declare — naming the path, the declared key set, and a did-you-mean — and the contract-carrying builtins additionally `parse()` their config at execute time, refusing the node on a type or missing-`required` violation (#4277). A node type that publishes no `configSchema` declares nothing, so nothing can be undeclared.

Node Examples

Decision (branching):

{
  id: 'check_status',
  type: 'decision',
  label: 'Check Status',
  config: {
    conditions: [
      { label: 'Approved', expression: "{status} == 'approved'" },
      { label: 'Rejected', expression: "{status} == 'rejected'" },
    ],
  },
}

Create Record:

{
  id: 'create_task',
  type: 'create_record',
  label: 'Create Follow-up Task',
  config: {
    objectName: 'task',
    fields: {
      title: 'Follow up on {record.name}',
      assignee: '{record.owner}',
      due_date: '{TODAY() + 7}',   // braces required — without them this writes the literal text
    },
  },
}

HTTP Request:

{
  id: 'notify_slack',
  type: 'http',
  label: 'Send Slack Notification',
  config: {
    url: 'https://hooks.slack.com/services/...',
    method: 'POST',
    body: { text: 'New order: {record.name}' },
  },
}

Script:

The built-in script executor never evaluates an arbitrary JavaScript string — it names a callable. Two forms:

  • config.function — a function registered through defineStack({ functions }). config.inputs is {var}-interpolated and handed to it; config.outputVariable binds the returned value as a flow variable, so a later declarative node persists it. This is the supported way to run server logic.
  • config.actionType — one of the two built-in side-effect markers, 'email' or 'slack'. These are logger-backed: they record the intent and succeed, they do not deliver anything — reach for a notify node when you want real delivery. Any other actionType value is treated as a registered-function name — except the marker 'invoke_function', which means "call the function named in config.function" and errors if that key is missing.

Inline config.script (a JS source body) is recognized but not executed — the built-in runtime has no server-side JS sandbox, so such a node warns and no-ops. A script node that names neither a built-in action nor a registered function fails the step loudly rather than passing silently.

{
  id: 'score_lead',
  type: 'script',
  label: 'Score Lead',
  config: {
    function: 'scoreLead',                  // registered via defineStack({ functions })
    inputs: { rating: '{record.rating}' },  // {var} templates resolve against flow variables
    outputVariable: 'leadScore',            // later: fields: { score: '{leadScore}' }
  },
}

It takes its inputs, returns a value, and a later declarative node persists that value. Data I/O stays on the flow graph — the function itself does no writes, and the runtime hands it nothing to write with (ctx carries input / variables / automation / logger, no data engine). The script action publishes this as handlerContract: 'pure' on its action descriptor.

The rule is load-bearing, not stylistic. Run summaries report what a run did to the data, and a script step reports no record metrics precisely because of it: every write a pure function causes is a downstream create_record / update_record that counts itself. A function that writes behind the platform's back makes its run claim it wrote nothing — the durable sys_automation_run row then reads exactly like a broken sweep, permanently.

If a function genuinely must write (an upstream billing system, a legacy API), declare it so the run stays honest:

defineStack({
  functions: {
    scoreLead: (ctx) => ({ score: 42 }),                     // pure — the default
    syncBilling: { handler: syncBilling, effect: 'writes' }, // declared writer
  },
});

A step that calls a declared writer is counted as an effect the platform cannot measure (unmeasured), never as zero — so the broken-sweep query selected > 0 AND acted = 0 AND unmeasured = 0 stops firing on that flow, and keeps working on every other flow that calls a function. Declaring changes what is reported, not what is allowed: an undeclared writer is still counted as having written nothing, and no runtime check can catch it — a function is ordinary host code and may close over a client at module scope.

Screen (flat fields):

The default shape. Each field is collected as a bare flow variable, so a later node reads {subject}, not {screen_1.subject}.

{
  id: 'screen_1',
  type: 'screen',
  label: 'Conversion Details',
  config: {
    fields: [
      { name: 'createOpportunity', label: 'Create Opportunity?', type: 'boolean',
        required: true, defaultValue: false },
      { name: 'opportunityName', label: 'Opportunity Name', type: 'text',
        required: true,
        visibleWhen: 'createOpportunity == true' },
    ],
  },
}

visibleWhen is bare CEL over the screen's own field names — not the {var} template dialect the rest of a node's config uses. The client re-evaluates it as the user types, against the values collected so far, which is why it cannot be a server-interpolated template. Omit it for a field that is always visible.

A hidden field is not collected, and is not enforced as required — so required on a visibleWhen field means "required when shown". That is what lets the example above ask for an opportunity name only when one is being created.

Two things worth knowing:

  • Give a required boolean a defaultValue. An untouched checkbox holds no value at all, which counts as unanswered — without defaultValue: false the user cannot express "no" by leaving it clear.
  • A broken predicate fails open (the field stays visible) rather than hiding an input the flow may be waiting on. registerFlow() does check the predicate as bare CEL — a {var} template or a syntax error is a loud registration failure that quotes the source and locates it as config.fields[N].visibleWhen — but a predicate that parses and simply names the wrong field still shows up as a field that never hides.

Screen (object form):

Set config.objectName to instead render an object's entire create/edit form — including any master-detail child grids (inlineEdit) — as one wizard step. The client renders the object form and, on save, persists the record itself (parent + inline children atomically); the flow then resumes with the new record's id bound to config.idVariable so a later step can reference it.

{
  id: 'new_opportunity',
  type: 'screen',
  label: 'Opportunity Details',
  config: {
    objectName: 'opportunity',         // render this object's full form
    mode: 'create',                    // 'create' (default) | 'edit'
    // recordId: '{account_id}',        // required for mode: 'edit'
    defaults: {                        // pre-filled values (interpolated)
      account: '{account_id}',
      stage: 'prospecting',
    },
    idVariable: 'opportunity_id',      // bind the saved record's id for later steps
  },
}

This is how a single flow walks the user through several full object forms in sequence (e.g. lead → account → opportunity), each step saving its own record.

Structured control flow (ADR-0031)

loop, parallel, and try_catch are structured control-flow constructs — the native, AI-authored model for iteration, concurrency, and error handling. Unlike free-form BPMN gateways (kept in the protocol for import/export interop only), these constructs are well-formed by construction: each owns its body as a self-contained, single-entry/single-exit region carried in config (representation B — a nested sub-graph). Ordinary step-to-step edges stay acyclic — iteration and concurrency are scoped containers, not back-edges — so the DAG invariant is preserved and termination stays analyzable. Regions are validated at registerFlow() (single-entry/single-exit, acyclic, bounded loop); a malformed construct is rejected before the flow can run.

A region runs in the enclosing variable scope (the iterator value and any body mutations are visible to the surrounding flow) — it is not a separate subflow invocation. The container node's ordinary out-edges are the "after-loop / after-block" continuation.

Loop container

Runs its body region once per item of a collection, binding the current item (and optionally its index) as flow variables, under a hard max-iteration guard.

{
  id: 'notify_each',
  type: 'loop',
  label: 'For each task',
  config: {
    collection: '{tasks}',        // template/variable resolving to an array
    iteratorVariable: 'task',     // current item, visible inside the body
    indexVariable: 'i',           // optional zero-based index
    maxIterations: 500,           // hard cap (clamped to the engine ceiling)
    body: {                       // single-entry/single-exit region
      nodes: [
        { id: 'send', type: 'script', label: 'Notify', config: { /* … */ } },
      ],
      edges: [],
    },
  },
}

A loop node with no body keeps the legacy flat-graph behavior — the container is additive.

Parallel block

Declares N branch regions that run concurrently and join implicitly when all complete — there is no author-visible split/join gateway. Branches should write distinct variables (last-writer-wins on collision); a failing branch fails the block.

{
  id: 'fan_out',
  type: 'parallel',
  label: 'Notify in parallel',
  config: {
    branches: [                   // ≥ 2 regions
      { name: 'Email', nodes: [{ id: 'email', type: 'script', label: 'Email', config: { /* … */ } }], edges: [] },
      { name: 'Slack', nodes: [{ id: 'slack', type: 'script', label: 'Slack', config: { /* … */ } }], edges: [] },
    ],
  },
}

Try / catch / retry

Wraps a protected try region; on failure runs the optional catch region (with the caught error bound to errorVariable), optionally retrying the try region first with exponential backoff. This surfaces the engine's existing fault + retry semantics as a structured construct (rather than BPMN boundary events).

{
  id: 'guarded',
  type: 'try_catch',
  label: 'Charge with fallback',
  config: {
    try: { nodes: [{ id: 'charge', type: 'http', label: 'Charge', config: { /* … */ } }], edges: [] },
    catch: { nodes: [{ id: 'flag', type: 'update_record', label: 'Flag failure', config: { /* … */ } }], edges: [] },
    errorVariable: '$error',
    retry: { maxRetries: 3, retryDelayMs: 1000, backoffMultiplier: 2 },
  },
}

BPMN parallel_gateway / join_gateway / boundary_event remain in the protocol as the interop representation and map onto these constructs on import/export — they are not the native authoring model.

Durable pause & resume (ADR-0019)

Some nodes don't finish in one pass — they suspend the run and wait for the outside world. The engine snapshots the run (variables, step log, position) to the sys_automation_run table and returns { status: 'paused', runId }; the pause survives a process restart and is continued later through a single entry point:

POST /api/v1/automation/{flow}/runs/{runId}/resume
{ "inputs": { … }, "branchLabel": "approve", "output": { … } }
Pausing node Suspends until… Resumed by
approval a human decision the approvals service (POST /api/v1/approvals/requests/:id/approve|reject) — resumes down the matching approve / reject edge. Decide through the approvals API; the resume route above refuses an approval pause outright (see below).
screen a user submits the form the UI runner posting the collected inputs; a paused response carrying the next screen chains multi-step wizards under one stable runId
wait (timer) an ISO-8601 duration elapses automatically — a one-shot job resumes the run; after a cold boot the engine re-arms pending timers from the durable store (overdue timers resume immediately)
wait (signal) a named external event any caller invoking resume(runId)

Who may resume — the gate is the suspended node

The resume route is generic, so the node the run is parked on decides whether a raw resume is a legitimate continuation. Every action descriptor carries a resumeAuthority:

  • 'any' (the default — screen, wait, your own pausing nodes): the caller supplies the continuation and the route is the intended door.
  • 'service' (approval): continuing is a side effect of a decision that some service must authorize and record first, so only that service may drive it. ApprovalService.decide enforces the approver slate, writes the sys_approval_action row, mirrors the status field — then resumes.

A resume of a 'service' pause through the route answers 403 and changes nothing: the request stays pending and the run stays parked, so the real decision can still land. (Before this gate a raw resume walked the approve edge with no decision recorded, leaving the sys_approval_request row and the run permanently disagreeing.)

The gate follows a linked-run pause down to the child, so the run id a launcher happens to hold is not a way around it — in both directions it reads the item rather than the loop:

  • subflow — the signal is delegated to the suspended child, so the child's node is where it lands;
  • map — the signal is not delegated (the node re-runs to start the next item), so continuing here would advance the batch past an item whose decision is still open. Refused while that item is service-gated; the map moves on when the item completes through its owning service.

A second rule on the same seam: a resume signal may not write the engine's $ namespace ($runId, $record, $flowName, <nodeId>.$mapItemDone, …) — those are the engine's own handoff variables, and a caller who could set them could forge a map item's recorded result or re-point the run id an approval / wait node correlates on. It covers both signal fields: inputs land under their plain names, and output keys land under the suspended node's id — which for a map-parked run is the map node itself, i.e. the very same reserved key. A reserved name answers 400, nothing is applied (not even legitimate keys sent alongside it), and the run stays parked. Ordinary author variables are unaffected, $ mid-name (price$) included.

Registering a pausing node of your own? Declare resumeAuthority: 'service' on its descriptor when the decision to continue belongs to your service rather than to whoever holds the run id.

Parallel approvals — one aggregating node, not two pauses

"Finance and legal must both sign off, concurrently" is one approval node with two approver groups and behavior: 'unanimous' — not two parallel pauses. On entry the node opens a single sys_approval_request whose pending_approvers holds both groups (notified concurrently); it stays suspended until every group has approved (the aggregation / AND), then resumes down approve. Any one rejection finalizes immediately down reject.

{
  id: 'dual_signoff',
  type: 'approval',
  label: 'Finance + Legal Sign-off',
  config: {
    approvers: [
      { type: 'position', value: 'finance' },
      { type: 'position', value: 'legal' },
    ],
    behavior: 'unanimous',   // 'first_response' = any one decides
    lockRecord: false,
  },
}

This is the aggregating-node pattern (Camunda multi-instance / Step Functions Map): the run keeps a single program counter and pauses once, so it needs no concurrent-token machinery. Durable pause inside a hand-drawn parallel branch or loop iteration — where two unrelated positions pause independently — is a separate, larger capability (ADR-0039 Track B); the aggregating node covers the common parallel/batch-approval demand without it. Worked example: showcase_invoice_signoff in the showcase app.

Batch approval — a map node, one item at a time

"Sign off every task in the release, in turn" is a map node: it runs a per-item subflow for each element of a collection, sequentially. Unlike loop (whose body runs synchronously and cannot pause), each item is a full child run, so the per-item subflow may durably pause on its own approval — the map waits for that item's decision, then moves to the next.

{
  id: 'signoffs',
  type: 'map',
  label: 'Sign off each task',
  config: {
    collection: '{items}',          // array (e.g. the release's tasks)
    iteratorVariable: 'task',
    flowName: 'one_task_signoff',    // the per-item subflow (it may pause)
    itemObject: 'showcase_task',     // when an item is a record, it becomes the child's `$record`
    outputVariable: 'signoffResults',// each item's subflow output, collected in order
  },
}

The run holds a single program counter the whole time: only one item's approval is open at any moment; when it is decided the engine re-enters the map node to start the next item. Resuming the map's own run id while an item is still awaiting a service-gated decision is refused (403) — otherwise the batch would step past that item as if it had been decided. v1 is sequential and fail-fast (the first item whose subflow fails fails the map). Concurrent fan-out (all items at once) is the larger ADR-0039 Track B work. Worked example: showcase_release_signoffshowcase_one_task_signoff.

Nested pause — pausing inside a subflow

A pausing node inside a subflow suspends the whole chain as linked runs: the child run pauses at its node, and the parent pauses at the subflow node (correlation: 'subflow:<childRunId>'). Both continuations are durable, and the chain resolves from either end:

  • deciding the child's approval (or its timer firing) completes the child and bubbles its output back into the parent, which continues with the same ${nodeId}.output / outputVariable mapping as a synchronous subflow;
  • resuming the parent run id (what a UI holds from the original launch) delegates down to the suspended child — multi-screen child wizards keep the parent run id stable. The resume gate delegates with it: if the child is parked on an approval, resuming the parent is refused too.

A child that fails terminally after the pause fails every waiting ancestor, so no run is stranded as resumable-forever. See the worked example pair in the showcase app: showcase_project_closure invokes the reusable showcase_closure_signoff approval subflow and notifies the owner with the bubbled decision.

Observing runs

GET /api/v1/automation/{flow}/runs                 # run history (status, steps, error)
GET /api/v1/automation/{flow}/runs/{runId}         # one run
GET /api/v1/automation/{flow}/runs/{runId}/screen  # re-fetch a paused screen

Each run's steps[] records every executed node — including loop iterations, parallel branch bodies, and try/catch region steps — which the Studio flow designer surfaces, nested by iteration / branch / handler, in its Runs side panel. Recent runs are held in an in-memory ring buffer; terminal runs (completed / failed) are also mirrored to sys_automation_run as durable history with a bounded step log, so listRuns / getRun still report a run's status, steps, and failure reason after a restart or ring-buffer eviction.

Run summaries

A run that reports success: true has not told you it did its job. A scheduled sweep that selects thirty records and writes none looks identical to one with nothing to do: same green status, same empty output, same silence. Every terminal run therefore carries a summary — on the AutomationResult, on the run in listRuns / getRun, and in the log:

[automation] run flow=stalled_deal_sweep run=run_a1b2 status=completed durationMs=142 selected=30 acted=0 skipped=30 gate=check_stalled->send_nudge:30
Field Meaning
selected Records read by the run's data nodes
acted Records created / updated / deleted, plus effects dispatched (notifications delivered)
skipped Node executions a closed gate prevented — one per loop iteration whose conditional edge evaluated false
unmeasured Executions that reached something the platform cannot count — see below
nodes[] Per-node terminal status with runs / failures / skipped and its own selected/acted
gates[] Which gates closed and how often, most-skipped first

The counts come from the node executors themselves — get_record reports what it matched, the write nodes report what the data engine actually changed — not from the engine guessing at a node's output shape. A node that touches no records (decision, assignment) reports nothing at all, which is different from reporting zero.

When the platform cannot count

Some nodes reach outside the platform, and for those acted has a third possible answer. A connector_action dispatches to an external system through a descriptor that declares nothing about whether the action reads or writes, so counting it 0 would understate a Salesforce create and counting it 1 would overstate a lookup — and the overstatement is the worse one, because it makes the broken-sweep alert never fire. Those executions increment unmeasured instead:

Node Reported
http, GET/HEAD/OPTIONS acted: 0 — a read can never write
http, mutating method, response OK acted: 1
http, mutating method, rejected / timed out unmeasured — a 500 can arrive after the write landed
http, durable: true acted: 1 — the outbox row is a real, durable effect
connector_action unmeasured
script, function declared pure (the default) nothing — a registered function is contractually pure: data I/O stays on the flow graph, so every write it causes is a downstream node that counts itself
script, function declared effect: 'writes' unmeasured — the function said it writes where the platform cannot see, so the run says the count is incomplete

The script row is a contract, not a measurement: nothing stops a registered function from writing, so an undeclared writer still makes its run report acted: 0. That is why the declaration exists and why it is worth using — see the purity callout. It is also why the reverse fix was rejected: marking every script step unmeasured would blind the detector on every flow that calls any function, to cover the few that break the rule.

unmeasured propagates through subflow and map roll-ups, so a parent whose child dispatched an uncountable effect knows its own acted is incomplete.

The same counts land on sys_automation_run as queryable columns (selected_count, acted_count, skipped_count, unmeasured_count, plus a summary_json breakdown), so a broken sweep is something you can alert on rather than notice:

// Runs that selected work and did none of it, newest first.
const suspect = await engine.find('sys_automation_run', {
  where: {
    status: 'completed',
    selected_count: { $gt: 0 },
    acted_count: 0,
    // Without this clause the alert fires on every healthy connector-driven
    // flow: those runs report acted 0 because the count is INCOMPLETE, not zero.
    unmeasured_count: 0,
    started_at: { $gte: since },
  },
  orderBy: [{ field: 'started_at', order: 'desc' }],
});

selected > 0 && acted == 0 && unmeasured == 0 over several consecutive runs is a near-perfect broken-sweep detector — the case that is otherwise invisible, because nobody is watching automation until it has already been dead for a month. A single such run is not proof of anything: a sweep whose work is all already done reports the same thing legitimately, which is why the signal is consecutive runs, and why the platform reports the counts rather than raising the alarm itself.

Rows written before summaries existed carry null counts, not 0 — "not measured" must not read as "measured zero". The log line defaults to info; AutomationServicePlugin's runSummaryLog: 'debug' | 'off' turns the volume down on a very high-frequency flow without turning the measurement off.

Edges

Edges connect nodes and define the execution path:

{
  id: 'edge_1',
  source: 'check_status',
  target: 'send_email',
  type: 'default',
  condition: "status == 'approved'",
  label: 'Approved',
}
Property Type Required Description
id string Unique edge identifier
source string Source node ID
target string Target node ID
type enum optional 'default' (success), 'fault' (error), 'conditional' (expression-guarded), or 'back' (declared back-edge, ADR-0044); defaults to 'default'
condition string optional Boolean CEL predicate for branching (a bare string is stored as { dialect: 'cel', source })
label string optional Label displayed on the connector — cosmetic only. It does not select a path except on a branching node (decision / approval), which picks its out-edge by label.
isDefault boolean optional BPMN default-flow marker (interop). Accepted by the schema, but the engine does not read it — traversal selects by condition and by label. On a decision node, the fallback is the out-edge labelled default: when no conditions[] entry matches, the node emits branchLabel: 'default'

Fault edges — handling a failed node

A fault edge routes a failed node to a handler instead of aborting the run. Without one, any node failure ends the run.

edges: [
  { id: 'e_ok',    source: 'charge_card', target: 'mark_paid' },
  { id: 'e_fault', source: 'charge_card', target: 'flag_for_review', type: 'fault' },
]
**`type: 'fault'` is what routes — a label is not.** Writing `{ source: 'charge_card', target: 'flag_for_review', label: 'error' }` without the type leaves an ordinary edge, and every unconditional out-edge is traversed (in parallel) on **success**. The handler then runs whenever the node *succeeds*, never when it fails, and the run still aborts on failure. Both halves are silent, so `os validate` reports this as `flow-error-label-not-fault`.

The handler reads what went wrong from two variables:

Variable Scope Use
{<nodeId>.error} the failing node {charge_card.error} — addressable by name, so one handler shared by several fault edges can tell which node it is handling
{$error} run-wide {$error.nodeId} / {$error.message} — the most recent failure only

A run that takes a fault branch and completes reports success, but the failed step stays in the run trace with status: 'failure' and its message — recovery never erases the record of what failed.

**A fault edge does not disable a guardrail.** Failures come in two classes, and only one is routable.
  • Runtime — the world did not cooperate, and a later run could succeed: an http node got a 404, a connector rate-limited or is degraded, the data engine rejected a write, a collection arrived the wrong shape, a subflow failed on its own. Routed to the fault edge.
  • Guard — a refuse-to-execute check found the metadata wrong. Never routed: these stay fatal whether or not a fault edge exists, and the run fails with the guard's own message.

A failure is a guard when both hold: re-running the flow unchanged could never succeed, and the fix is to edit metadata. In practice that is a missing required config key (a data node with no objectName, an http node with no url, a subflow or map with no flowName, a connector_action with no connectorId/actionId), a filter token that resolved to nothing so the condition was dropped from the query, a graph that recurses past the nesting ceiling, or a run that would execute unscoped.

The split is deliberate. A dropped filter condition does not narrow a query, it widens it — so if guards were routable, one fault edge on a delete_record would turn off the protection against emptying the object while the run still reported success. Re-running changes nothing either: the fix is to correct the metadata, which os validate will point at.

Fault edges vs. errorHandling.retry

The two recovery mechanisms operate at different scopes and do not compound:

Scope On failure
fault edge one node traversal continues from the handler; the run completes
errorHandling: { strategy: 'retry', maxRetries: n } the whole flow the flow re-runs from the start, up to n more times

A failure a fault edge handled is not a flow failure, so it does not consume a retry. That matters because flow-level retry replays every node that already succeeded — a second notification, a second created record. Prefer a fault edge where the failure is local; reach for retry only when replaying the whole flow is genuinely safe.

Variables

Flows use variables to pass data between nodes and to/from callers:

{/* os:check */}

variables: [
  { name: 'input_id', type: 'text', isInput: true, isOutput: false },
  { name: 'result', type: 'object', isInput: false, isOutput: true },
  { name: 'counter', type: 'number', isInput: false, isOutput: false },
]
Property Type Description
name string Variable name
type string 'text', 'number', 'boolean', 'object', 'list'
isInput boolean Available as input parameter
isOutput boolean Available as output parameter

Error Handling

Configure how errors are handled during flow execution:

errorHandling: {
  strategy: 'retry',           // 'fail' | 'retry' | 'continue'
  maxRetries: 3,
  retryDelayMs: 5000,
}
Property Type Description
strategy enum 'fail' (stop) or 'retry' (re-run the whole flow). 'continue' parses but the engine branches only on 'retry', so it behaves exactly like 'fail' — use a fault edge to keep going past a failed node (default 'fail')
maxRetries number Retry attempts after the initial one, 010. Under strategy: 'retry' it must be at least 1 and there is no default — see below (default 0, i.e. no retries, for the strategies that never retry)
retryDelayMs number Delay between retries (ms) (default 1000)
backoffMultiplier number Exponential backoff multiplier (default 1)
maxRetryDelayMs number Ceiling on the backed-off delay (default 30000)
jitter boolean Randomize the delay to avoid a thundering herd (default false)

maxRetries counts the re-runs, not the total attempts: maxRetries: 2 runs the flow up to three times.

strategy: 'retry' has to say how many times

There is no default retry count. strategy: 'retry' without maxRetries — or with maxRetries: 0 — is refused when the flow is registered:

// ❌ rejected: "retry" that retries zero times is just "fail"
errorHandling: { strategy: 'retry' }

// ✅ state the attempts
errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 5000 }

A retry re-runs the whole flow from the start, so every node that already succeeded runs again — records get created again, callouts fire again. That is too consequential a number to pick on the author's behalf, and picking 0 would make opting into 'retry' do nothing at all. The knobs above are read only under 'retry'; a fully spelled-out block under 'fail' or 'continue' is fine and simply ignored.

Before ObjectStack 17 the count depended on how the flow reached the engine: a flow parsed by `FlowSchema` retried `0` times when the count was unstated, while a hand-built definition passed straight to the engine retried `3` — the schema and the engine each carried a default and they disagreed ([#4247](#4247)). The engine's copy is gone; the schema is the only source, and the case that was ambiguous is now rejected instead of guessed.

Discovery & Registration

You almost never call engine.registerFlow() directly. The AutomationServicePlugin (@objectstack/service-automation) auto-discovers every inline flow definition during its start() phase by reading the ObjectQL schema registry:

// inside AutomationServicePlugin.start()
const ql = ctx.getService('objectql');
const flows = ql.registry.listItems('flow');
for (const flow of flows) {
  engine.registerFlow(flow.name, flow);
}

Any flow attached via the defineStack / manifest.register() pipeline is picked up automatically:

import { defineStack } from '@objectstack/spec';
import { approvalFlow } from './flows/approval';

export default defineStack({
  manifest: { id: 'com.example.crm', version: '1.0.0', type: 'app', name: 'CRM' },
  objects: [...],
  flows: [approvalFlow],   // ← registered with the engine on boot
});

The plugin is a soft dependency on metadata — it tolerates running without MetadataPlugin and it logs (not throws) on per-flow registration failures so one broken flow does not abort startup.

Expressions in flows

A flow mixes three expression dialects, and using the wrong one is the single most common way a flow silently misbehaves. Which dialect applies is decided by where the expression sits — not by what it looks like:

Where Dialect Write it like Bindings
Start-node condition CEL (bare, no braces) record.amount > 500 record.*, previous.*, bare field names, vars.*
Edge condition CEL (bare, no braces) record.status == 'open' same as above
Decision-node conditions[].expression Template compare (braces required) {order_amount} > 10000 flow variables by name, in {…}
Field values in create_record / update_record Interpolation (braces required) 'Follow up on {record.name}', '{TODAY() + 7}' {var}, {var.path}, {$User.Id}, {$User.Email}, {NOW()}, {TODAY()}, {TODAY() + 90} (whole days)
**The two failure modes to memorize:**
  1. Braces missing in a decision expression'order_amount > 10000' isn't evaluated as a variable at all. It compares the string "order_amount" against "10000", which is always true, so the flow always takes the first branch and never tells you. Write '{order_amount} > 10000'.
  2. Braces missing in a field valuedue_date: 'TODAY() + 7' writes the literal text TODAY() + 7 into the field. Write '{TODAY() + 7}'.

The mirror mistake is putting braces into a CEL condition ('{record.amount} > 500') — CEL conditions fail loudly rather than silently, with an error that tells you to drop the braces.

CEL conditions that fail to evaluate raise an error and stop the run — they never quietly pass. See Formulas for the CEL language itself.

**A boolean gotcha:** SQLite/libSQL store booleans as `0`/`1`, and CEL's `1 != true`. Compare against the stored shape (`record.done == 1`) or normalize the value before the condition.

Run a flow via API

Flows of any type can be launched over HTTP — this is what an external system, a scheduled job outside the platform, or the Console's test runner uses:

curl -b cookies.txt -X POST \
  https://your-app.example.com/api/v1/automation/order_approval/trigger \
  -H "Content-Type: application/json" \
  -d '{ "recordId": "ord_123", "objectName": "order", "params": { "priority": "high" } }'
# → { "success": true, "data": { ... }, "meta": { ... } }
Endpoint Purpose
POST /api/v1/automation/:name/trigger Start a flow (canonical)
GET /api/v1/automation/:name/runs List runs (?limit, ?cursor)
GET /api/v1/automation/:name/runs/:runId One run's detail (404 Execution not found)
POST /api/v1/automation/:name/runs/:runId/resume Resume a paused run — body { inputs, output, branchLabel }
GET /api/v1/automation/:name/runs/:runId/screen The pending screen of a screen-flow run

Passing inputs. Declare variables with isInput: true, then send them in params under the same names — only declared inputs are bound. recordId and objectName are lifted into params for you (plus an <objectName>Id alias), and event defaults to 'manual'.

Identity. The caller's identity (user, positions, permissions, tenant) is forwarded into the run, so a runAs: 'user' flow executes under that caller's row-level security. See API Authentication for credentials.

**`success: true` doesn't always mean "it ran".** If the start condition isn't met, the response is a *successful* skip — the run result rides in `data`, and the skip marker sits on its `output`: `{ "success": true, "data": { "success": true, "output": { "skipped": true, "reason": "condition_not_met" } } }`. A self-triggering flow caught by the loop guard reports `reason: "reentrancy_loop_guard"` the same way. Check `data.output.skipped` before assuming the work happened.

Console Flow Viewer & Test Runner

Every flow surfaces in the Console metadata browser under /_console/, where ObjectUI's app-shell registers FlowPreview as the flow metadata preview (registerMetadataPreview('flow', FlowPreview)) in place of the default JSON inspector. It renders the graph on a canvas — editable (add a node, patch a selection) only in the designer's edit mode, read-only otherwise. Opening a flow shows the full-width canvas with no side panel; the toolbar toggles one collapsible panel at a time:

Side panel Component Purpose
Variables The flow's declared variables with their types and input/output flags. Opt-in — it is the fallback panel, not one that opens on its own
Problems ProblemsPanel Validation findings; selecting one reveals the offending node or edge on the canvas
Debug FlowSimulatorPanel Steps the graph client-side, highlighting the active node, the visited nodes, and the traversed edges
Runs FlowRunsPanel Real run history from GET /api/v1/automation/:name/runs, each run's step log nested by loop iteration / parallel branch / try-catch handler

Actually running a flow is a separate Console page — Developer › Flow Runs (developer/flow-runs). Its test runner auto-generates a form for every isInput: true variable (with type-aware coercion for number / boolean / object / list) and calls client.automation.execute(name, { params }) — the same POST /api/v1/automation/:name/trigger endpoint — then renders the returned result. A screen flow that comes back paused is handed to the same FlowRunner the record and list surfaces use, so multi-step wizards (and object-form steps) can be driven to completion from here instead of orphaning a suspended run. A run-history panel beside it lists recent runs and expands one for its step detail. The Console is not project-scoped: the client comes from the app adapter, with no projectId in the route.

Flows in practice

Flows automate business processes as explicit node graphs. Use them for field updates, notifications, record creation, HTTP calls, decisions, waits, screens, subflows, and approval pauses. The old standalone Workflow Rule authoring model is retired; model the same logic as a Flow.

Record-change flow

{/* os:check */}

import type { Flow } from '@objectstack/spec/automation';

export const hotLeadFollowUp: Flow = {
  name: 'hot_lead_follow_up',
  label: 'Hot Lead Follow Up',
  type: 'record_change',
  status: 'active',
  nodes: [
    {
      id: 'start',
      type: 'start',
      label: 'Start',
      config: {
        triggerType: 'record-after-create',
        objectName: 'lead',
        condition: "record.rating == 'hot'",
      },
    },
    {
      id: 'create_task',
      type: 'create_record',
      label: 'Create Follow-up Task',
      config: {
        objectName: 'task',
        fields: {
          subject: 'Follow up on hot lead',
          related_to: '{record.id}',
          priority: 'high',
        },
      },
    },
    {
      id: 'notify_owner',
      type: 'notify',
      label: 'Notify Owner',
      config: {
        recipients: '{record.owner}',
        title: 'New hot lead',
        message: 'Hot lead created: {record.name}',
        channels: ['inbox'],
      },
    },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'create_task' },
    { id: 'e2', source: 'create_task', target: 'notify_owner' },
    { id: 'e3', source: 'notify_owner', target: 'end' },
  ],
};

Scheduled flow

export const contractExpirationCheck: Flow = {
  name: 'contract_expiration_check',
  label: 'Contract Expiration Check',
  type: 'schedule',
  status: 'active',
  nodes: [
    {
      id: 'start',
      type: 'start',
      label: 'Start',
      config: {
        triggerType: 'schedule',
        schedule: { type: 'cron', expression: '0 0 * * *', timezone: 'UTC' },
      },
    },
    { id: 'find_expiring', type: 'get_record', label: 'Find Expiring Contracts' },
    { id: 'notify_owners', type: 'notify', label: 'Notify Owners' },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'find_expiring' },
    { id: 'e2', source: 'find_expiring', target: 'notify_owners' },
    { id: 'e3', source: 'notify_owners', target: 'end' },
  ],
};

Time-relative flow (declarative date sweep)

Instead of hand-writing the "find records, then act" query above — and far more robust than a record_change flow gated on date-equality (end_date == daysFromNow(60)), which only fires if the record is edited on the exact day — a schedule flow whose start node declares a timeRelative descriptor is swept on a schedule (daily by default) and runs once per matching record:

export const renewalReminder: Flow = {
  name: 'renewal_reminder',
  label: 'Renewal Reminder',
  type: 'schedule',
  status: 'active',
  runAs: 'system', // a sweep has no trigger user — elevate explicitly
  nodes: [
    {
      id: 'start',
      type: 'start',
      label: 'Start',
      config: {
        timeRelative: {
          object: 'contract',
          dateField: 'end_date',
          offsetDays: [60, 30, 7], // — or — withinDays: 30 (negative = overdue lookback)
          filter: { status: 'active' }, // optional, ANDed with the date window
        },
        // schedule: { type: 'cron', expression: '0 8 * * *' } // optional; defaults to daily 08:00 UTC
      },
    },
    { id: 'notify_owner', type: 'notify', label: 'Notify Owner' },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'notify_owner' },
    { id: 'e2', source: 'notify_owner', target: 'end' },
  ],
};

Exactly one of offsetDays (discrete T-minus days) or withinDays (a range) is required. Requires the triggers and job capabilities. The record is on the flow context (record.*), so the start condition and {record.*} interpolation work as in a record-change flow.

Update-triggered flow

Trigger on a record update and compare against the previous value:

// Flow: Update probability
flows: [
  {
    name: 'update_probability',
    label: 'Update Probability',
    type: 'record_change',
    status: 'active',
    nodes: [
      {
        id: 'start',
        type: 'start',
        label: 'Start',
        config: {
          triggerType: 'record-after-update',
          objectName: 'opportunity',
          condition: 'record.stage != previous.stage',
        },
      },
      { id: 'update_probability', type: 'update_record', label: 'Update Probability' },
      { id: 'end', type: 'end', label: 'End' },
    ],
    edges: [
      { id: 'e1', source: 'start', target: 'update_probability' },
      { id: 'e2', source: 'update_probability', target: 'end' },
    ],
  },
],

Create-or-update flow (record-after-write)

A record_change start node binds to lifecycle events through triggerType. The single-event tokens map one-to-one:

triggerType Fires on
record-after-create insert (after)
record-after-update update (after)
record-after-delete delete (after)
record-after-write insert or update (after)
record-before-create insert (before)
record-before-update update (before)
record-before-delete delete (before)
record-before-write insert or update (before)

For a rule that must run on both create and update — "recompute the SLA whenever a case is created or its priority changes" — use record-after-write instead of duplicating the flow. write is the create-OR-update union (delete is excluded: a write persists field data, a delete removes the row). One start node binds both lifecycle events; exactly one fires per mutation, so it is not a double run.

{
  id: 'start',
  type: 'start',
  label: 'Start',
  config: {
    objectName: 'crm_case',
    triggerType: 'record-after-write', // created OR updated
  },
}

To branch on which event fired, test previous — it is empty on create (there was no prior row) and populated on update:

// Edge conditions are bare CEL (ADR-0032) — no {…} braces.
{ id: 'e_created', source: 'start', target: 'on_create', condition: 'previous == null' },
{ id: 'e_updated', source: 'start', target: 'on_update', condition: 'previous != null' },

Best practices

DO:

  • Keep flows simple and focused
  • Prefer one record-after-write flow over two near-identical create/update copies
  • Document the business logic
  • Test recursion and retry behavior
  • Use scheduled flows for batch updates

DON'T:

  • Create uncontrolled loops
  • Update too many fields in one node
  • Use triggers when a declarative flow is enough
  • Mix unrelated concerns in one flow

Related