ObjectStackObjectStack

Control Flow — Automation Protocol reference

Structured control-flow constructs — the native + AI-authored flow model: a loop container, a parallel block, and structured try/catch/retry.

Structured control-flow constructs (ADR-0031) — the native + AI-authored flow model: a loop container, a parallel block, and structured try/catch/retry. Unlike BPMN's gateway/boundary/token graph (kept in the protocol for interop only), these constructs are well-formed by construction, locally composable, and statically analyzable — the right substrate for LLM authoring (ADR-0010/0011).

Representation — decision: (B) nested sub-structure

ADR-0031 flagged two ways to carry structured containers in the flat nodes[]+edges[] model:

  • (A) marker-delimited scoped regions (a container node + a scope-end marker; the body is the edges between them in the main graph), or
  • (B) the container node carries a nested mini-flow in its config.

We adopt (B). Each container holds its body as a self-contained FlowRegionSchema (config.body for loop, config.branches[] for parallel, config.try/config.catch for try_catch). The reasons:

  1. Well-formed by construction — a nested region is its own graph, so single-entry is intrinsic; there are no scope markers to balance and no way to "leak" an edge across a boundary. Validation is local.
  2. The shared engine traversal stays untouched — the container executor runs its own body via a scoped helper; the main DAG traverseNext never learns about scope markers (important under the multi-agent discipline around engine.ts). The container's ordinary out-edges remain the "after-loop / after-block" continuation.
  3. Cleaner AST for AI — ADR-0031 calls (B) "the cleaner long-term AST," and AI authoring is the design center.

Existing flat-graph loops (a loop node with no config.body) keep their legacy behavior — the constructs are additive, activated only when the nested structure is present.

The canonical construct type ids are LOOP_NODE_TYPE (loop, pre-existing), PARALLEL_NODE_TYPE (parallel), and TRY_CATCH_NODE_TYPE (try_catch). These are distinct from the BPMN interop node types (parallel_gateway / join_gateway / boundary_event), which remain author-invisible interchange representations.

Unknown keys are rejected (#4001 / ADR-0078)

Every shape below is strictObject. Before that they were plain z.object, so zod's default .strip applied and a key this file does not declare was discarded in silence — the container still parsed, still registered, and still ran, with the author's configuration simply absent. On these five shapes that silence is unusually expensive, because each one carries control rather than data: a swallowed maxIterations is an uncapped loop, a swallowed branch key is a branch that runs without what it was given.

How this relates to validateControlFlow

validateControlFlow is a sibling guard, not a key gate — it answers "is this region single-entry / single-exit / acyclic", which no amount of key strictness can answer. The two are no longer disjoint, and since #16134 that is deliberate: the schema rejects undeclared KEYS and one structural fact — a duplicate node id — while the analysis rejects malformed STRUCTURE. They meet at two seams.

#4001 — validateControlFlow safeParses each region slot before analyzing it, so that parse is also where a region's undeclared key surfaces, reported as <where>: invalid region — <the strictObject message>. That seam duplicated nothing and removed nothing, and left the structural prose this guard exists for untouched; it simply stopped silently repairing its own input.

#16134 — FlowSchema's superRefine holds ONE node-id space across the top-level nodes[] and every region body, judged at every depth collectFlowGraphs walks. That walk stops at MAX_REGION_DEPTH (32), so past the ceiling a region is left raw and analyzeRegion's own duplicate node id line, reached through this guard, is the only refusal of a within-region duplicate (a cross-region collision beyond the ceiling is not judged). The two guards overlap there by design and hand off at that measured boundary.

Source: packages/spec/src/automation/control-flow.zod.ts

TypeScript Usage

import { FlowRegionSchema, LoopConfigSchema, ParallelBranchSchema, ParallelConfigSchema, RetryPolicySchema, TryCatchConfigSchema, TryCatchErrorValueSchema } from '@objectstack/spec/automation';
import type { FlowRegion, LoopConfig, ParallelBranch, ParallelConfig, RetryPolicy, TryCatchConfig, TryCatchErrorValue } from '@objectstack/spec/automation';

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

FlowRegion

Properties

PropertyTypeRequiredDescription
nodes{ id: string; type: string; label: string; config?: Record<string, any>; … }[]✅Region body nodes (single-entry/single-exit sub-graph)
edges{ id: string; source: string; target: string; condition?: string | object; … }[]optionalRegion body edges

Nested Shape: FlowRegion.nodes[number]

PropertyTypeRequiredDescription
idstring✅Node unique ID
typestring✅Action type — a built-in FlowNodeAction id or a plugin-registered node type. Validated against the live action registry at registerFlow() (ADR-0018), not by a closed enum.
labelstring✅Node label
configRecord<string, any>optionalNode configuration
connectorConfig{ connectorId: string; actionId: string; input?: Record<string, any> }optional
position{ x: number; y: number }optional
timeoutMsintegeroptionalMaximum execution time for this node in milliseconds
inputSchemaRecord<string, { type: Enum<'string' | 'number' | 'boolean' | 'object' | 'array'>; required?: boolean; description?: string }>optionalInput parameter schema for this node
outputSchemaneveroptional[REMOVED] flow.nodes[].outputSchema was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions ({{nodeId.field}}) regardless of any declaration. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
waitEventConfig{ eventType: Enum<'timer' | 'signal' | 'webhook' | 'manual' | 'condition'>; timerDuration?: string; signalName?: string }optionalConfiguration for wait node event resumption. REQUIRED on a type: 'wait' node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under eventType: 'timer' a timerDuration is required too.
boundaryConfig{ attachedToNodeId: string; eventType: Enum<'error' | 'timer' | 'signal' | 'cancel'>; interrupting?: boolean; errorCode?: string; … }optionalConfiguration for boundary events attached to host nodes. REQUIRED on a type: 'boundary_event' node — without it the node names neither the activity it watches nor what fires it.

Nested Shape: FlowRegion.edges[number]

PropertyTypeRequiredDescription
idstring✅Edge unique ID
sourcestring✅Source Node ID
targetstring✅Target Node ID
conditionstring | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalPredicate (CEL) returning boolean used for branching. An evaluated slot: a bare non-blank CEL string, or an envelope carrying a non-blank source — an ast-only envelope, and a source that is blank after trimming, are refused at authoring because the engine evaluates source alone and would otherwise answer a silent false.
typeEnum<'default' | 'fault' | 'conditional' | 'back'>optional (default: "default")Connection type: default (normal flow), fault (error path), conditional (expression-guarded), or back (ADR-0044 declared back-edge — traversed normally at run time, but excluded from DAG cycle validation so a revise/rework loop can re-enter an earlier node)
labelstringoptionalLabel on the connector
isDefaultbooleanoptional (default: false)BPMN default flow: traverse this edge only when no sibling conditional edge of the same source node matched. Mutually exclusive with condition; at most one per source node.

LoopConfig

Properties

PropertyTypeRequiredDescription
collectionany[] | { dialect: 'cel'; source: string; ast?: any; meta?: object }✅The list the node iterates: a CEL value envelope { dialect: 'cel', source } evaluated in the run's scope to a list (anything else, null included, fails the node), or an inline array of the items, iterated as written — a string is refused, a {…} template token or a variable's bare name alike
iteratorVariablestringoptional (default: "item")Loop variable holding the current item — a name without a leading $ (the $ names are the flow engine's own), read as {{ name }}
indexVariablestringoptionalOptional loop variable holding the current index — a name without a leading $ (the $ names are the flow engine's own)
maxIterationsintegeroptionalHard cap on iterations (clamped to the engine ceiling)
body{ nodes: object[]; edges?: object[] }optionalLoop body region (omit for legacy flat-graph loops)

Nested Shape: LoopConfig.body

PropertyTypeRequiredDescription
nodes{ id: string; type: string; label: string; config?: Record<string, any>; … }[]✅Region body nodes (single-entry/single-exit sub-graph)
edges{ id: string; source: string; target: string; condition?: string | object; … }[]optionalRegion body edges

ParallelBranch

Properties

PropertyTypeRequiredDescription
namestringoptionalBranch label
nodes{ id: string; type: string; label: string; config?: Record<string, any>; … }[]✅Branch body nodes
edges{ id: string; source: string; target: string; condition?: string | object; … }[]optionalBranch body edges

Nested Shape: ParallelBranch.nodes[number]

PropertyTypeRequiredDescription
idstring✅Node unique ID
typestring✅Action type — a built-in FlowNodeAction id or a plugin-registered node type. Validated against the live action registry at registerFlow() (ADR-0018), not by a closed enum.
labelstring✅Node label
configRecord<string, any>optionalNode configuration
connectorConfig{ connectorId: string; actionId: string; input?: Record<string, any> }optional
position{ x: number; y: number }optional
timeoutMsintegeroptionalMaximum execution time for this node in milliseconds
inputSchemaRecord<string, { type: Enum<'string' | 'number' | 'boolean' | 'object' | 'array'>; required?: boolean; description?: string }>optionalInput parameter schema for this node
outputSchemaneveroptional[REMOVED] flow.nodes[].outputSchema was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions ({{nodeId.field}}) regardless of any declaration. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
waitEventConfig{ eventType: Enum<'timer' | 'signal' | 'webhook' | 'manual' | 'condition'>; timerDuration?: string; signalName?: string }optionalConfiguration for wait node event resumption. REQUIRED on a type: 'wait' node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under eventType: 'timer' a timerDuration is required too.
boundaryConfig{ attachedToNodeId: string; eventType: Enum<'error' | 'timer' | 'signal' | 'cancel'>; interrupting?: boolean; errorCode?: string; … }optionalConfiguration for boundary events attached to host nodes. REQUIRED on a type: 'boundary_event' node — without it the node names neither the activity it watches nor what fires it.

Nested Shape: ParallelBranch.edges[number]

PropertyTypeRequiredDescription
idstring✅Edge unique ID
sourcestring✅Source Node ID
targetstring✅Target Node ID
conditionstring | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalPredicate (CEL) returning boolean used for branching. An evaluated slot: a bare non-blank CEL string, or an envelope carrying a non-blank source — an ast-only envelope, and a source that is blank after trimming, are refused at authoring because the engine evaluates source alone and would otherwise answer a silent false.
typeEnum<'default' | 'fault' | 'conditional' | 'back'>optional (default: "default")Connection type: default (normal flow), fault (error path), conditional (expression-guarded), or back (ADR-0044 declared back-edge — traversed normally at run time, but excluded from DAG cycle validation so a revise/rework loop can re-enter an earlier node)
labelstringoptionalLabel on the connector
isDefaultbooleanoptional (default: false)BPMN default flow: traverse this edge only when no sibling conditional edge of the same source node matched. Mutually exclusive with condition; at most one per source node.

ParallelConfig

Properties

PropertyTypeRequiredDescription
branches{ name?: string; nodes: object[]; edges?: object[] }[]✅Branch regions executed concurrently; implicit join at block end

Nested Shape: ParallelConfig.branches[number]

PropertyTypeRequiredDescription
namestringoptionalBranch label
nodes{ id: string; type: string; label: string; config?: Record<string, any>; … }[]✅Branch body nodes
edges{ id: string; source: string; target: string; condition?: string | object; … }[]optionalBranch body edges

RetryPolicy

Properties

PropertyTypeRequiredDescription
maxRetriesintegeroptional (default: 0)Retry attempts after the initial one. 0 (the default) means no retry — state a count to opt in.
backoffMsintegeroptional (default: 1000)Base delay before the first retry (ms); subsequent delays multiply by backoffMultiplier
backoffMultipliernumberoptional (default: 1)Exponential backoff multiplier; 1 (the default) keeps the delay flat
maxRetryDelayMsintegeroptional (default: 30000)Ceiling for a single backoff delay (ms)
jitterbooleanoptional (default: false)Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries
retryDelayMsneveroptional[REMOVED] retryDelayMs was removed in @objectstack/spec 17.0.0 — the retry policy now has ONE spelling for its base delay across every surface that carries it: job.retryPolicy, a try_catch node's retry and flow.errorHandling. Rename the key to backoffMs; the value (milliseconds before the first retry) is unchanged. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.

TryCatchConfig

Properties

PropertyTypeRequiredDescription
try{ nodes: object[]; edges?: object[] }✅Protected region
catch{ nodes: object[]; edges?: object[] }optionalHandler region run when the try region fails
errorVariablestringoptional (default: "$error")Variable holding the caught error in the catch region — $error (the default), or a name without a leading $ read as {{ name.message }}; any other $ name is the flow engine's own and refused. The value is a TryCatchErrorValue: nodeId, message, code when the failing node carried a platform-classified error code (ADR-0112 — branch on $error.code to tell "the row is already there" from "the store is down"), and iteration / item when the failure happened inside a loop body
retry{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }optionalOptional retry policy for the try region

Nested Shape: TryCatchConfig.try

PropertyTypeRequiredDescription
nodes{ id: string; type: string; label: string; config?: Record<string, any>; … }[]✅Region body nodes (single-entry/single-exit sub-graph)
edges{ id: string; source: string; target: string; condition?: string | object; … }[]optionalRegion body edges

Nested Shape: TryCatchConfig.catch

PropertyTypeRequiredDescription
nodes{ id: string; type: string; label: string; config?: Record<string, any>; … }[]✅Region body nodes (single-entry/single-exit sub-graph)
edges{ id: string; source: string; target: string; condition?: string | object; … }[]optionalRegion body edges

Nested Shape: TryCatchConfig.retry

PropertyTypeRequiredDescription
maxRetriesintegeroptional (default: 0)Retry attempts after the initial one. 0 (the default) means no retry — state a count to opt in.
backoffMsintegeroptional (default: 1000)Base delay before the first retry (ms); subsequent delays multiply by backoffMultiplier
backoffMultipliernumberoptional (default: 1)Exponential backoff multiplier; 1 (the default) keeps the delay flat
maxRetryDelayMsintegeroptional (default: 30000)Ceiling for a single backoff delay (ms)
jitterbooleanoptional (default: false)Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries
retryDelayMsneveroptional[REMOVED] retryDelayMs was removed in @objectstack/spec 17.0.0 — the retry policy now has ONE spelling for its base delay across every surface that carries it: job.retryPolicy, a try_catch node's retry and flow.errorHandling. Rename the key to backoffMs; the value (milliseconds before the first retry) is unchanged. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.

TryCatchErrorValue

Properties

PropertyTypeRequiredDescription
nodeIdstring✅Node the failure is attributed to
messagestring✅Message of the error that ended the try region, after any retries
codestringoptionalPlatform-classified error code (ADR-0112) of the failure that ended the try region, e.g. create_record's DUPLICATE_RECORD; present only when the failing node's own result carried one, so a catch region branching on $error.code treats "unset" as "no classified code", never as "nothing failed". An open string, not a closed enum: the vocabulary is StandardErrorCode plus registered ledger codes plus tenant-authored codes, and third-party node executors bind their own
iterationintegeroptionalZero-based iteration of the enclosing loop when the failure happened inside a loop body; absent outside a loop
itemanyoptionalThe loop item being processed (the enclosing loop's iteratorVariable value) when the failure happened inside a loop body; absent outside a loop

On this page