Skip to content

Commit fb49b87

Browse files
authored
Merge pull request #1706 from objectstack-ai/adr-0037-token-scope-tree
docs(adr): ADR-0037 concurrent durable pause — multi-instance now, token tree later
2 parents f207b4b + ae35d1e commit fb49b87

3 files changed

Lines changed: 257 additions & 1 deletion

File tree

docs/adr/0019-approval-as-flow-node.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ The open-source / enterprise split is **not** an architectural concern and is **
176176
A pausing node inside a **subflow** now suspends the whole chain instead of failing the parent.
177177
Model: **linked runs** (the inter-flow half of the long-term execution-state architecture —
178178
cf. Step Functions nested executions / Temporal child workflows; the intra-flow half, a
179-
token/scope tree replacing the single-program-counter continuation, is a separate future ADR).
179+
token/scope tree replacing the single-program-counter continuation, is [ADR-0037](./0037-token-scope-tree-execution.md)).
180180

181181
- The child's continuation persists under its **own run id** (run identity keeps per-flow version
182182
pinning, run logs, and `$runId`-based approval/wait correlation intact). The parent suspends at

docs/adr/0031-advanced-flow-node-executors-and-dag.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@ with best-effort folding of foreign BPMN gateways. BPMN 2.0 **XML**
196196
- Relaxing the DAG invariant to allow arbitrary cycles (loops are structured
197197
containers instead).
198198
- Runtime BPMN boundary events (timer/signal) — interop representation retained.
199+
- Durable pause *inside* a `parallel` branch or `loop` iteration — the structured
200+
constructs run their regions synchronously here. Generalizing the engine's
201+
single-program-counter to a token/scope tree (the runtime dual of these
202+
regions) is [ADR-0037](./0037-token-scope-tree-execution.md).
199203

200204
## Already shipped this line of work
201205

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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

Comments
 (0)