ADR-001 — Execution Model: Instance + Tracks¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.6 |
| Date | 2026-06-27 |
| Owner | Ruslan Gabitov |
| Supersedes | v.2 (three-layer Instance / track / token). v.3 collapses to two layers — token becomes a logical projection of a track, not a stored entity — and defers persistence / rehydration to a dedicated ADR. |
| Refines | SAD-001 v.1 §10 Execution Model |
1. Context¶
goBpm executes BPMN 2.0 processes per the spec's token-flow model. The in-memory runtime this ADR defines MUST handle:
- Many concurrent Process Instances (one engine, N instances of different Process definitions running in parallel).
- Many concurrent execution threads within one instance (Parallel Gateway split, Multi-Instance Activity, Event-Sub-Process activations).
- Wildly different time scales in the same instance — a ServiceTask in milliseconds; a UserTask waiting days; a multi-day Timer. Long waits MUST NOT hold a goroutine.
- Predictable state mutation with no data races — transitions match BPMN normative state machines per activity-lifecycle.md.
- Graceful termination (Terminate End Event cancels all in-flight work in the instance, per end-events.md).
Scope of this ADR. It defines the runtime execution-model core — how an instance and its threads of execution live, fork, and cancel in memory. The concerns that build on this core are owned by dedicated ADRs and are not specified here (see §9): join/merge semantics (ADR-005), event delivery & event-triggered cancellation (ADR-006), the in-memory long-wait release model (ADR-007). Persistence and recovery are a first-class P0 requirement (SAD-001 §6), a distinct heavy concern deferred to a dedicated Persistence & State ADR; §4.7 states only the runtime-level invariants persistence must preserve.
The current codebase already has a developed model in internal/instance/ (Instance + track + token + stepInfo). This ADR formalizes the runtime those types serve and collapses token from a stored type to a projection — see §3.1 and §6.
2. Decision¶
Two-layer ownership. An Instance owns one or more tracks. A track is the operational thread of execution — one goroutine — that carries its current flow position and state. The token is the BPMN-spec control position expressed as a logical projection of a track's current step (track.Token(), Instance.GetTokens()), not a stored object. The Instance holds a track registry only — there is no token registry; "instance done" means all tracks have ended. Instance-scoped state is mutated only inside the Instance's single event-loop goroutine; tracks report progress as events on a channel and never mutate Instance state directly. A fork creates a new track for each additional branch (1:1 between a track and its position), the parent track continuing on the first flow. context.Context is the cancellation contract. Persistence and rehydration are specified separately; this ADR is the in-memory runtime they build on.
In summary form:
| Concern | Mechanism |
|---|---|
| Instance state ownership | Instance registers and coordinates tracks; mutates instance state only on its serialized event loop |
| Per-thread execution | track runs in a dedicated goroutine; drives a step state machine over a sequence of nodes |
| BPMN-spec semantic position | token is a projection of a track's current step (position + derived state + lineage); computed on demand, never stored |
| Fork mechanics | parent track continues on the first flow; one new track per additional flow, each its own goroutine |
| State-mutation safety | Instance state mutated only inside its event-loop goroutine (no locks); track state mutated only inside its own goroutine |
| Cancellation | context.Context cascade: Engine → Instance → track |
| Lineage | carried on the track (track.prev); a fork's parentage is track parentage |
| Join / events / long waits | out of scope here — ADR-005 / ADR-006 / ADR-007 (see §9) |
| Persistence / restart | deferred — dedicated Persistence & State ADR |
3. Alternatives Considered¶
3.1 Layering alternatives¶
| Option | Description | Verdict |
|---|---|---|
| Single-layer (Instance + token only) | Each token has its own goroutine and state machine. | Rejected. Loses the execution-stage decomposition (prologue / execute / epilogue / await-results) the stepState model captures; overloads token with both BPMN semantics and goroutine driving. |
| Three-layer (Instance + track + token-as-object) | Track owns a goroutine + step machine; token is a separate live object owning BPMN position + lineage, 1:1 with the track. | Rejected (was the v.2 choice). At 1:1 with no migration, a live token object only duplicates the track: it forced bidirectional back-references (token.inst, token.trk), a second registry (Instance.tokens[] alongside tracks), and a duplicate lineage chain (token.prevs/nexts mirroring track.prev). The token's distinct identity is needed only as a serialized value at persistence time — handled in the Persistence ADR — not as a live concurrent object. |
| Two-layer (Instance + track; token as projection) — chosen | Track is the operational thread carrying position + state; token is a read-model projected from the track's current step. | Selected. Removes the cross-reference tangle and duplicate registries/lineage; the 1:1 invariant holds by construction (there is nothing to keep in sync). Token survives as BPMN vocabulary and as a projection/serialized value, not as a stored entity. |
| Four-layer (explicit Scope as an ownership level) | Scope as an ownership layer between track and position. | Rejected. Scope (internal/scope/) is data-resolution context, not an ownership layer. |
3.2 Fork mechanics¶
| Option | Mechanics | Verdict |
|---|---|---|
| A. Fork keeps N positions on one track | One goroutine drives parallel branches sequentially | Rejected. No real parallelism. The current token.split(n) keeps the split on the same track — this is the behavior v.3 changes. |
| B. Fork creates a new track per additional branch — chosen | Each parallel branch gets its own goroutine; the parent continues on the first flow | Selected. True parallelism; matches BPMN's "concurrent threads of execution"; goroutine cost is negligible. |
| C. Policy-driven (tracks or positions) | Instance chooses | Rejected. Policy surface without a win; concurrency caps can ride a semaphore over Option B. |
3.3 Instance state-mutation¶
| Option | Mechanics | Verdict |
|---|---|---|
| Shared state + mutex | Tracks hold a pointer to the Instance and mutate fields under lock | Rejected. Lock-discipline bug class; race-detector dependency; contention under heavy fan-out. (This is the current code's shape.) |
| Event-loop serialization — chosen | Instance owns its state; tracks send events on a channel; one Instance goroutine applies them in order | Selected. Single-owner mutation — race-free by construction, no locks on instance state. |
3.4 Goroutine driver¶
| Option | Verdict |
|---|---|
| Per-instance OS process | Rejected — start cost, IPC, defeats embeddability. |
| Single global reactor over all instances | Rejected — one slow node blocks all instances. |
| Goroutine-per-track — chosen | Native Go concurrency; aligns with BPMN's concurrent-thread model. |
4. Decision Detail¶
4.1 Roles¶
Instance¶
The ownership boundary. One goroutine runs the event loop; it is the only writer of instance state.
- Holds the registry of live tracks (the map of active execution threads). No token registry.
- Receives track events (progress / fork / end) on a channel and applies them in order.
- Spawns new tracks at fork points.
- Owns the root
context.Context; cancellation cascades to all tracks. - Decides instance completion: the instance is done when all tracks have ended (no track registry left active), or on context cancellation / shutdown.
- Projects tokens on demand:
Instance.GetTokens()returns the token view of every active track.
track¶
A single thread of execution. One goroutine per track, for its lifetime.
- Carries its current flow position (the node it is at) and its track-level + step-level state.
- Drives the step lifecycle for the current node: prologue → execute → epilogue → await results.
- Executes the node's BPMN behavior via the node's
NodeExecutor. - Reads inputs from Scope, writes outputs back; emits track events to the Instance at each BPMN-observable transition.
- Carries fork lineage (
track.prev). - Handles interruption via
ctx.Done(); ends cleanly on completion, failure, merge, or cancellation. - Exposes its position as a token projection:
track.Token().
token (projection — not a stored type)¶
The token is the BPMN-spec control-flow position, expressed as a read-model projected from a track's current step — node position + derived state + lineage. It is computed on demand (track.Token(), Instance.GetTokens()), never stored as a live cross-linked object, and has no back-reference to its track or to the Instance. "token" remains the project's and BPMN's vocabulary (events, audit, and — later — the unit serialized by persistence are token-worded); only the live object is gone.
4.2 State machines¶
Instance lifecycle: Created → Active → Completed, with a cancellation branch Active → Terminating → Terminated (instance.State). The error branch (Failing/Failed) and suspend (Paused) are owned by their future ADRs and are deliberately absent from the runtime (see §9).
track lifecycle (preserved from current code):
TrackCreated → TrackReady → TrackExecutingStep → TrackProcessStepResults → TrackWaitForEvent
↓
[TrackMerged | TrackEnded | TrackCanceled | TrackFailed]
The runtime produces the terminal track states TrackEnded / TrackCanceled / TrackFailed. TrackMerged (synchronizing join) and the withdrawn end-reason (Event-Based Gateway race loss) are produced by ADR-005; their enum values exist but have no producer in this core.
step lifecycle (preserved):
StepCreated → StepStarted → StepPrologued → StepExecuting → StepEpilogued → StepAwaitsResults → StepEnded → (StepFailed)
token state as a projection. Token state is not stored; it is a pure function of track + step state:
| Token (logical) | Projected from (tokenStateFor) |
|---|---|
Alive |
TrackReady / TrackExecutingStep / TrackProcessStepResults |
WaitForEvent |
TrackWaitForEvent |
Consumed |
TrackEnded / TrackMerged / TrackCanceled / TrackFailed |
~~Withdrawn~~ |
retired — the Event-Based gateway routes without minting arm tokens, so no Withdrawn token is produced (ADR-005 v.4 §2.12.1) |
This replaces the v.2 stored TokenState enum with a derived view. (The
TokenWithdrawn value was once reserved for an Event-Based Gateway race loss;
ADR-005 v.4 §2.12.1 retired it — the gate routes
without ever placing a token on a losing arm, so there is no Withdrawn token to
project.)
4.3 Channel topology (event loop)¶
The built shape — one inbound event stream (track → Instance):
type Instance struct {
ctx context.Context
events chan trackEvent // tracks -> loop() (evFork / evEnded)
tracks map[string]*track // mutated ONLY in loop()
state atomic.Uint32 // run state; written only by loop(), read lock-free
// ... no token registry
}
func (i *Instance) loop(ctx context.Context, initial []*track) {
// spawn initial tracks, then drain until every track has ended:
for active > 0 {
select {
case <-ctx.Done():
stopAll() // signal every track; loop keeps draining
case ev := <-i.events: // evFork -> build a track per extra flow
... // evEnded -> active--
}
}
// all tracks ended -> Completed, or Terminated if cancellation drove it
}
A second inbound edge (EventHub → Instance for Message / Timer / Signal
delivery) is added by ADR-006; it is not
part of this core. The BPMN-observable, token-worded view (split / merged /
waiting / consumed / withdrawn) is derived from these events for audit (and,
later, for persistence checkpoints) — it is not a second live channel.
4.4 Fork mechanics¶
A fork point is any FlowNode with N>1 outgoing sequence flows that become active — not gateway-exclusive (an Activity with multiple outgoing flows is an uncontrolled split, per token-flow.md).
- Track A is executing the fork node; its active outgoing flows are F1…FN (declaration order).
- Track A continues on F1 — its position advances to F1's target; A does not end.
- For each remaining Fₖ (k=2…N): the Instance constructs a new track at Fₖ's target node, with
track.prev = A(lineage), registers it, and starts its goroutine. - After the fork, N tracks run independently — 1 original + N−1 new — each with its own position, each on its own goroutine.
Which outgoing flows activate by gateway type (parallel / inclusive split / activity uncontrolled split) is defined in ADR-005; this core forks over whatever flows the node reports active.
4.5 Join mechanics — out of scope¶
Join/merge semantics (synchronizing join, non-synchronizing merge, OR-join, Event-Based Gateway) are not part of this runtime core. They are defined in ADR-005 Gateways & Joins. The runtime has no join accounting today: a node reached by several tracks is executed once per arrival.
4.6 Context cancellation cascade¶
- Engine context owns all Instance contexts; Instance context derives from it; track context derives from the Instance context.
- Engine shutdown → cancel all Instance contexts → cascade.
- On cancellation the loop signals every track (
stop()), keeps draining their terminal events, and reachesTerminatedonce all have exited; a normal drain reachesCompleted.
The BPMN nodes that trigger this cascade — Terminate End Event (cancels the whole instance) and interrupting boundary events (cancel a single track) — are owned by ADR-006; this core owns the cascade mechanism, not its BPMN triggers.
4.7 Runtime invariants for long waits & persistence¶
The in-memory long-wait release model (a waiting track's goroutine ends; a fresh track is spawned on trigger arrival) is owned by ADR-007 In-Memory Long Waits, and the durable version (surviving a restart) by the Persistence & State ADR. This core fixes only the runtime invariants both must honor:
- A track's continuation state is fully described by its position (node), track/step state, Scope data, and lineage — there is no hidden state on a separate token object.
- A node with resumable in-flight state (timer position, correlation subscription, partial activity state) owns the shape of that state. Runtime-state ownership is decided by ADR-009 v.1: each instance clones the process template into its own private node graph, so per-instance runtime state lives on that instance's own node — the shared template definitions stay immutable. Durable persistence of that state — serializing it and rehydrating across a restart — remains the Persistence & State ADR's concern.
5. Consequences¶
Pros
- Faithful to BPMN vocabulary, without the overhead. Token stays the spec's concept; track is the operational primitive; the token is just the track's current position, projected.
- 1:1 by construction. No second registry, no duplicate lineage, no token↔track/instance back-references to keep consistent.
- Race-free instance state. Single event-loop owner; no locks on instance state; the race detector (now gating CI) backs this up.
- Native to Go. Goroutines + channels + contexts; no framework.
- Reuses existing structure.
track,stepInfo,trackState,stepStatecarry forward; the work is removing the token type and the reactive/lock paths, not inventing machinery.
Cons / things to enforce
- Goroutine count = sum of active tracks. Bounded by BPMN structure; pathological models can be capped via a semaphore over the fork path (optimization, not a model change).
- Channel discipline is mandatory. Every track MUST emit a terminal event before its goroutine exits, or it leaks. Mitigation:
defercleanup; tests assertruntime.NumGoroutine()returns to baseline. - No fairness between tracks. Go's scheduler decides order; token-flow tests MUST NOT depend on goroutine scheduling order.
- Terminal events must always be delivered. A track's
evEndedis accounted even during cancellation (the loop drains to a terminal state);emitdrops only after the loop has exited. (The withdrawn/canceled end-reason distinction itself arrives with ADR-005.)
6. Conception vs Current Code — Deliberate Departures¶
Runtime-only (persistence departures move to the Persistence ADR). The implementation lands per the SRD for this refactor.
| Topic | Current code | This ADR (v.3) | Required change |
|---|---|---|---|
| Token as a type | token struct with inst, trk, prevs, nexts, state; Instance.tokens []*token |
No stored token; token is a projection of a track's current step | Delete the token type and Instance.tokens. Add track.Token() / Instance.GetTokens() returning a computed Token value (read-model). |
| Track:token cardinality | token.split(n) makes N tokens on the same track (newToken(t.inst, t.trk)), then checkFlows reassigns |
A track is one position; a fork makes new tracks | Remove split; fork constructs new tracks directly at each extra flow's target. |
| Instance ↔ token ownership | Instance.addToken / tokenConsumed; token.inst back-ref; token.updateState calls up into Instance |
Instance holds tracks only; no token back-refs | Remove addToken / tokenConsumed / token.inst. "Instance done" = all tracks ended (track registry empty/terminal), decided in the event loop. |
| Lineage | duplicated: track.prev and token.prevs/nexts |
Single chain on the track | Keep track.prev; drop token lineage. |
| State mutation | reactive methods on Instance guarded by sync.RWMutex |
Single event-loop goroutine; tracks send events; no locks on instance state | Add Instance.loop() + channel topology; convert direct mutations to events. |
| Token state | stored TokenState enum on the token |
Derived projection from track/step state (tokenStateFor) |
Done — enum removed from the token; computed in the projection. The withdrawn end-reason is relocated to ADR-005. |
| Instance lifecycle | 9 ad-hoc states (Created/Ready/StartingTracks/Runned/Stopping/Paused/FinishingTracks/Finished/Canceled), several unused |
Created → Active → Completed; cancel branch Terminating → Terminated |
Done — enum reconciled to the §4.2 vocabulary; error (Failing/Failed) and suspend (Paused) relocated to their future ADRs (§9). |
Known issue (relocated). Node execution calls
NodeDataLoader.RegisterData, which currently mutates the shared node (e.g.EndEvent.dataPath) — a violation of §4.7 immutability that races when two tracks cross one node (surfaced by a non-synchronizing-merge test). The fix is a per-node state / data-loading contract owned by the Persistence & State ADR; non-sync merge over a shared node is therefore tracked in ADR-005, not claimed by this core's §7 gate.
7. Verification¶
How we know the implementation matches the conception — the acceptance gate for
this runtime core. All rows below are exercised and green (tests in
internal/instance/, run under -race in CI); this is the evidence backing the
Accepted status.
| What | How | Exercised by |
|---|---|---|
| Race-freedom | All tests run with -race (CI-gated). Instance state is mutated only in loop(); any data race is a CI-blocking failure. |
whole package -race |
| Goroutine-leak-free | Helper asserts runtime.NumGoroutine() returns to baseline after completion. |
leakcheck_test.go |
| 1:1 fork | 2-way split: assert independent tracks each with its own position; parent continued on F1 (did not end); child branched after the fork. | TestM4ForkCompletes |
| No token registry | Assert tokens are exposed only via GetTokens() (projected from tracks), no tokens field; track.Token() reflects the current step. |
TestTokenStateProjection, TestM3* |
| Instance completion | Assert the instance reaches Completed exactly when all tracks have ended — not via a token-alive scan. |
TestM2LinearCompletes, TestM4ForkCompletes |
| Termination cascade | Cancelling the context stops every track and drains its goroutine within a bound; the instance reaches a terminal state. | TestTerminationCascade |
Relocated gates are owned by their ADRs: synchronizing join / non-synchronizing merge → ADR-005; in-memory long wait → ADR-007; Terminate End Event / boundary triggers → ADR-006. Restart-recovery and durable-checkpoint tests belong to the Persistence & State ADR.
8. References¶
- SAD-001 v.1 Vision & Architecture — §6 Quality Attributes; §10 Execution Model (this ADR refines); §13 Distribution & Scale (preliminary).
- docs/bpmn-spec/state-machines/activity-lifecycle.md, process-lifecycle.md — normative lifecycles.
- docs/bpmn-spec/semantics/token-flow.md, gateways.md, end-events.md — fork/join/termination semantics.
- ADR-005 v.4 Gateways & Joins, ADR-006 v.1 Events & Subscriptions, ADR-007 v.1 In-Memory Long Waits — concerns refining this core (see §9).
- Persistence & State ADR (to be authored) — checkpoint policy, per-node state contract, long-wait durability, restart recovery, Scope/timer/compensation/error/activity state. Depends on the
Repositoryinterface (ADR-002 v.1 Extension Architecture). - Existing code:
internal/instance/instance.go,track.go,token.go— the runtime this ADR formalizes (token type removed; event loop; lifecycle reconciled).
9. Out of scope — owned by future ADRs¶
This ADR is the runtime core. The following build on it and are specified in dedicated ADRs (sideways refs, hierarchy-consistent); each carries its own acceptance gate and lands with its own SRD + code:
| Concern | Owner | Status |
|---|---|---|
Join/merge semantics — synchronizing join, non-synchronizing merge, OR-join, Event-Based Gateway + Withdrawn end-reason; fork-flow activation by gateway type |
ADR-005 v.4 Gateways & Joins | Accepted |
Event delivery (EventHub → Instance), Terminate End Event, interrupting boundary events, wait nodes |
ADR-006 v.1 Events & Subscriptions | Accepted |
| In-memory long-wait release model (subscription → goroutine ends → re-spawn) | ADR-007 In-Memory Long Waits | Draft |
Durable persistence & restart recovery; per-node state contract (fixes the shared-node RegisterData mutation, §6) |
Persistence & State ADR (to be authored) | — |
Instance error states (Failing/Failed) and suspend (Paused) |
future Error-Handling / Persistence ADRs | — |
Per-adapter ADR policy. Each substantial extension adapter — persistence (Repository), observability (OpenTelemetry), messaging, authorization, worker dispatch — is specified by its own ADR, not fully inside ADR-002 or the skeleton SRD (trivial defaults such as no-op or slog need none). The extension skeleton (SRD-004) lands only the minimal, in-memory default contracts needed to run today's BPMN on this two-layer model; the Repository interface as defined in ADR-002 v.1 is sufficient for that migration goal (in-memory only). Each adapter's production-grade contract — for Repository: durable serialization, versioning/CAS, transactions, history/inbox/subscriptions, pagination — is owned by its dedicated ADR (the Persistence & State ADR above).
Document History¶
| Version | Date | Author | Change |
|---|---|---|---|
| v.6 | 2026-06-27 | Ruslan Gabitov | Records the maturation of the single-writer principle (§2; §4.1 — tracks never mutate Instance state directly) and its §7 race-freedom gate: ownership now spans not just instance-state mutation but cross-goroutine reads — the loop is the sole reader of the shared token-position / join view, so a live track no longer exposes mutable state for another goroutine to read. The foreign-goroutine read race that was previously patched site-by-site is thereby eliminated by construction; the gate is now exercised at scale under -race (CI-gated, incl. extended concurrent-stress runs). No change to this core's conception — the event-delivery subsystem realizing this is owned by its own dedicated ADR (§9 event-delivery row). Consolidated stale outgoing pins at bump (§8 ADR-005 v.2→v.4; §9 ADR-005/006 status →Accepted, pinned). RU twin synced to v.6 in this change-set. |
| v.5 | 2026-06-11 | Ruslan Gabitov | §4.7 reconciled with ADR-009 v.1: per-node runtime-state ownership is now decided (each instance clones the process template into its own private node graph; per-instance runtime state lives on the instance's own node; the shared template definitions stay immutable), where v.4 deferred it wholesale to the Persistence ADR. Durable persistence/serialization/rehydration stays the future Persistence & State ADR. No execution-model change otherwise. RU twin sync deferred (batched). |
| v.4 | 2026-06-08 | Ruslan Gabitov | Recorded the per-adapter ADR policy (§9): each substantial extension adapter gets its own ADR; the SRD-004 skeleton ships only minimal in-memory default contracts to run current BPMN on the two-layer model, with production-grade contracts (notably durable Repository — serialization, versioning, transactions, history/inbox/subscriptions, pagination) deferred to per-adapter ADRs. The minimal Repository of ADR-002 v.1 is sufficient for the in-memory migration goal. No execution-model change. RU twin sync deferred (batched until the current edit run settles). |
| v.3 | 2026-06-07 | Ruslan Gabitov | Accepted. Collapsed the three-layer model to two layers (Instance + track); token becomes a logical projection of a track's current step, not a stored type (removes token.inst/trk, Instance.tokens[], duplicate lineage). Adopted the single event-loop goroutine for instance-state mutation (no locks). Scoped this ADR to the runtime core — relocated join/merge (ADR-005), event delivery & triggers (ADR-006), and the in-memory long-wait release model (ADR-007) to dedicated ADRs (§9), so doc equals code. Reconciled the instance lifecycle to Created → Active → Completed (+ Terminating → Terminated). §7 gate exercised and green (race-freedom, leak-free, fork, projection, completion, termination cascade) — including two bugs the gate surfaced (track.stopIt race; emit dropping evEnded on cancel). Persistence/rehydration deferred to the Persistence & State ADR. Pre-acceptance Draft iteration folded in without per-round rows. |
| v.2 | 2026-05-29 | Ruslan Gabitov | Three-layer Instance/track/token model (superseded by v.3). |