Skip to content

SRD-056.B — Multi-Instance (behavior)

Field Value
Status Accepted
Date 2026-07-21
Owner Ruslan Gabitov
Implements ADR-025 v.2 §2.8 (behavior — the Multi-Instance event-throwing slice) + §2.12 (the throw executes as an off-loop emit by the iteration decorator) — the last of #88's MI work
Upstream ADR-006 v.4 (event throwing/catching this rides), ADR-018 v.1 (the boundary-catch mechanism the thrown events are caught by), ADR-017 v.1 (the single-writer loop the off-loop throw hands the event to), ADR-010 v.2 (name-based data), ADR-013 v.2 (facts), ADR-001 v.6
Refines
Related SRD-055 (sequential MI on the decorator), SRD-056.A (parallel MI on the decorator) — behavior wires into both drivers

§1 Background

BPMN 2.0 §13.3.7's behavior (MultiInstanceBehavior, default All) governs whether a Multi-Instance activity throws a boundary-catchable event as instances complete, letting a model react to progress (e.g. quorum-reached once 3 of 5 approvals arrive). ADR-025 §2.8 decides the four modes: All (default — throw nothing), None (throw on every completion), One (throw once, on the first), Complex (evaluate each ComplexBehaviorDefinition's condition and throw its event when it holds — one completion may throw several).

This is the fourth and final slice of the ADR-025 v.2 decorator re-landing (§2.12, after composite Standard Loop SRD-054, sequential MI SRD-055, and parallel MI SRD-056.A) — and it is the slice the whole decorator rework existed to enable. A throw hands the event to the single-writer loop's ordered inbound channel (via the EventHub); a prior, superseded attempt issued the throw on the loop goroutine from the loop-side completion seams (afterDrain / parallelInstanceDrained), where the hand-off self-deadlocks (the loop is the channel's only reader, busy inside the throw) — and made fire-and-forget it dropped the boundary catch nondeterministically. §2.12 moved iteration control onto the activity's own off-loop runner, so the behavior throw becomes an ordinary off-loop emit: the runner calls PropagateEvent, which blocks only until the loop accepts the event, then proceeds to complete the activity — the boundary catch is ordered before completion, on a boundary that is still armed, by construction.

The machinery this rides exists. Instance.PropagateEvent(ctx, eDef) (eventproducer.go) emits a definition up to the EventHub; a boundary on the MI host catches a matching event via the ADR-018 path (armBoundaries / fireBoundary, the armedFor completion-race guard), and an MI host's boundaries are armed for the whole MI window (armed on arrival, disarmed only on move-off). The four behavior model types (MultiInstanceBehavior, ImplicitThrowEvent, ComplexBehaviorDefinition, the behavior / noneBehaviorEventRef / oneBehaviorEventRef / complexBehaviorDefinition fields + options + the multiInstance accessors) were authored and validated in the superseded attempt (behavior-preserving here — the model was never the problem, the throw locus was) and are re-applied unchanged.

In scope: the four behavior modes wired into both off-loop drivers (runMISequential and runMIParallel, since behavior is orthogonal to isSequential, §2.8), the throw-before-completion ordering, and the current §2.9 attributes on the thrown events; the quorum-reached worked example. Deferred: completionQuantity (SRD-046 NFR-4); MI compensation (Transaction, ADR-025 §2.10). This SRD completes the Multi-Instance feature (#88's MI work) and is the last slice before ADR-025 v.2 flips Accepted.

§2 Requirements

Functional — the model (re-applied from the superseded attempt, unchanged)

  • FR-1 — the three model types. (a) MultiInstanceBehavior — a named string type with typed constants BehaviorAll (default), BehaviorNone, BehaviorOne, BehaviorComplex (activities/multiinstance.go, the data-over-code convention). (b) ImplicitThrowEvent (pkg/model/events/) — a ThrowEvent that is not a token node (no incoming/outgoing, no Exec); it embeds the shared throwEvent base (the EventDefinition(s) + Definitions() + per-instance cloning) and is thrown implicitly by the MI (spec events.md §ImplicitThrowEvent). (c) ComplexBehaviorDefinition (activities/, → foundation.BaseElement): a condition data.FormalExpression + an event *events.ImplicitThrowEvent (spec activities.md §ComplexBehaviorDefinition).
  • FR-2 — MI fields, options, validation. MultiInstanceLoopCharacteristics gains behavior (default BehaviorAll), noneBehaviorEventRef / oneBehaviorEventRef (flow.EventDefinition), and complexBehaviorDefinition ([]*ComplexBehaviorDefinition), with options WithBehavior / WithNoneBehaviorEvent / WithOneBehaviorEvent / WithComplexBehavior. NewMultiInstance validates behavior⇄ref consistency (a data table, mirroring the cardinality-source switch): None requires exactly noneBehaviorEventRef; One requires oneBehaviorEventRef; Complex requires ≥1 complexBehaviorDefinition (each condition a boolean expression); All (default) forbids all three. Self-identifying errors.
  • FR-3 — runtime capability accessors. The internal/instance multiInstance interface gains Behavior(), NoneBehaviorEvent(), OneBehaviorEvent(), ComplexBehavior() — matched exactly by the model getters, or the capability assertion silently fails.

Functional — the runtime throw (off-loop, on the decorator)

  • FR-4 — one shared behavior-throw helper, both drivers. A single throwMIBehavior is called from both off-loop completion points — the sequential runMISequential (mi.go) after each instance drains, and the parallel parallelBarrierStep (mi_parallel.go) after each delivered drain — on each instance completion, since the event fires per completion regardless of whether that completion ends the activity. It switches on Behavior(): All → no-op (the zero-cost common case, early return); None → throw noneBehaviorEventRef; One → throw oneBehaviorEventRef only on the first completion (completed == 1); Complex → evaluate each definition's condition (the evalCompletion-shaped frame at the host scope) and throw its event's definition when true (a single completion may throw several).
  • FR-5 — throw via PropagateEvent, off the loop. The helper throws through t.instance.PropagateEvent(ctx, eDef) on the runner goroutine (not the loop), so the hand-off to the EventHub — and the loop's ordered receipt of the resulting boundary catch — block-then-proceed instead of self-deadlocking (§4.2). No node-frame emitEvent ceremony (the MI has no node-execution frame; a Complex condition opens its own frame like evalCompletion).
  • FR-6 — throw before the activity completes (ordering). The behavior throw is issued before the driver completes the activity — before runMISequential's publishOutput + executeNode, and before parallelBarrierStep's scopeComplete
  • the driver's executeNode. Because PropagateEvent blocks until the loop accepts the event (single-writer receipt), the resulting boundary catch is enqueued while the host is still on the node and its boundary still armed — even for the event thrown at the last completion (the host moves off only after executeNode, several loop steps later). This is the ordering the loop-goroutine model could not achieve without deadlocking.
  • FR-7 — the events carry the current §2.9 attributes. Before evaluating a Complex condition and throwing, the current runtime attributes (numberOfInstances / numberOfActiveInstances / numberOfCompletedInstances / numberOfTerminatedInstances) are published at the host scope. Parallel already binds them per drain off the loop (bindMICounters, SRD-056.A); sequential publishes them only at each pass's start, so runMISequential rebinds the post-drain count before the throw (§2.8 "the thrown events implicitly carry the runtime attributes").

Functional — the catch & front door

  • FR-8 — caught on the MI boundary (existing machinery). A thrown behavior event is caught by a boundary event on the MI activity through the unchanged ADR-018 path (armBoundaries / fireBoundary, the armedFor completion-race guard). The recommended carrier is a Signal EventDefinition — matched by name broadcast (eventhub broadcastSignal), which cleanly supports one throw caught by several boundaries (Complex) — but any definition whose id the boundary references is caught. Both interrupting and non-interrupting boundaries work; a non-interrupting boundary is the progress-notification case, an interrupting one cancels the MI (its teardown is the SRD-056.A cancelHostScope MI path, which unblocks the parked driver through awaitScopeDrained's evtCh-closed path). This SRD adds no boundary logic — it only throws.
  • FR-9 — front door. A runnable examples/multi-instance-behavior/ (a review board that throws a quorum-reached signal, caught by a non-interrupting boundary that spawns a notification), the iteration guide, CHANGELOG.md, the conformance tracker row 4 (behavior ✅ — the MI feature complete), and the READMEs (EN + RU).

Non-functional

  • NFR-1 — reuse the throw + boundary machinery. No new event infrastructure — the helper rides PropagateEvent and the ADR-018 boundary catch. The genuinely new code is the three model types (re-applied) + the one runtime helper + its two off-loop call sites.
  • NFR-2 — behavior is orthogonal to isSequential. One helper, both drivers; the sequential (SRD-055) and parallel (SRD-056.A) suites re-verify unchanged.
  • NFR-3 — All is zero-cost. The helper early-returns on All, so the common case (no behavior) adds nothing to the hot completion path.
  • NFR-4 — the throw is off-loop (ADR-025 v.2 §2.12). throwMIBehavior runs only on the runner goroutine; it is never called from the loop. The single-writer loop stays the sole reader of its inbound channel, and the throw is an ordinary producer of it — the structural fix for the superseded deadlock.
  • NFR-5 — coverage. Every touched file ≥95% diff-coverage (aim 100%); make ci green, -race clean (the throw races the boundary catch across goroutines).

§3 Models

§3.1 pkg/model/events/implicit_throw.go — the new throw type

// ImplicitThrowEvent is a ThrowEvent thrown implicitly by an activity (BPMN
// §ImplicitThrowEvent), never reached by a token: it carries an EventDefinition
// (+ optional data) and is emitted by the engine (here, a Multi-Instance
// behavior). Unlike IntermediateThrowEvent it declares no flow-node wiring.
type ImplicitThrowEvent struct {
    throwEvent
}

func NewImplicitThrowEvent(name string, def flow.EventDefinition,
    baseOpts ...options.Option) (*ImplicitThrowEvent, error)

§3.2 pkg/model/activities/multiinstance.go — behavior on the MI type

type MultiInstanceBehavior string
const (
    BehaviorAll     MultiInstanceBehavior = "all"  // default — no event thrown
    BehaviorNone    MultiInstanceBehavior = "none" // throw on every completion
    BehaviorOne     MultiInstanceBehavior = "one"  // throw on the first completion
    BehaviorComplex MultiInstanceBehavior = "complex"
)

// ComplexBehaviorDefinition — a condition + the event thrown when it holds.
type ComplexBehaviorDefinition struct {
    foundation.BaseElement
    condition data.FormalExpression
    event     *events.ImplicitThrowEvent
}

// added to MultiInstanceLoopCharacteristics:
//   behavior                  MultiInstanceBehavior   // default BehaviorAll
//   noneBehaviorEventRef      flow.EventDefinition
//   oneBehaviorEventRef       flow.EventDefinition
//   complexBehaviorDefinition []*ComplexBehaviorDefinition

Options WithBehavior / WithNoneBehaviorEvent / WithOneBehaviorEvent / WithComplexBehavior; getters Behavior / NoneBehaviorEvent / OneBehaviorEvent / ComplexBehavior. No import cycle: activities already depends on events; events does not depend on activities.

§3.3 Runtime deltas (internal/instance/)

  • mi.go — the multiInstance interface gains the four behavior accessors.
  • mi_behavior.go (new)throwMIBehavior(ctx, t, mi, node, completed) (the shared switch) + throwComplexBehavior (per-definition condition eval + throw), both *track methods (runner-side). Reuses evalCompletion's frame shape for a Complex condition.
  • mi.go runMISequential / mi_parallel.go parallelBarrierStep — each calls throwMIBehavior off the loop, after the drain's completed++ and the §2.9 rebind, before the driver completes the activity (FR-6). Sequential rebinds the post-drain counts first (FR-7); parallel already did (SRD-056.A bindMICounters).

§4 Analysis

§4.1 One helper, both off-loop drivers (FR-4, NFR-2)

Sequential runMISequential and parallel parallelBarrierStep both advance a completed count on each instance completion, on the runner goroutine. behavior fires there, independent of the completion-condition outcome, so a single helper — taking the track, the multiInstance, the node, and the completed count — serves both. One keys off completed == 1. The helper is a *track method because the throw (t.instance.PropagateEvent) and the Complex frame both hang off the running track, exactly as the other off-loop decorator steps do.

§4.2 Off the loop, the throw block-then-proceeds — the deadlock is gone (FR-5, NFR-4)

The superseded design threw on the loop goroutine inside the loop-side completion hook, and its own §4.2 reasoned about enqueue ordering — but the throw's round trip (PropagateEvent → EventHub → the boundary catch delivered back to this instance's inbound channel) has the loop as the channel's only reader, so a synchronous throw issued from the loop self-deadlocks, and a fire-and-forget throw races the catch against the activity's own completion. Both are the loop-locus bug §2.12 removes. On the decorator the throw runs on the runner: PropagateEvent blocks only until the loop accepts the event (the loop is free to read), the loop routes the boundary catch as an ordinary evBoundary, and the runner — released — proceeds to complete the activity. The wait graph stays a DAG (runner → loop), never a cycle.

§4.3 Throw before the activity completes — the ordering contract (FR-6)

The hazard is the last completion: the same driver iteration both throws and then completes the activity (executeNode → the host's outgoing → evMoved → the boundary disarm). Issuing the throw before executeNode, and blocking on PropagateEvent until the loop accepts it, enqueues the boundary catch while the host is still on the node — and the disarm is several loop steps further (complete → run-goroutine advance → evMoved → disarm). The Active-guard in PropagateEvent also holds: at the last drain the instance is still Active (it reaches Completed only after the token leaves via an end event), so the throw is accepted. TestBehaviorLastCompletionCaught is the empirical canary for this ordering. Contingency: the guarantee rests on the EventHub enqueuing the resulting evBoundary to this instance's loop before the runner returns from PropagateEvent; if the hub delivers asynchronously and the canary flakes, the last-completion throw gets an explicit ordering point — the driver yields to the loop (a no-op roundtrip) before executeNode, so the pending evBoundary is processed while the boundary is still armed. M2 confirms which path the hub takes.

§4.4 Current runtime attributes for the thrown events (FR-7)

§2.8 says the thrown events implicitly carry the §2.9 attributes. Parallel already binds them at the host scope on each drain off the loop (SRD-056.A bindMICounters); sequential binds them only at each pass's start (and clears miState at completion), so a behavior throw at a sequential completion would read a stale count. runMISequential rebinds the post-drain counts before throwMIBehavior, unifying attribute availability across both drivers — and it is the same rebind a sequential Complex completionCondition already needs.

§4.5 ImplicitThrowEvent reuses the throw base (FR-1)

ImplicitThrowEvent embeds the same throwEvent base IntermediateThrowEvent uses — inheriting the EventDefinition list, Definitions(), and per-instance cloning — so no new emit path is needed. It differs only behaviorally: it is never a token target, so it declares none of the flow-node/executor assertions. The helper reads the configured definition and propagates it.

§6 Test scenarios

Test Level Asserts (FR)
TestMultiInstanceBehaviorModelValidation model FR-1/FR-2 enum + fields + behavior⇄ref validation (None/One/Complex/All)
TestImplicitThrowEventBuild model FR-1 ImplicitThrowEvent carries its definition
TestParallelBehaviorNoneThrowsEach instance FR-4 None throws on every completion (N throws)
TestParallelBehaviorOneThrowsFirst instance FR-4 One throws once, on the first completion
TestParallelBehaviorComplexCondition instance FR-4 Complex throws when a definition's condition holds (quorum)
TestSequentialBehaviorNoneThrowsEach instance FR-4/NFR-2 behavior works on the sequential driver too
TestBehaviorAllThrowsNothing instance NFR-3 All (default) throws no event
TestBehaviorEventCaughtOnBoundary instance FR-8 a thrown behavior signal is caught by a non-interrupting boundary on the MI host
TestBehaviorLastCompletionCaught instance FR-6 the event thrown at the last completion is still caught (ordering)
TestMultiInstanceBehaviorE2E thresher FR-4–FR-8 end-to-end quorum-reached notification

§7 Milestones

M Scope Files
M1 Model types + fields + options + validation + runtime accessors (re-applied from the superseded attempt) pkg/model/events/implicit_throw.go, pkg/model/activities/multiinstance.go, internal/instance/mi.go (interface)
M2 The shared off-loop throwMIBehavior helper (mi_behavior.go) + wiring into runMISequential and parallelBarrierStep (None/One/Complex, throw-before-complete, current attributes) internal/instance/mi_behavior.go, mi.go, mi_parallel.go
M3 Boundary-catch test + e2e + examples/multi-instance-behavior/ (quorum notification) + docs (guide/CHANGELOG/tracker/READMEs) + Accepted; flip ADR-025 v.2 Draft → Accepted (the §2.12 re-landing complete) pkg/thresher/…, examples/…, docs

§8 Cross-doc

§9 Definition of Done

  • FR-1…FR-9 wired and covered by the §6 tests; the e2e green; the SRD-055 + SRD-056.A suites still pass (NFR-2).
  • make ci green (diff-coverage ≥95% touched; -race; govulncheck; all modules).
  • examples/multi-instance-behavior/ runs and exits 0 (binary gitignored).
  • Conformance tracker row 4 advanced (Standard Loop ✅ + MI sequential ✅ + parallel ✅
  • behavior ✅; only completionQuantity remains, deferred); CHANGELOG [Unreleased]; iteration guide behavior note; README EN+RU.
  • /check-srd PASS. ADR-025 v.2 flips Draft → Accepted — the whole §2.12 decorator re-landing (Loop + MI sequential/parallel/behavior) is complete.

§10 Implementation summary

§10.1 Stages by commit (branch feat/mi-behavior-decorator)

Stage Commit Scope Tests
doc de498f3 SRD-056.B authored for the off-loop decorator (Draft)
M1 a6a4428 model types re-applied from the superseded attempt (ImplicitThrowEvent, MultiInstanceBehavior, ComplexBehaviorDefinition, fields/options/validation, multiInstance accessors) model validation + ImplicitThrowEvent build
M2 dd72fc9 off-loop throw — mi_behavior.go throwMIBehavior/throwComplexBehavior; evalBoolAtHost extraction; wired into runMISequential + parallelBarrierStep before completion None/One/Complex/All × seq/parallel + error paths (-race)
M3 this commit thresher e2e (quorum + last-completion ordering) + examples/multi-instance-behavior/ + docs (CHANGELOG / iteration guide / tracker / READMEs) + ADR-025 v.2 flipped Accepted (EN + RU twin) TestMultiInstanceBehaviorE2E + TestBehaviorLastCompletionCaught

§10.2 Empirical findings vs the draft

  • The FR-6 ordering holds — the §4.3 fallback was not needed. The flagged risk (the last-completion boundary catch racing the host's disarm) did not materialize: TestBehaviorLastCompletionCaught catches the event thrown at the last completion reliably, 8× under -race. PropagateEvent blocking until the loop accepts the throw, before executeNode, is sufficient — the yield-before- executeNode contingency stays unused.
  • The model re-applied cleanly. M1 was a near-verbatim re-application of the superseded 5f105e6 model — the model was never the problem; only the throw locus (loop → runner) moved. The runtime throwMIBehavior moved from a loopState method to a *track method (the off-loop runner owner).
  • The sequential counter rebind is a shadowed guard. runMISequential rebinds the §2.9 counts before the throw, but its error return can't fire when bindInstance already bound the same host scope earlier in the pass — left uncovered (98.9% diff-coverage; the callee bindMICounters is 100% via parallel).
  • The e2e's notification op had to count. The shared markSawOp stores a -1 sentinel; the quorum assertions needed a per-fire counting op (countSawOp).

§10.3 Backlog

  • completionQuantity (SRD-046 NFR-4) is the only remaining Multi-Instance surface. This SRD completes epic #88's MI work and the ADR-025 v.2 §2.12 decorator re-landing (Standard Loop + MI sequential/parallel/behavior all off the loop).

Open questions

None.