ADR-0031: Structured control-flow for flows (loop / parallel / try-catch) — native + AI-authored, BPMN as interop
Status: Accepted (2026-06-02) — implemented in #1482 (spec + loop), #1489 (parallel), #1499 (try/catch/retry), #1500 (BPMN mapping); docs #1497. Follow-ups: #1504 (BPMN XML plugin); #1505 (region step logs) done — runRegion now returns its body steps and the container folds them into the run log via NodeExecutionResult.childSteps, tagged with parentNodeId / iteration / regionKind (ExecutionStepLogSchema).
Deciders: ObjectStack Protocol Architects
Builds on: ADR-0018 (open action registry — node types are an open vocabulary, executors are the source of truth), ADR-0019 (durable-pause node via suspend/resume), ADR-0010 + ADR-0011 (AI flow authoring — the design center)
Consumers: @objectstack/services/service-automation (engine + builtin executors), @objectstack/spec (automation/flow.zod.ts, automation/bpmn-interop.zod.ts, studio/flow-builder.zod.ts), ../objectui (flow designer)
The flow designer and protocol expose BPMN-style control nodes — loop,
parallel_gateway, join_gateway, boundary_event — but the engine has no
executors for them (loop is a no-op stub). The naive fix is "implement those
four BPMN token nodes." This ADR argues against that and instead adopts
structured control-flow constructs — a loop container, a parallel
block, and try/catch/retry — as the native + AI-authored model, keeping
the BPMN gateway/boundary node types in the protocol only as the import/export
representation that maps to/from the structured constructs.
The deciding lens is AI authoring of flow metadata ([ADR-0010/0011]). A free-form node+edge graph with BPMN gateways/boundary/back-edges is easy to make semantically broken (orphan joins, unbalanced gateways, deadlocks, infinite cycles) — exactly the failure an LLM will produce confidently. A small set of structured constructs is well-formed by construction, locally composable, and statically analyzable — the right substrate for AI.
(Already shipped on this line: wait (#1469, durable timer/signal pause) and
subflow (synchronous reusable invoke). subflow stays a first-class reuse
primitive — it is not the loop mechanism.)
- The engine is DAG-only.
registerFlow()→detectCycles()throws on any back-edge ("Only DAG flows are allowed"). A classic inline loop (body → loop back-edge) therefore cannot be registered. parallelis partly free:traverseNext()already runs a node's unconditional out-edges viaPromise.all. The missing piece is a correct join (wait-for-all-then-continue-once).- Error handling already exists: edges carry
type: 'fault'(route on failure) anderrorHandling.strategy: 'retry'(exponential backoff). This is already a structured-ish try/catch/retry — closer to what authors want than BPMN boundary events. - BPMN import/export is valued (
automation/bpmn-interop.zod.ts). The protocol must stay BPMN-complete so external BPM tools round-trip. - The repo is multi-agent. Core
engine.tsedits (traversal, cycle detection) must be deliberate and well-sequenced.
BPMN's gateway/boundary/token model is expressive but notoriously error-prone to author (for humans, and more so for LLMs). For AI generating flow metadata, structured control-flow constructs (like a programming language's AST: sequence, branch, loop, parallel, try/catch) win on the three things AI needs:
- Well-formed by construction. A loop has a defined body + exit; a parallel block's join is implicit at block end; there are no raw back-edges or dangling tokens. An LLM can't easily emit a deadlock or an infinite loop.
- Locally composable. AI generates a self-contained subtree; variables are in scope; no cross-node token wiring to get wrong.
- Statically analyzable / terminating. The engine can validate well-formedness and bound iteration — and reject the malformed before run.
Concretely, the per-item-subflow loop (an earlier candidate) is worse for AI: one loop forces two flows (parent + body) plus input/output mapping — more artifacts, more plumbing to get wrong. A self-contained inline loop is simpler for AI and humans alike.
Adopt structured control-flow constructs as the native, AI-authored model:
A loop node owns a bounded body region (a single-entry/single-exit
subgraph) plus an "after-loop" continuation. The engine drives iteration over a
collection (collection, iteratorVariable) with a hard max-iteration
guard; the body region is scoped, not a raw cycle — so the DAG invariant for
ordinary edges is preserved and termination stays analyzable. (Inline, local,
AI-friendly.)
A parallel construct declares N branch regions and continues once when all
branches complete — the join is implicit at block end, engine-synchronized
(per-run, race-free in single-threaded JS). No author-visible join_gateway
arrival-counting node to mis-wire or deadlock. (The existing Promise.all
fan-out is the execution substrate.)
Surface the engine's existing fault edge + errorHandling.retry as a
structured try/catch/retry attached to a step or block, rather than BPMN
boundary events. This is the low-code-native error model and is already
engine-backed.
Ordinary step-to-step edges stay acyclic. Iteration and parallel are structured containers, not cycles or arbitrary token flow — so we get inline loops without giving up termination/analyzability.
parallel_gateway / join_gateway / boundary_event and boundaryConfig stay
in the protocol + bpmn-interop + designer rendering (for import/display).
BPMN import maps them onto the structured constructs (parallel gateways → a
parallel block; loop markers / multi-instance → a loop container; boundary error
→ try/catch); export maps back. They are the interchange format, not the native
authoring model.
Runnable-flow authoring (designer + AI) composes the structured constructs +
executable nodes from the live action registry (/api/v1/automation/actions +
each node's configSchema). Validity is schema-enforced (well-formed
containers), so AI output is runnable by construction. The full BPMN modeling set
remains available for interop, not for native authoring.
The flow model is today a flat nodes[] + edges[] graph. Two ways to carry
structured containers; to be decided in the implementation ADR/PR:
- (A) Marker-delimited scoped regions (recommended starting point): a
loop/parallelnode plus a matching scope-end marker; edges inside the scope are the body/branches; the engine validates single-entry/single-exit and executes the region as a unit. Keeps the flat graph the designer + BPMN use; adds structured semantics + validation on top. - (B) Nested sub-structure: the container node carries a nested mini-flow
(
config.body). Cleaner AST, but diverges from the flat graph.
(A) is least disruptive and most BPMN-mappable; (B) is the cleaner long-term AST. The choice, plus designer rendering of containers and migration of existing flows, is the first task below.
The implementation adopts (B): each container carries its body as a
self-contained region in config — config.body for loop,
config.branches[] for parallel, config.try/config.catch for try_catch
(see @objectstack/spec automation/control-flow.zod.ts). Rationale:
- Well-formed by construction. A nested region is its own graph, so
single-entry is intrinsic and there are no scope markers to balance or leak
across — validation (
analyzeRegion/validateControlFlow) is local. - The shared
engine.tstraversal stays untouched. The container executor runs its body via a scopedAutomationEngine.runRegion(); the main DAGtraverseNextnever learns about scope markers (deliberate, given the multi-agent discipline aroundengine.ts). The container's ordinary out-edges remain the after-loop/after-block continuation, so the DAG invariant for ordinary edges holds. - Cleaner AST for AI — the design center (ADR-0010/0011).
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. Migrating legacy flat loops and designer rendering of
nested containers (in ../objectui) are deferred follow-ups.
- Positive: AI (and humans) author from a small set of constructs that are valid + terminating by construction; no proliferation of error-prone BPMN token nodes; inline loops without breaking the DAG invariant; BPMN import/export preserved as interop; error handling unified on try/catch/retry.
- Cost: bigger than "add four executors" — it introduces structured control-flow into the flow model (spec + engine + designer + a BPMN mapping). Delivered incrementally (below). Existing flat flows keep working (constructs are additive).
- Spec-define the constructs —
loopcontainer,parallelblock,try/catch/retry: their schema,configSchema, and well-formedness validation (single-entry/single-exit regions; bounded loop). Pick representation (A) vs (B). - Loop container — engine execution (bounded iteration over a collection) + designer + e2e. (Highest value; replaces the stub.)
- Parallel block — implicit-join execution (per-run synchronization) + designer + e2e.
- Try/catch/retry — surface the existing
fault+retryas a structured construct in spec + designer. - BPMN mapping —
bpmn-interopimport/export ↔ structured constructs.
Tasks 1–5 are shipped. Task 5 landed as a model-level mapping
(@objectstack/spec automation/bpmn-mapping.ts): exportConstructsToBpmn /
importBpmnToConstructs translate between the structured constructs and the
BPMN gateway/boundary/multi-instance vocabulary on the flow model
(nodes + edges + config), round-trippable via an osConstruct extension marker,
with best-effort folding of foreign BPMN gateways. BPMN 2.0 XML
(de)serialization layers on top and stays a plugin concern (per
bpmn-interop.zod.ts, "Priority: Low"). Designer rendering of the constructs
(in ../objectui) is tracked separately.
- Author-visible low-level BPMN
parallel_gateway/join_gateway/boundary_eventas the native model (kept for interop only). - Relaxing the DAG invariant to allow arbitrary cycles (loops are structured containers instead).
- Runtime BPMN boundary events (timer/signal) — interop representation retained.
- Durable pause inside a
parallelbranch orloopiteration — the structured constructs run their regions synchronously here. Generalizing the engine's single-program-counter to a token/scope tree (the runtime dual of these regions) is ADR-0039.
waitexecutor (#1469) — durable timer/signal pause.subflowexecutor — synchronous reusable invoke (depth-guarded; nested pause → clear error). Remains a reuse primitive, orthogonal to the loop container.