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 constantsBehaviorAll(default),BehaviorNone,BehaviorOne,BehaviorComplex(activities/multiinstance.go, the data-over-code convention). (b)ImplicitThrowEvent(pkg/model/events/) — aThrowEventthat is not a token node (no incoming/outgoing, noExec); it embeds the sharedthrowEventbase (theEventDefinition(s) +Definitions()+ per-instance cloning) and is thrown implicitly by the MI (specevents.md§ImplicitThrowEvent). (c)ComplexBehaviorDefinition(activities/, →foundation.BaseElement): acondition data.FormalExpression+ anevent *events.ImplicitThrowEvent(specactivities.md§ComplexBehaviorDefinition). - FR-2 — MI fields, options, validation.
MultiInstanceLoopCharacteristicsgainsbehavior(defaultBehaviorAll),noneBehaviorEventRef/oneBehaviorEventRef(flow.EventDefinition), andcomplexBehaviorDefinition([]*ComplexBehaviorDefinition), with optionsWithBehavior/WithNoneBehaviorEvent/WithOneBehaviorEvent/WithComplexBehavior.NewMultiInstancevalidates behavior⇄ref consistency (a data table, mirroring the cardinality-source switch):Nonerequires exactlynoneBehaviorEventRef;OnerequiresoneBehaviorEventRef;Complexrequires ≥1complexBehaviorDefinition(eachconditiona boolean expression);All(default) forbids all three. Self-identifying errors. - FR-3 — runtime capability accessors. The
internal/instancemultiInstanceinterface gainsBehavior(),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
throwMIBehavioris called from both off-loop completion points — the sequentialrunMISequential(mi.go) after each instance drains, and the parallelparallelBarrierStep(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 onBehavior():All→ no-op (the zero-cost common case, early return);None→ thrownoneBehaviorEventRef;One→ throwoneBehaviorEventRefonly on the first completion (completed == 1);Complex→ evaluate each definition'scondition(theevalCompletion-shaped frame at the host scope) and throw itsevent's definition whentrue(a single completion may throw several). - FR-5 — throw via
PropagateEvent, off the loop. The helper throws throught.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-frameemitEventceremony (the MI has no node-execution frame; a Complex condition opens its own frame likeevalCompletion). - FR-6 — throw before the activity completes (ordering). The behavior throw is
issued before the driver completes the activity — before
runMISequential'spublishOutput+executeNode, and beforeparallelBarrierStep'sscopeComplete - the driver's
executeNode. BecausePropagateEventblocks 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 afterexecuteNode, 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
conditionand 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, sorunMISequentialrebinds 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, thearmedForcompletion-race guard). The recommended carrier is a SignalEventDefinition— matched by name broadcast (eventhubbroadcastSignal), 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.AcancelHostScopeMI path, which unblocks the parked driver throughawaitScopeDrained'sevtCh-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
PropagateEventand 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 —
Allis zero-cost. The helper early-returns onAll, 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).
throwMIBehaviorruns 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 cigreen,-raceclean (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— themultiInstanceinterface 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*trackmethods (runner-side). ReusesevalCompletion's frame shape for a Complex condition.mi.gorunMISequential/mi_parallel.goparallelBarrierStep— each callsthrowMIBehavioroff the loop, after the drain'scompleted++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.AbindMICounters).
§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¶
- Implements ADR-025 v.2 §2.8, §2.12.
- Upstream ADR-006 v.4, ADR-018 v.1, ADR-017 v.1, ADR-010 v.2, ADR-013 v.2, ADR-001 v.6 — all up/sideways, version-pinned.
- Related SRD-055, SRD-056.A (sideways, number-only per the one-shot rule). No downward references.
§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 cigreen (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
completionQuantityremains, deferred); CHANGELOG[Unreleased]; iteration guide behavior note; README EN+RU. /check-srdPASS. 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:
TestBehaviorLastCompletionCaughtcatches the event thrown at the last completion reliably, 8× under-race.PropagateEventblocking until the loop accepts the throw, beforeexecuteNode, is sufficient — the yield-before-executeNodecontingency stays unused. - The model re-applied cleanly. M1 was a near-verbatim re-application of the
superseded
5f105e6model — the model was never the problem; only the throw locus (loop → runner) moved. The runtimethrowMIBehaviormoved from aloopStatemethod to a*trackmethod (the off-loop runner owner). - The sequential counter rebind is a shadowed guard.
runMISequentialrebinds the §2.9 counts before the throw, but its error return can't fire whenbindInstancealready bound the same host scope earlier in the pass — left uncovered (98.9% diff-coverage; the calleebindMICountersis 100% via parallel). - The e2e's notification op had to count. The shared
markSawOpstores a-1sentinel; 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.