|
| 1 | +# ADR-0037: Concurrent durable pause — multi-instance nodes now, token/scope-tree later |
| 2 | + |
| 3 | +**Status**: Proposed (2026-06-11) — revised after a code + industry self-review |
| 4 | +**Deciders**: ObjectStack Protocol Architects |
| 5 | +**Builds on**: [ADR-0019](./0019-approval-as-flow-node.md) (durable-pause node via suspend/resume — *between*-flow chaining added in its 2026-06-10 addendum), [ADR-0031](./0031-advanced-flow-node-executors-and-dag.md) (structured `loop` / `parallel` / `try_catch` constructs, DAG invariant), [ADR-0018](./0018-unified-node-action-registry.md) (open node/executor registry) |
| 6 | +**Consumers**: `@objectstack/services/service-automation` (engine core — `executeNode` / `traverseNext` / `runRegion` / `resume`, `SuspendedRun`, `sys_automation_run`), `@objectstack/spec` (`automation/execution.zod.ts`), `../objectui` (Runs panel, flow runner) |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## TL;DR |
| 11 | + |
| 12 | +The engine tracks a paused run with a **single program counter** — `SuspendedRun.nodeId`, |
| 13 | +one position — and suspend is implemented as a **thrown exception that unwinds |
| 14 | +the call stack** (`FlowSuspendSignal`). That cannot represent **two pauses at |
| 15 | +once**, so the engine **forbids pausing inside a `parallel` branch or `loop` |
| 16 | +iteration** (`runRegion` converts a suspend inside a region into a hard error). |
| 17 | +This blocks **parallel approvals** ("finance AND legal sign off concurrently") |
| 18 | +and **batch approvals** ("route each line item over $10k"). |
| 19 | + |
| 20 | +The tempting answer — adopt a BPMN-style **token / scope tree** (Camunda) — is |
| 21 | +the right *long-term* runtime model but is a **full engine-core rewrite**: it is |
| 22 | +not just a data structure, it forces replacing three coupled things the current |
| 23 | +engine relies on (see [Why the token tree is expensive](#why-the-token-tree-is-expensive-the-real-cost)). |
| 24 | +A code review (below) shows the cost is much larger than the data-structure |
| 25 | +change implies. |
| 26 | + |
| 27 | +**Decision: two tracks.** Ship **Track A — multi-instance / aggregating nodes** |
| 28 | +first: model the demand as *single nodes* that wait for N decisions, the way |
| 29 | +Camunda multi-instance and AWS Step Functions `Map` do. Track A splits into a |
| 30 | +**free** tier and a **bounded** tier — a distinction worth stating up front: |
| 31 | +**A1 (parallel approval — one `approval` node aggregating N decisions) needs no |
| 32 | +engine change and is shipped (#1708)**; **A2 (a `map` / multi-instance node for |
| 33 | +batch approval) is NOT free** — because each item can pause, it needs a bounded |
| 34 | +extension of the engine's resume path (N:1 aggregation or node re-entry), so it |
| 35 | +is a separately-justified increment, not a free rider on A1. Defer **Track B — |
| 36 | +the general token/scope tree** until demand exceeds what multi-instance covers; |
| 37 | +this ADR records its design so Track A is built toward it, not away from it. |
| 38 | + |
| 39 | +## Context — current state (verified 2026-06-11, against the code) |
| 40 | + |
| 41 | +- **One position per run.** `SuspendedRun.nodeId` is a single node id. `resume` |
| 42 | + restores that one position and calls `traverseNext` from it. |
| 43 | +- **Suspend is a thrown exception.** A pausing node throws `FlowSuspendSignal`; |
| 44 | + `executeNode` unwinds, `execute()` / `resume()` catch it and snapshot the one |
| 45 | + position. The JS call stack *is* the continuation while running; on resume the |
| 46 | + engine re-derives traversal from the single `nodeId` (it does not restore a |
| 47 | + stack). |
| 48 | +- **`runRegion` bans pause structurally.** `parallel` / `loop` / `try_catch` run |
| 49 | + their region(s) through `runRegion`, which catches a `FlowSuspendSignal` and |
| 50 | + rethrows it as `Error("durable pause inside a structured region … is not |
| 51 | + supported")`. That is where the ban lives. |
| 52 | +- **Two concurrency sources, not one.** Besides the structured `parallel` node, |
| 53 | + `traverseNext` already runs a node's **multiple unconditional out-edges |
| 54 | + concurrently via `Promise.all`** — raw graph fan-out. A suspend in either path |
| 55 | + unwinds and the siblings are not cancelled; correctness holds only because |
| 56 | + pause-in-branch is banned. |
| 57 | +- **Variables are one flat shared `Map`.** `Map<string, unknown>` is shared by |
| 58 | + the whole run *and* every region/branch/iteration — there is **no scoping**. |
| 59 | + Loop iterations overwrite the iterator var in place; node output is written as |
| 60 | + `variables.set('${nodeId}.${key}', …)`. ADR-0031 deliberately runs regions "in |
| 61 | + the enclosing variable scope," i.e. on this same flat map. |
| 62 | +- **Between-flow pause already works.** ADR-0019's addendum (subflow linked |
| 63 | + runs, #1693) chains *separate* runs across the subflow boundary — orthogonal |
| 64 | + to this ADR and unchanged by either track. |
| 65 | + |
| 66 | +The gap is strictly **intra-flow concurrency + pause**: one run, several live |
| 67 | +positions. |
| 68 | + |
| 69 | +## Why the token tree is expensive (the real cost) |
| 70 | + |
| 71 | +A self-review against Camunda/Zeebe/Flowable and the actual code found that the |
| 72 | +token/scope tree is a *data structure* whose value only appears when paired with |
| 73 | +**three execution-model changes the current engine does not have**. Adopting the |
| 74 | +tree without these (as a first draft of this ADR did) is adopting the noun |
| 75 | +without the verb. |
| 76 | + |
| 77 | +1. **Recursion + throw → an explicit token scheduler.** Today execution is |
| 78 | + recursive `executeNode` and suspend is a thrown unwind. You cannot |
| 79 | + simultaneously "pause branch A" and "keep branch B running" with a thrown |
| 80 | + exception — `Promise.all` rejects on A's throw while B keeps mutating the |
| 81 | + shared map *after* the snapshot. Camunda/Zeebe instead run a **command/job |
| 82 | + queue**: pop a runnable token, advance it one step, persist; a token that |
| 83 | + hits a wait state simply stops being runnable (no exception). Concurrent pause |
| 84 | + *requires* this scheduler — it is the core rewrite, not a refactor. |
| 85 | + |
| 86 | +2. **Flat shared map → hierarchical scope variables.** Camunda resolves a |
| 87 | + variable by walking **up the execution tree** (token scope → parent → … → |
| 88 | + process instance); a write defaults to the current scope and is discarded |
| 89 | + when the scope ends unless promoted. (The first draft of this ADR invented a |
| 90 | + "copy-on-write + merge-on-join" scheme — **no major engine does that**; it is |
| 91 | + both harder and semantically surprising.) Moving from one flat `Map` to |
| 92 | + scope-chained resolution touches **every** `variables.get`/`set`, every |
| 93 | + template interpolation, and every CEL evaluation in the engine. |
| 94 | + |
| 95 | +3. **Per-run serialization.** Two sibling tokens (e.g. two parallel approvals |
| 96 | + decided at the same instant) would resume concurrently and race on shared run |
| 97 | + state and the join barrier. Camunda serializes commands **per process |
| 98 | + instance** (optimistic locking). v1 of Track B would likewise need to |
| 99 | + serialize token advances within a run — which means the concurrency is |
| 100 | + *logical* (independent pause points), not *parallel execution*. That is a |
| 101 | + real, honest limitation to state up front. |
| 102 | + |
| 103 | +The token tree is correct long-term, but its cost is "rebuild the engine's |
| 104 | +execution model," not "add a tree to `SuspendedRun`." |
| 105 | + |
| 106 | +## Decision |
| 107 | + |
| 108 | +### Track A (now) — multi-instance / aggregating nodes |
| 109 | + |
| 110 | +Model the concrete demand as **single nodes** that internally fan out and |
| 111 | +aggregate, leaving the engine's one-program-counter model intact: |
| 112 | + |
| 113 | +Track A has **two tiers of cost** — a distinction the first revision of this ADR |
| 114 | +got wrong by lumping them together. They are not equal. |
| 115 | + |
| 116 | +**A1 — aggregating `approval` node (truly free; shipped #1708).** One `approval` |
| 117 | +node with `behavior: 'unanimous'` over N approver groups opens **one** |
| 118 | +`sys_approval_request` whose `pending_approvers` lists all groups (notified in |
| 119 | +parallel) and stays suspended until every group approves, then resumes down |
| 120 | +`approve` / `reject`. "Finance AND legal" is exactly this — **one node, one |
| 121 | +program counter, paused once**. This needed **no engine change**: the |
| 122 | +unanimous-over-N aggregation already exists in the approvals service and is |
| 123 | +unit-tested; A1 added a showcase (`showcase_invoice_signoff`) and docs, browser- |
| 124 | +verified. The aggregation state lives in the plugin's own `sys_approval_request` |
| 125 | +row, not the engine. |
| 126 | + |
| 127 | +**A2 — `map` / multi-instance node (NOT free — engine-adjacent).** A correction: |
| 128 | +a `map` node that serves **batch approval** (each item can pause) **cannot** be |
| 129 | +"no engine change," contrary to this ADR's first revision. Examined against the |
| 130 | +code, every flavor needs a bounded extension of the engine's resume/bubble path: |
| 131 | + - *concurrent* map (N items pause at once) needs **durable N:1 aggregation + |
| 132 | + per-parent serialization + completion-ordering handling** — i.e. part of |
| 133 | + Track B's hard concurrency, just confined to one node; |
| 134 | + - *sequential* map (one item at a time) needs **resume-into-the-node** (process |
| 135 | + the next item) instead of the engine's resume-past-the-node default — the DAG |
| 136 | + has no back-edge to loop the node; |
| 137 | + - only a *synchronous, non-pausing* map is engine-free, and that does not serve |
| 138 | + batch approval (which pauses). |
| 139 | + The map node reuses ADR-0019's linked-runs (#1693) for the 1:1 bubble but |
| 140 | + extends it to N:1 / re-entry. It is a real, bounded engine task — smaller than |
| 141 | + the full Track B scheduler, but **not** the zero-cost item A1 was. It should be |
| 142 | + built only against concrete batch-approval demand, with the aggregation / |
| 143 | + re-entry semantics designed first. |
| 144 | + |
| 145 | +So Track A as shipped (**A1**) covers *parallel* approvals at zero engine cost. |
| 146 | +*Batch* approvals (**A2**) are a deliberate, separately-justified increment, not |
| 147 | +a free rider on A1. |
| 148 | + |
| 149 | +### Track B (deferred) — the general token / scope tree |
| 150 | + |
| 151 | +When a flow genuinely needs to pause at **arbitrary, independent positions** that |
| 152 | +multi-instance cannot express (e.g. two unrelated long-running waits on different |
| 153 | +branches that each continue into different downstream logic), adopt the full |
| 154 | +model: |
| 155 | + |
| 156 | +- **Token** = `{ tokenId, scopeId, nodeId, status }`, |
| 157 | + `status ∈ { running | paused | completed | cancelled }`. |
| 158 | +- **Scope** = a region instance (root flow, parallel branch, loop iteration, try |
| 159 | + region), nested by containment into a tree. A linear flow is a one-token / |
| 160 | + one-scope tree — the back-compat anchor (today's behavior unchanged). |
| 161 | +- **Execution** is the scheduler of [§1 above](#why-the-token-tree-is-expensive-the-real-cost), |
| 162 | + not recursion. **Variables** are scope-hierarchical (§2). **Resume** targets a |
| 163 | + `tokenId` (defaulting to the sole paused token for back-compat) and is |
| 164 | + **serialized per run** (§3). **Split/join** are scope operations; a scope's |
| 165 | + join is a barrier that fires when its child tokens reach its single exit |
| 166 | + (ADR-0031 single-entry/single-exit makes this well-defined). **Failure** |
| 167 | + fails the scope and cancels siblings (interrupt) unless caught by a `try_catch` |
| 168 | + scope; this cancellation primitive is what later unlocks boundary events/timers. |
| 169 | +- **Persistence is additive**: keep `nodeId` as the primary token's position so |
| 170 | + existing readers and one-pause flows are unchanged; add `tokens_json` for the |
| 171 | + full tree when there is more than one. |
| 172 | +- **Authoring and DAG unchanged** (D7 below): tokens are runtime-only; the flow |
| 173 | + JSON, the designer, and the AI design center (ADR-0010/0011) are untouched, and |
| 174 | + no back-edges are introduced. |
| 175 | + |
| 176 | +### D7 — invariants that hold on both tracks |
| 177 | + |
| 178 | +- The flow JSON, the structured-construct authoring surface (ADR-0031), the AI |
| 179 | + design center, and the DAG invariant are **unchanged**. Concurrency is a |
| 180 | + runtime concern, never an authoring one. |
| 181 | +- The single-position / single-token case stays bit-for-bit today's behavior. |
| 182 | +- Subflow linked-runs (ADR-0019 addendum) composes with either track. |
| 183 | + |
| 184 | +## Why not the other models |
| 185 | + |
| 186 | +- **Serialize the interpreter stack** (Salesforce-Flow style): inlines child |
| 187 | + state into the parent, destroys per-branch run identity, and still cannot |
| 188 | + express N independent pauses. Rejected. |
| 189 | +- **Event-sourced deterministic replay** (Temporal/Zeebe-internals style): |
| 190 | + requires every node to be deterministic/idempotent. ADR-0018's **open node |
| 191 | + registry** lets third-party executors run arbitrary side effects — the replay |
| 192 | + precondition does not hold here. This is a generative-ecosystem constraint, not |
| 193 | + a taste call. Rejected as the engine model. |
| 194 | +- **Jump straight to the general token tree** (first draft of this ADR): |
| 195 | + correct long-term but over-built for the near-term demand, and its true cost |
| 196 | + (the three execution-model changes above) is not yet justified. Deferred to |
| 197 | + Track B. |
| 198 | + |
| 199 | +## Consequences |
| 200 | + |
| 201 | +- **Track A unblocks the real demand now** (parallel + batch approvals) with no |
| 202 | + engine-core rewrite, no persistence change, and no new concurrency hazards. |
| 203 | +- **Track B is recorded, not started.** The team avoids a premature core rewrite |
| 204 | + while keeping a coherent target; Track A's multi-instance node is designed so |
| 205 | + its per-unit state could later be re-expressed as scoped tokens. |
| 206 | +- **Honest limitation of Track A**: it does not allow pausing at a *free* point |
| 207 | + inside a hand-drawn parallel/loop region — only the structured aggregating node |
| 208 | + pauses. If a flow needs that, it is the signal to start Track B. |
| 209 | +- **Observability**: Track A shows N per-unit rows under one node (e.g. the |
| 210 | + approvals list); Track B would show a tree of live positions. The Runs panel |
| 211 | + extends additively either way. |
| 212 | + |
| 213 | +## Sequencing |
| 214 | + |
| 215 | +1. **A1 — aggregating `approval` node. ✅ Shipped (#1708).** The |
| 216 | + `unanimous`-over-N-approver-groups aggregation already existed and was |
| 217 | + unit-tested; #1708 added the `showcase_invoice_signoff` worked example |
| 218 | + (finance AND legal, browser-verified) and docs. No engine change. Threshold / |
| 219 | + quorum (M-of-N) stays enterprise-tier per `approval.zod.ts`. |
| 220 | +2. **A2 — `map` / multi-instance node (design-first; not started).** Collection |
| 221 | + in, per-item child unit, aggregation, single suspend at the node. **Cost |
| 222 | + correction**: because items can pause, this needs a bounded engine resume-path |
| 223 | + extension (durable N:1 aggregation for concurrent, or resume-into-node for |
| 224 | + sequential) — it is *not* the zero-engine-change item A1 was, so it is gated on |
| 225 | + concrete batch-approval demand and a design note that nails the aggregation / |
| 226 | + re-entry + serialization semantics first. |
| 227 | +3. **B-gate** — only if a concrete flow needs arbitrary-position concurrent |
| 228 | + pause that a multi-instance node cannot express: open a follow-up ADR to start |
| 229 | + Track B at the scheduler, with the one-token refactor as the first, |
| 230 | + behavior-preserving step. |
| 231 | + |
| 232 | +## Non-goals / deferred |
| 233 | + |
| 234 | +- The general token/scope tree and its scheduler (Track B) — recorded, not |
| 235 | + scheduled. |
| 236 | +- Distributed token execution across workers/nodes (one claimer per run stands). |
| 237 | +- Concurrent loop iterations / true parallel I/O speedup (logical concurrency |
| 238 | + only; not a throughput feature). |
| 239 | +- Full BPMN boundary-event / event-subprocess semantics (built on Track B's |
| 240 | + cancellation primitive; separate node-type ADR). |
| 241 | +- Any change to the authoring model (D7). |
| 242 | + |
| 243 | +## Relationship to prior ADRs |
| 244 | + |
| 245 | +- **ADR-0019** gave durable pause for a single position and (addendum) |
| 246 | + between-flow chaining. Track A reuses that pause as-is (the aggregating node |
| 247 | + pauses once); Track B would generalize the within-flow position to a tree. |
| 248 | +- **ADR-0031** defined the structured regions. Track A's multi-instance node is a |
| 249 | + new structured construct alongside them; Track B's scopes are their runtime |
| 250 | + dual. The DAG invariant and AI-authoring center are preserved on both. |
| 251 | +- **ADR-0018**'s open registry is why replay models are rejected and why, when |
| 252 | + Track B comes, the Camunda-style scheduler (not Temporal replay) is the fit. |
0 commit comments