SRD-055 — Multi-Instance (sequential)¶
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-07-21 |
| Owner | Ruslan Gabitov |
| Implements | ADR-025 v.2 §2.4–§2.7, §2.9 (the Multi-Instance model — the sequential slice; §2.8 behavior → SRD-056.B), §2.12 (composite iteration as an off-loop decorator); epic #88 |
| Upstream | ADR-017 v.1 (the single-writer loop the decorator requests scope operations from), ADR-011 v.7 (the structural data.Collection — Count/GetAt/SetAt — the split/assemble mediator rides), ADR-010 v.2 (name-based data-plane resolution), ADR-023 v.2 (the composite child-scope lifecycle), ADR-013 v.2 (iteration facts), ADR-001 v.6 |
| Refines | — |
| Related | SRD-054 (the iteration decorator this reuses and extends) |
§1 Background¶
BPMN 2.0 §13.3.7 lets an activity carry MultiInstanceLoopCharacteristics — run
the inner activity N times (fixed at activation, from an int loopCardinality
XOR a collection's size), splitting a collection element in per instance and
assembling one out, with a completionCondition for early stop. This SRD lands the
sequential shape (one instance at a time).
This is the second slice of the ADR-025 v.2 decorator re-landing (§2.12,
after SRD-054's composite Standard Loop). The prior landing drove sequential-MI
iteration control on the loop goroutine via the compositeIterator/miIterator
seam (firstOpen/beforeClose/afterDrain called from onScopeOpen/completeScope/
resumeScopeHost). §2.12 moved that control onto the activity's own off-loop
execution — the iteration decorator. SRD-054 landed the engine (the scopeReq
request/response scope protocol + runCompositeLoop); this SRD re-lands sequential
MI on it as runMISequential, so the compositeIterator seam retires entirely.
This SRD deletes and reuses the SRD-055 slot: its prior (loop-goroutine-driven) execution content is replaced with the decorator design; the model and the data mediator / completion / attribute semantics are unchanged — only who drives the iteration moves off the loop.
The one forced loop-side operation — output capture. The outputDataItem of a
completed instance must be read from its child scope before that scope closes
(the visibility barrier assembles it into a private staging collection). The
decorator only wakes after the drain (its awaitScopeDrained is released by the
scopeDone the loop delivers post-CloseScope), and Scope.GetData can only read
a scope that is still open. So the capture — and only the capture — stays a loop-side
step in completeScope, symmetric with parallel MI's existing captureParallelOutput
(SRD-056.A); the decorator drives everything else off the loop. The
scopeDone-on-evtCh send is the happens-before fence: the loop captures before it
emits scopeDone, so the staged output is complete when the runner wakes; because MI
is sequential, one instance's staging slot is in flight at a time and the publish
barrier is after the last drain — no concurrent reader/writer.
§2 Requirements¶
Functional — the model (landed, unchanged)¶
- FR-1 —
MultiInstanceLoopCharacteristicstype. ALoopCharacteristicsimplementation (activities/multiinstance.go) carryingisSequential,loopCardinality(int expr, nil ⇒ collection-driven),loopDataInputRef/loopDataOutputRef/inputDataItem/outputDataItem(string names), andcompletionCondition(bool expr, nil ⇒ run all N); embedsfoundation.BaseElement. - FR-2 — construction validates all inputs.
NewMultiInstance(opts…): XOR ofloopCardinality/loopDataInputRef;loopCardinality.ResultType()=="int";completionCondition.ResultType()=="bool"; a collection-driven MI requiresinputDataItem. Self-identifying errors. - FR-3 — Event Sub-Process rejects MI (inherited). Covered by SRD-054 FR-3a
(the shared
LoopCharacteristicsguard); a test asserts it holds for the MI marker.
Functional — the decorator drives sequential MI off the loop (reworked)¶
- FR-4 —
multiInstanceOfdetector. The capability detector (mi.go, sibling tostandardLoopOf) reports the MI characteristics; mutually exclusive withstandardLoopOf. - FR-5 — the host drives its own iteration via
runMISequential. A sequential-MI composite iterates on its own runner goroutine (a newrunMISequential, routed byexecuteStepbefore the park path, likerunCompositeLoop). It resolves N once, then for each instance opens the child scope (scopeRoundtrip), parks for the drain (awaitScopeDrained), evaluates the completion condition, and on exit publishes the assembled output and follows the outgoing flow once. The host no longer parks for control — adrivesOwnIteration(node)helper (Standard-Loop composite or sequential-MI composite) makescheckNodeType/enterCompositenot-park it. Parallel MI still parks (its fan-out driver is a separate slice). - FR-6 — cardinality once at activation. N is resolved exactly once, off the
loop, at the top of
runMISequential:loopCardinalityevaluated to anint(the transient frame +EvaluatepathresolveActivationalready uses), or the referenced collection'sCount(). Frozen for the activity's lifetime (§13.3.7). N ≤ 0 completes with zero instances (no scope opened, no publish, follow outgoing once). - FR-7 — sequential re-entry. Instance i+1's scope opens only after instance
i's has drained — the decorator's
for i := 0; i < Nloop awaits each drain before the nextscopeRoundtrip. At most one instance scope is ever open.
Functional — the data mediator (semantics unchanged; loci per §2.12)¶
- FR-8 — split in (off the loop). Before instance i's scope opens, the
decorator binds
inputDataItem= element i of theloopDataInputRefcollection at the host scope (bindDataItemAt, a mutex-safe plane write, off the loop — like SRD-054's off-loopbindLoopCounterAt); the body reads it by name via walk-up. The bind lands beforescopeRoundtrip, so the seeded body sees it. - FR-9 — assemble out (capture loop-side, assemble off-loop). When instance i
drains, its
outputDataItemis read from the still-open child scope into slot i of a private staging collection — this capture stays on the loop goroutine (captureSequentialOutputincompleteScope, beforeCloseScope), the one operation the decorator cannot do (§1, §4.2). The staging is host-owned; the loop's per-passSetAt(i,…)write is fenced before thescopeDonethe decorator awaits. - FR-10 — visibility barrier. The staging collection is not scope-visible
during the run (no per-slot commit, no partial reads, no per-slot
DataChange); the decorator publishes it underloopDataOutputRefat the host scope once (bindValueAt, off the loop), at activity completion. A never-run instance leaves its slot at its pre-run value.
Functional — completion condition & runtime attributes (semantics unchanged)¶
- FR-11 —
completionCondition. Evaluated off the loop after each instance drains (evalCompletion— its existingopenFrameAt+Evaluate+ bool-assert):true→ the activity is done now — no further instance launches, the output publishes, the host follows its outgoing;false→ the next instance launches (or the activity completes when all N are done). For sequential MI, "cancel remaining" is simply "stop launching" — one instance runs at a time, so there is no active-scope teardown. The decorator incrementsnumberOfCompletedInstancesand rebinds the §2.9 attributes before the evaluation, preserving the exact count the prior landing exposed to the condition. - FR-12 — runtime attributes. A per-host
miState(frozen N, completed count, the staging) drivesnumberOfInstances/numberOfCompletedInstances/numberOfActiveInstances(0 or 1 for sequential), bound at the host scope off the loop alongsideloopCountereach pass socompletionConditionand the body resolve them by name.miStatebecomes host-runner-owned (the decorator drives it), except the single loop-sideSetAtcapture (FR-9).
Functional — observability & front door¶
- FR-13 — observability. Each instance's scope facts carry the iteration ordinal
—
scopeLoopCounterkeys ondrivesOwnIteration(node)so a decorator-driven sequential MI still reportsloopCounter(the same fix SRD-054 M2 made for SL). - FR-14 — front door.
examples/multi-instance-sequential/, the iteration guide,CHANGELOG.md, the conformance tracker, the READMEs (EN + RU) already describe sequential MI (behavior unchanged); the rework needs only a CHANGELOG note.
Non-functional¶
- NFR-1 — reuse the decorator, don't duplicate. No new scope-lifecycle / seeding
/ drain / protocol code —
runMISequentialreuses the landedscopeRoundtrip/awaitScopeDrained/handleScopeRequest/executeNode. The genuinely new code isrunMISequential+captureSequentialOutput(a relocation ofbeforeClose) + thedrivesOwnIterationhelper. - NFR-2 — single-writer preserved (ADR-017 v.1). The loop stays the sole writer
of scope lifecycle (
OpenScope/CloseScope,ls.scopes,ls.waiting) and performs the one pre-close plane read (the capture). The decorator does only mutex-safe plane writes (bindDataItemAt/bindValueAt) off the loop. - NFR-3 — the staging fence is the one deliberate cross-goroutine field. Written
on the loop (the per-pass capture) and read off-loop at publish, ordered by the
scopeDone-on-evtChedge; safe only because MI is sequential (one slot in flight, barrier at end). Documented onmiState;-raceon the e2e guards it. - NFR-4 — deferred surfaces stay out. No
behavior(SRD-056.B), no parallel execution (SRD-056.A untouched), no compensation. - NFR-5 — the existing suites are the safety net. The landed
TestMultiInstance*(internal/instance/mi_test.go) +pkg/thresher/mi_sequential_test.gostay green throughout — behavior is unchanged. - NFR-6 — coverage. Every touched file finishes ≥95% diff-coverage (aim 100%);
make cigreen.
§3 Models¶
§3.1 Model type (pkg/model/activities/multiinstance.go) — landed, unchanged¶
MultiInstanceLoopCharacteristics (embedding foundation.BaseElement) with the
FR-1 fields, built by NewMultiInstance(opts…) under the FR-2 guards. No model
change in this SRD — the rework is runtime-only.
§3.2 Runtime deltas (internal/instance/) — the decorator drives MI¶
-
mi.go—runMISequential(new, host runner) +miStaterelocation.runMISequential(ctx, step, mi): n, col := resolveActivation(...) // off-loop; count once (FR-6) host.miState = &miState{n, col, staging?, names} // off-loop if n <= 0 { host.miState = nil; return t.executeNode(ctx, step) } // zero-instance for i := 0; i < n; i++ { bindInstance(ctx, host, i) // off-loop: loopCounter=i, numberOf* attrs, inputItem=col.GetAt(i) (FR-8/12) scopeRoundtrip{host, node} // loop opens child scope, seeds, arms (reuse handleScopeRequest) awaitScopeDrained(ctx) // loop runs captureSequentialOutput + CloseScope, then scopeDone (FR-9) host.miState.completed++ // off-loop rebindCounters(host) // off-loop: numberOfCompletedInstances now current (FR-11) if mi.CompletionCondition() != nil && evalCompletion(...) { break } // stop-launching (FR-11) } publishOutput(host) // off-loop: bindValueAt(host.scopePath, outputRef, staging) — barrier (FR-10) host.miState = nil return t.executeNode(ctx, step) // follow the composite's single outgoing once (FR-7)resolveActivation,bindInstance,evalCompletion,publishOutput— kept, their callers move to the runner (off-loop).resolveActivationstays reused by parallel MI'sfanOutParallelMI(untouched). -
scope_runtime.go—captureSequentialOutput(the one loop-side step) + seam rewire.beforeClose's capture (GetData(childPath, outputItem)→staging.SetAt(loopCounter, …)) is relocated into a plaincaptureSequentialOutputcalled fromcompleteScopebeforeCloseScope, symmetric withcaptureParallelOutput.resumeScopeHost's top-guard generalizes fromstandardLoopOftodrivesOwnIteration(a sequential-MI drain justdispatchToParked(scopeDone)to the parked runner, noafterDrain).scopeLoopCounterkeys ondrivesOwnIteration. -
track.go/std_loop.go— the interception.drivesOwnIteration(node)= SL-composite OR sequential-MI-composite;enterCompositereturns nil (no park) for it;executeSteproutes ascopeHostsequential MI torunMISequential. -
Seam removal (M2).
miIterator.firstOpen/afterDrain, thecompositeIteratorinterface +compositeIteratorOf, and theonScopeOpen/resumeScopeHostsequential-MI branches are deleted (both SL and sequential MI now leave the seam).
§4 Analysis¶
§4.1 runMISequential realizes ADR-025 v.2 §2.12¶
The decorator drives the count-driven iteration on the host runner, requesting scope
opens from the single-writer loop via the SRD-054 protocol — the locus §2.12
dictates. It is a separate driver from runCompositeLoop (not a strategy hook):
MI is count-driven (N once) vs condition-driven; carries per-instance data + output
assembly; and its condition is a completion (test-after-body, early-stop) not a
continuation (test-before-body). The only shared primitives — scopeRoundtrip,
awaitScopeDrained, executeNode — are already small standalone *track helpers;
a shared skeleton for two divergent bodies would be more branching than sharing.
§4.2 The capture stays loop-side — the only viable option (FR-9)¶
captureSequentialOutput reads the child scope's outputDataItem and must run in the
window between the inner tracks finishing and CloseScope — a window only the loop
observes (it is what triggers completeScope). A capture request is impossible:
the decorator is parked in awaitScopeDrained and learns of the drain only via the
scopeDone delivered after CloseScope; an off-loop capture at
scopeRoundtrip-return is too early (the body has not run). So the capture is
loop-side by necessity, not preference — and it is symmetric with the parallel MI
capture already living in completeScope. Safety: the loop's SetAt(i,…) happens
before it emits scopeDone (dispatchToParked → evtCh <- scopeDone), which happens
before the runner's awaitScopeDrained receive; sequential execution means one slot
is written at a time and the publish read is after the last drain — no overlap.
§4.3 The completion-condition sees the current count (FR-11)¶
completionCondition reads the §2.9 attributes at the host scope. The decorator
increments completed and rebinds numberOfCompletedInstances (and the running
attrs) before evalCompletion, so the condition sees the post-drain count —
the exact value the prior afterDrain/bindInstance sequencing exposed. Preserving
this ordering keeps the completion tests (e.g. the numberOfCompletedInstances >= k
scenarios) identical.
§4.4 Zero-instance and the visibility barrier (FR-6/FR-10)¶
n ≤ 0 opens no scope and follows the composite's outgoing once (executeNode), with
no publish (staging is unallocated). A completing activity publishes the staging under
loopDataOutputRef once, off the loop, after the last drain — the single visibility
barrier; no intermediate slot is scope-visible.
§6 Test scenarios¶
| Test | Level | Covers |
|---|---|---|
TestMultiInstanceRunsNSequentially |
instance | FR-5/FR-7 — N passes, one scope at a time (existing, green) |
TestMultiInstanceZeroCardinality |
instance | FR-6 — n≤0, no scope, follow outgoing (existing, green) |
TestMultiInstanceCardinalityFromCollection |
instance | FR-6 — count = collection size (existing, green) |
TestMultiInstanceInputItemVisible |
instance | FR-8 — per-instance input split visible to the body (existing, green) |
TestMultiInstanceAssemblesOutput |
instance | FR-9/FR-10 — positional staging → published collection (existing, green; -race) |
TestMultiInstanceRuntimeCounters |
instance | FR-12 — numberOf* attributes (existing, green) |
| completionCondition tests | instance | FR-11 — stop-launching on true (existing, green) |
| cardinality/collection error tests | instance | FR-6 — eval/type/missing-ref errors (existing, green) |
TestMultiInstanceSequentialE2E |
thresher | FR-5–FR-10 end-to-end, ordering [2,3,4] (existing, green) |
Grep before M2: any test asserting on miIterator/compositeIterator internals is
re-pointed at runMISequential / observable behavior; the outcomes (order, counts,
output collection, errors) stay identical.
§7 Milestones¶
| # | Scope | Files |
|---|---|---|
| M1 | runMISequential + drivesOwnIteration; wire enterComposite (not-park) + executeStep (route); relocate beforeClose → captureSequentialOutput in completeScope; generalize the resumeScopeHost guard + scopeLoopCounter. compositeIteratorOf kept temporarily. TestMultiInstance* + e2e green on the decorator. |
mi.go, scope_runtime.go, track.go, std_loop.go |
| M2 | Remove the dead seam — miIterator.firstOpen/afterDrain, the compositeIterator interface + compositeIteratorOf, the onScopeOpen/resumeScopeHost sequential-MI branches (mirror of SRD-054 M2). |
mi.go, composite_iter.go, scope_runtime.go |
| M3 | CHANGELOG note; SRD-055 §10; doc sync. | docs |
§8 Cross-doc¶
- Implements ADR-025 v.2 §2.4–§2.7, §2.9, §2.12.
- Upstream ADR-017 v.1, ADR-011 v.7, ADR-010 v.2, ADR-023 v.2, ADR-013 v.2, ADR-001 v.6.
- Related SRD-054 (the decorator engine reused). Direction: SRD → ADR / SRD only (up/sideways), version-pinned; no downward reference.
§9 Definition of Done¶
- FR-1…FR-14 wired; FR-5/FR-6/FR-7 via
runMISequential, FR-8/FR-10 off-loop, FR-9 via the loop-sidecaptureSequentialOutput. - §6 tests exist and pass; the landed sequential-MI suite + e2e stay green (NFR-5);
examples/multi-instance-sequential/runs and exits 0. - The
compositeIteratorseam is removed (M2); parallel MI (SRD-056.A) is unaffected. - Single-writer preserved (NFR-2); the staging fence documented (NFR-3) and
-racegreen on the assembly e2e. make cigreen (verify the gate's own completion markers, not a wrapper exit); CHANGELOG[Unreleased]notes the internal rework./check-srdPASS before flipping status; ADR-025 v.2 stays Draft until the whole re-landing (parallel MI + behavior) completes.
§10 Implementation summary¶
§10.1 Stages by commit (branch feat/mi-sequential-decorator)¶
| Stage | Commit | Scope | Tests |
|---|---|---|---|
| doc | a944451 |
SRD-055 rewritten (delete-and-reuse) for the off-loop decorator | — |
| M1 | a43c882 |
runMISequential + drivesOwnIteration (mi.go); executeStep routing (std_loop.go); enterComposite not-park (track.go); relocate beforeClose → captureSequentialOutput, generalize the resumeScopeHost guard + scopeLoopCounter (scope_runtime.go); TestRunMISequential{RequestError,BindError,DrainError} + miState doc |
3 new white-box + landed TestMultiInstance* + e2e green |
| M2 | a63e4f4 |
remove the dead seam — composite_iter.go (interface + compositeIteratorOf), miIterator.firstOpen/afterDrain, the onScopeOpen/resumeScopeHost MI branches; TestCompositeIteratorDispatch → TestDrivesOwnIteration |
rewrote 1 dispatch test; make ci diff-coverage 97.7% |
§10.2 Empirical findings vs the draft¶
- The capture stayed exactly where §4.2 predicted.
captureSequentialOutputlives loop-side incompleteScope(beforeCloseScope), symmetric with parallel MI'scaptureParallelOutput— no surprise; thescopeDone-on-evtChfence held and-raceon the assembly e2e is clean. - No fold step was needed (unlike SRD-054). SRD-054's protocol landed with no
caller until its runner arrived; here M1's
runMISequentialis the runner, so the routing and the driver landed together — the seam went unreachable at M1 and was deleted at M2, no intermediate dead-caller stage. - The
bindInstancefail-fast guard is reachable only past the property clone. A broken input collection seeded as a process property is deep-copied by the snapshot→instance clone (its overriddenGetAtis lost), so the guard never fired from a property fixture.TestRunMISequentialBindErrorinjects the broken collection straight into the running scope viasc.bindValueAt(the data plane returns data by reference, no clone) to exercise thecol.GetAt(i)-error path white-box — confirming the guard is a real fail-fast, not unreachable defense.
§10.3 Backlog¶
- Parallel MI on the decorator (SRD-056.A re-land) and
behavior(SRD-056.B) are the remaining ADR-025 v.2 §2.12 slices; ADR-025 v.2 flips Accepted when they land. - One shadowed
publishOutput-error return remains uncovered (97.7%, above the 95% gate) — a defensive propagation behind the already-coveredstaging == nilno-op; no white-box hook worth a fixture.
Open questions¶
None.