Skip to content

SRD-056.A — Multi-Instance (parallel)

Field Value
Status Accepted
Date 2026-07-21
Owner Ruslan Gabitov
Implements ADR-025 v.2 §2.5 (parallel execution), §2.6 (data mediator), §2.7 (completion-condition cancellation), §2.9 (runtime attributes), §2.12 (composite iteration as an off-loop decorator) — the parallel slice; §2.8 behavior → SRD-056.B; epic #88
Upstream ADR-017 v.1 (the single-writer loop the decorator requests scope operations from), ADR-023 v.2 (the execution-scope re-entry + cancelScope), ADR-018 v.1 (scoped interruption / boundary cancel this reuses per instance), ADR-011 v.7 (the data.Collection split/assemble mediator), ADR-010 v.2 (name-based data resolution), ADR-013 v.2 (iteration facts), ADR-001 v.6
Refines
Related SRD-054 (the composite-iteration decorator engine — the scopeRequest/scopeRoundtrip protocol this extends), SRD-055 (the sequential Multi-Instance slice this parallels), SRD-053 (the unique per-instance scope segment + N-concurrent-scope precedent)

§1 Background

BPMN 2.0 §13.3.7 lets a parallel Multi-Instance (isSequential = false, the model default) run its inner activity N times concurrently — all N instances start at activation, run in distinct per-instance scopes, split a collection element in per instance, assemble one out, and the activity completes when the last drains; a completionCondition cancels the still-running remainder.

This is the third slice of the ADR-025 v.2 decorator re-landing (§2.12, after SRD-054's composite Standard Loop and SRD-055's sequential MI). The prior landing drove the parallel fan-out and the N-of-N barrier entirely on the loop goroutine: fanOutParallelMI opened N scopes at the host's onScopeOpen, the host parked once, and parallelInstanceDrained (loop-side, from completeScope) counted each drain, evaluated the completionCondition, cancelled the remainder, published the output, and resumed the host at exhaustion. §2.12 moves that control onto the activity's own off-loop runner — the iteration decorator — so §2.8's behavior throw (SRD-056.B) becomes an ordinary off-loop throw caught on the MI boundary, instead of a loop-goroutine throw that self-deadlocks on the loop's own inbound channel.

This SRD deletes and reuses the SRD-056.A slot: its prior (loop-goroutine-driven) execution content is replaced with the decorator design. The model, the per-instance data mediator, positional assembly, the completion semantics, and the runtime attributes are unchanged — only who drives the fan-out and the barrier moves off the loop.

§1.1 The constraint that shapes the parallel barrier

A composite host's off-loop runner parks on a single, cap-1, waiting-gated channel (track.evtCh, eventBufferDepth == 1; dispatchToParked flips the host out of ls.waiting and blocks on the send). Sequential MI (SRD-055) fits this perfectly — one instance scope is open at a time, so one scopeDone arrives per pass and awaitScopeDrained reads exactly one. Parallel has N concurrent instance scopes draining in arbitrary order at that single-goroutine, single-slot runner, so the loop cannot fire N scopeDones at it — each delivery needs the runner re-armed first.

The decorator therefore drives the barrier with a per-drain handshake (ADR-025 v.2 §2.12 "fan-out-then-await-all"): the loop delivers instance drains to the runner one at a time through the cap-1 re-arm, queueing any that arrive while the runner is busy; the runner processes each off-loop (count, bind the §2.9 attributes, evaluate the completionCondition, and — in SRD-056.B — throw the behavior event) and re-arms for the next. All completion policy runs on the runner; the loop performs only the single-writer mutations it requests (open N, capture each output before its scope closes, cancel the remainder, publish).

In scope: the off-loop parallel driver (runMIParallel), the fan-out + N-of-N handshake barrier, the per-instance data mediator (reused), positional assembly + the visibility barrier (reused), completionCondition cancellation, the runtime attributes, and MI-aware cancelHostScope (the interrupting-boundary teardown of all N). Deferred: behavior/ComplexBehaviorDefinition → SRD-056.B; MI compensation → future Transaction work (ADR-025 §2.10). Multi-Instance stays composite-host-only (as SRD-055 — no leaf-Task MI path).

§2 Requirements

Functional — the model (landed, unchanged)

  • FR-1 — no model change. MultiInstanceLoopCharacteristics already expresses parallel (isSequential = false, the default), constructed under the SRD-055 FR-2 guards. This SRD is runtime-only.

Functional — the decorator drives parallel MI off the loop (reworked)

  • FR-2 — the host drives its own fan-out via runMIParallel. A parallel-MI composite iterates on its own runner goroutine (a new runMIParallel, routed by executeStep before the park path, like runMISequential). It resolves N once off the loop, requests the loop to fan out N instance scopes, then runs the per-drain barrier and, on exit, follows the composite's single outgoing flow once. The host no longer parks for controldrivesOwnIteration(node) now covers a parallel-MI composite too (Standard-Loop or any Multi-Instance composite), so checkNodeType/enterComposite do not park it and the old onScopeOpen fan-out dispatch is removed.
  • FR-3 — fan out N distinct instance scopes (loop-side, on request). A scopeRequest{op: scopeFanOut, n, col} roundtrip asks the loop to open N child scopes — instance i at segment scopeSegment(node) + "-" + i (0-based, deterministic, ADR-025 §2.2/§2.5) — binding each instance's loopCounter + input item at its own scope and seeding + arming its body, all before the reply. Because the handler runs to completion on the loop goroutine, every instance is registered in the loop-owned group and the host is marked waiting before any drain is processed. N ≤ 0 opens no scope, creates no group, and follows the outgoing once.
  • FR-4 — the loop owns a lean N-of-N group; the runner owns the policy. A loop-owned miGroup (the host, node, the still-open instance-path→ordinal set, the pre-sized staging collection, the output names, N, and the undelivered-drain count) is the barrier substrate the per-scope scopeEntry model cannot express. The counting, the completionCondition, the cancellation decision, and the §2.9 attribute binds run on the runner (off the loop); the loop only mutates the group on request.

Functional — the per-drain handshake barrier

  • FR-5 — one drain, one delivery. Each instance scope drains through the existing decScopecompleteScope, which (on the loop) captures the instance's output before the scope closes (FR-8), removes it from the group's open set, and then delivers one scopeDone to the runner if it is waiting, else queues it (increments the undelivered count). The runner receives exactly one scopeDone per completed instance, in the loop's completion order.
  • FR-6 — re-arm delivers the next. After processing a drain the runner issues a scopeRequest{op: scopeReArm} roundtrip; the loop re-marks the host waiting and, if a drain is queued, immediately delivers one (dequeues). The runner then awaitScopeDraineds the next. The handshake makes the loop deliver concurrent drains strictly one at a time — the cap-1 channel is never sent to while the runner is busy, so the single-writer loop never blocks on it.
  • FR-7 — completion order is nondeterministic; the engine is ordered. Instances run concurrently, but each scope's completeScope runs on the loop goroutine, one at a time — so output capture and the group mutations are single-writer and race-free; and the runner processes one delivered drain at a time, so the counters and the completionCondition evaluation are single-reader without new locks.

Functional — the data mediator (per-instance scope; unchanged)

  • FR-8 — split in / assemble out, at each instance's own scope. Instance i's inputDataItem = element i of the loopDataInputRef collection is bound into instance i's child scope at fan-out (loop-side, reused openParallelInstance) — not the host scope, which SRD-055 could use only because it runs one at a time; concurrent instances would clobber a shared loopCounter/item. When instance i drains, its outputDataItem is read from its child scope into slot i of the private staging collection, keyed by the ordinal the scope carries (frozen at fan-out), not the host's mutable loopCounter — the one loop-side capture, captureParallelOutput in completeScope before CloseScope (reused). Positional assembly (slot = input ordinal) is deterministic despite nondeterministic completion order (ADR-025 §2.6).
  • FR-9 — visibility barrier. The staging collection is never scope-visible during the run (pre-sized to N; SetAt(ordinal) replaces; a cancelled slot keeps its pre-run nil). It publishes under loopDataOutputRef at the host scope once, at activity completion — the loop performs the bindValueAt in the terminal scopeComplete handler (the staging is loop-owned; publish is a plane write, not a throw, so it stays loop-side, unlike sequential's off-loop publish).

Functional — completion condition & cancellation

  • FR-10 — completionCondition cancels the remainder. Evaluated on the runner, off the loop, after each delivered drain (evalCompletion reused, reading the §2.9 attributes the runner bound at the host scope): true → the activity is done now — the runner issues scopeRequest{op: scopeComplete, cancel: true}; the loop cancels every still-open instance scope as a unit (cancelScope(path, PhaseCanceled), ADR-018 mechanism, idempotent + per-path), counts them in numberOfTerminatedInstances, publishes the assembled output over the completed slots, drops the group, and replies with the terminated count. Cancelled instances contribute no outputDataItem (their slot stays nil). false → the instance is counted and the runner re-arms for the next.
  • FR-11 — natural completion. When the runner has received N drains with no completionCondition firing, it issues scopeRequest{op: scopeComplete, cancel: false}; the loop publishes the staging and drops the group.

Functional — runtime attributes & host teardown

  • FR-12 — runtime attributes (§2.9). After each delivered drain the runner binds the §2.9 attributes at the host scope (off the loop, bindDataItemAt, mutex-safe), where the completionCondition (and the body) resolve them by name: numberOfInstances (frozen N), numberOfActiveInstances (still-running, genuinely > 1 for parallel), numberOfCompletedInstances (the delivered count — monotonic, one per evaluation, matching sequential), and numberOfTerminatedInstances (§2.7). Each instance additionally sees its own 0-based loopCounter in its own scope (bound at fan-out).
  • FR-13 — MI-aware host teardown. An interrupting boundary firing on the MI activity (or a scoped Terminate reaching it) tears down all N instance scopes: cancelHostScope is MI-aware — for a parallel-MI host it cancels the group's open paths as a unit and drops the group (reused cancelParallelGroup). The parked runner unblocks through awaitScopeDrained's evtCh-closed path (as SL / sequential MI), and runMIParallel returns.
  • FR-14 — observability. Each instance scope's Opened/Completed/Canceled facts carry that instance's own 0-based ordinal — entry.ordinal, frozen on the instance scope at fan-out — not the shared host.loopCounter, so concurrent passes are individually observable. The ordinal is applied by the completeScope grouped-entry override (if entry.group != nil { ordinal = entry.ordinal }) and openParallelInstance's direct inot by scopeLoopCounter, which returns the meaningless host.loopCounter for a self-driving node. Because this SRD widens drivesOwnIteration to include parallel MI, scopeLoopCounter(parallel) flips from -1 to host.loopCounter; M1 therefore extends the same entry.group ordinal override to the cancel-path fact (cancelScope's reportScope) so a cancelled instance reports its ordinal, not the shared counter.

Functional — front door

  • FR-15 — front door. examples/multi-instance-parallel/ (the voting worked example — a parallel MI over a candidate list with a completionCondition quorum), the iteration guide, CHANGELOG.md, the conformance tracker, and the READMEs (EN + RU) already describe parallel MI (behavior unchanged); the rework needs only a CHANGELOG note.

Non-functional

  • NFR-1 — reuse the decorator + the concurrent-scope substrate. No new scope open/seed/drain/cancel primitives — runMIParallel reuses the SRD-054 scopeRoundtrip/awaitScopeDrained protocol and the SRD-056.A-landed openParallelInstance / captureParallelOutput / cancelOpenInstances / cancelParallelGroup. The genuinely new code is runMIParallel, the three new scope-protocol ops (scopeFanOut / scopeReArm / scopeComplete) and their loop-side handlers, and the completeScope group-branch rewrite (capture + dispatch-or-queue instead of the loop-side driving).
  • NFR-2 — single-writer preserved (ADR-017 v.1). The loop stays the sole writer of scope lifecycle (OpenScope/CloseScope, ls.scopes, ls.miGroups, the group fields) and performs the pre-close capture and the final publish. The runner does only mutex-safe plane writes (bindDataItemAt for the §2.9 attributes) off the loop, and never reads loop-owned group state mid-flight — it counts delivered drains locally and learns the terminated count from the scopeComplete reply.
  • NFR-3 — no new locks; the handshake is the fence. All group state is loop-goroutine-owned; concurrency lives only in the instance bodies, which scope isolation already separates. The cap-1 re-arm handshake serializes drain delivery, so the loop never blocks on the runner's channel. -race on the e2e guards it.
  • NFR-4 — deferred surfaces stay out. No behavior/ComplexBehaviorDefinition (SRD-056.B), no compensation, no leaf-Task MI.
  • NFR-5 — the existing suites are the safety net. The landed TestParallelMultiInstance* (internal/instance/mi_parallel_test.go) + pkg/thresher e2e + the SRD-054/SRD-055 suites stay green throughout — behavior is unchanged.
  • NFR-6 — coverage. Every touched file finishes ≥95% diff-coverage (aim 100%); make ci green, -race clean.

§3 Models

§3.1 Runtime deltas (internal/instance/)

  • mi_parallel.gorunMIParallel (new, host runner) + the group relocation.

    runMIParallel(ctx, step, mi):
      n, col := resolveActivation(...)                    // off-loop; count once
      if n <= 0 { return t.executeNode(ctx, step) }       // zero-instance
      scopeRoundtrip{op:scopeFanOut, host:t, node, n, col} // loop opens N + group + marks waiting
      for completed := 0; ; {
         awaitScopeDrained(ctx)                           // one delivered drain
         completed++
         bindParallelCounters(t, n, completed, terminated=0) // off-loop (FR-12)
         if completed < n && mi.CompletionCondition() != nil && evalCompletion(...) {
            term := scopeRoundtrip{op:scopeComplete, cancel:true}  // loop cancels+publishes+drops
            bindParallelCounters(t, n, completed, term)   // final off-loop
            break                                          // (SRD-056.B: throw before this)
         }
         if completed == n {
            scopeRoundtrip{op:scopeComplete, cancel:false} // loop publishes+drops
            break
         }
         scopeRoundtrip{op:scopeReArm}                    // loop re-marks waiting + delivers next queued
      }
      return t.executeNode(ctx, step)                     // follow the composite's outgoing once
    
    bindParallelCounters moves off the loop (the runner calls bindDataItemAt); resolveActivation / evalCompletion reused (mi.go).

  • scope_decorator.go — the protocol gains an op. scopeRequest carries an op scopeOp (scopeOpen — the existing sequential/SL open; scopeFanOut; scopeReArm; scopeComplete) plus n int / col data.Collection (fanOut) / cancel bool (complete); scopeReply gains terminated int. handleScopeRequest dispatches on op:

  • scopeFanOut → build the miGroup, openParallelInstance ×N (reused), mark the host waiting, reply.
  • scopeReArmls.waiting[host] = {}; if the group has a queued drain, dispatch one scopeDone; reply.
  • scopeComplete → if cancel, cancelOpenInstances (reused) counting terminated; publish grp.staging at the host scope (bindValueAt); drop ls.miGroups[host]; reply(terminated).

  • mi_parallel.gomiGroup keeps host/node/open/staging/outputRef/ outputItem/n, adds pending int (undelivered drains). The loop-side driving fields (completed/terminated/mi/collection/inputItem) that only the old parallelInstanceDrained used are dropped — the runner owns those counts; the fan-out reads names/collection from the request + multiInstanceOf(node).

  • scope_runtime.gocompleteScope group-branch rewrite. Keep the captureParallelOutput step (forced loop-side, FR-8). Replace the parallelInstanceDrained call with: remove the path from grp.open; if ls.waiting[host]dispatchToParked(scopeDone); else grp.pending++. Remove the onScopeOpen parallel-MI fan-out dispatch (a parallel MI never parks now). resumeScopeHost's drivesOwnIteration guard is untouched (the group branch returns before it).

  • track.go / std_loop.go — routing. drivesOwnIteration(node) becomes Standard-Loop composite or any Multi-Instance composite (drop the IsSequential() narrowing); executeStep routes a scopeHost parallel MI to runMIParallel.

  • Seam removal (M2). fanOutParallelMI, parallelInstanceDrained, the loop-side bindParallelCounters, and the onScopeOpen parallel branch are deleted (the old loop-driven fan-out + barrier). openParallelInstance, captureParallelOutput, cancelOpenInstances, cancelParallelGroup, cancelHostScope's MI branch are kept (reused by the new handlers / boundary teardown).

No model changebehavior fields land in SRD-056.B.

§4 Analysis

§4.1 Why the barrier moves off the loop (FR-2, ADR-025 v.2 §2.12)

The v.1 parallel barrier lived in parallelInstanceDrained on the loop goroutine — it counted drains, evaluated the completionCondition, cancelled the remainder, and resumed the host. SRD-056.B must throw a behavior event at those exact points (per instance / on completion, §2.8). A throw hands the event to the loop's ordered inbound channel; issued from the loop goroutine (the channel's only reader, busy inside the throw) it self-deadlocks, and made fire-and-forget it drops the boundary catch nondeterministically (§2.12). Because the behavior decision depends on the aggregate group state (None/One/All/Complex over the completed count), it must run wherever the barrier is — so the barrier itself must move off the loop. This SRD does that now, so SRD-056.B adds only the throw at an already-off-loop decision point, with no barrier rework.

§4.2 The per-drain handshake is forced by the cap-1 runner park (FR-5, FR-6)

The naive "the runner awaits N drains" is impossible: the runner's evtCh is cap-1 and waiting-gated, so the loop can deliver a scopeDone only when the host is waiting, and delivering flips it not-waiting. With N concurrent drains and a single busy runner, the loop would block on the second send (§1.1). The handshake resolves it: completeScope delivers one drain if the host is waiting, else queues it (grp.pending), and scopeReArm re-marks the host waiting and dequeues the next. Every loop-side step — capture, close, group mutation, dispatch/queue, re-arm — runs on the loop goroutine and is serialized, so there is no race between a concurrent drain's completeScope and a scopeReArm handler. The runner sees drains strictly one at a time; the concurrency is only in the instance bodies (unchanged).

§4.3 The runner never reads loop-owned group state mid-flight (NFR-2)

Delivery is 1:1 with completion, in order, so the runner's local completed count (number of scopeDones received) equals the group's authoritative completed count for every instance it has been told about — it never reads grp.completed. numberOfCompletedInstances as the completionCondition sees it is the delivered count (monotonic, one increment per evaluation) — the same "the condition sees each completion once, in order" contract as sequential MI, and a cleaner definition than the v.1 loop-side grp.completed (which could jump ahead of the evaluated drain when siblings drained into the queue). The terminated count comes back in the scopeComplete reply; grp.staging is read only at publish, which the loop performs (FR-9), after the last capture — no cross-goroutine read of loop state. The existing TestParallelMultiInstanceRuntimeAttributes asserts only completion (a never-true numberOfInstances >= 100 condition, all N run), not mid-run active counts, so the delivered-count definition is behavior-preserving against the suite.

§4.4 Publish and capture stay loop-side; only policy is off-loop (FR-8, FR-9)

grp.staging is loop-owned: captureParallelOutput writes it on each drain (before CloseScope — the one window only the loop observes, as for sequential MI), and the final bindValueAt publishes it. Both are plane operations, not event throws, so keeping them on the loop introduces no deadlock and avoids sharing the group pointer with the runner. What genuinely must be off-loop is the policy — count, attribute binds, completionCondition, cancellation decision, and (SRD-056.B) the throw — and that is exactly what runMIParallel owns. This is the parallel counterpart of sequential's captureSequentialOutput staying loop-side (SRD-055 §4.2); parallel additionally keeps the publish loop-side because its staging is group-owned, not host-owned. This sequential/parallel asymmetry is deliberate and both conform to §2.12 ("the loop performs the mutation on request"): sequential publishes off-loop (publishOutput) because its staging is host-owned; parallel routes publish through the scopeComplete request because its staging is group-owned.

§4.5 Cancellation is a single ordered step (FR-10, FR-13)

completionCondition = truescopeComplete{cancel:true} runs one loop step: cancelOpenInstances tears down every still-open instance path (cancelScope, idempotent + per-path), counts them terminated, then publishes over the completed slots and drops the group — no window where the host resumes before the output is published or a sibling's slot is captured. The same open-path set drives cancelHostScope's MI-awareness (FR-13): an interrupting boundary / scoped Terminate on the MI activity tears down all N via the loop-owned group, closing the gap the default-segment resolution (sp-<id>) leaves for a fanned-out activity. A queued (undelivered) drain at cancellation time is already out of grp.open (removed at its completeScope), so it counts as completed and keeps its captured slot — cancellation touches only the truly still-open instances.

§4.6 Zero-instance and the fan-out race

n ≤ 0 opens no scope, creates no group, and follows the composite's outgoing once (executeNode) — no publish (no staging). For n > 0, scopeFanOut runs to completion on the loop goroutine (opening all N, populating grp.open, marking the host waiting) before the loop processes any instance drain, so a fast instance cannot resume the host before the group is fully populated — the same "single loop goroutine removes the fan-out race" property the v.1 landing relied on, now reached through the request handler instead of onScopeOpen.

§6 Test scenarios

The landed internal/instance/mi_parallel_test.go + pkg/thresher e2e are the behavior-preserving safety net (names reconciled at Accepted). New white-box tests cover the decorator's off-loop error paths (runMIParallel's roundtrip / drain / cancel faults), mirroring SRD-055's mi_decorator_test.go.

Test Level Covers
TestParallelMultiInstanceRunsAll instance FR-2/FR-3/FR-5 — N instances all run, complete when the last drains (existing)
TestParallelMultiInstanceDistinctScopes instance FR-3/FR-14 — N distinct -i segments, each fact carries its ordinal (existing)
TestParallelMultiInstanceZeroCardinality instance FR-3 — N ≤ 0, no scope, follow outgoing (existing)
TestParallelMultiInstanceInputItemPerScope instance FR-8 — each instance sees its own element (existing)
TestParallelMultiInstanceAssemblesOutput / …OutputItemMissing instance FR-8/FR-9 — positional output, published once (+ missing-item fault) (existing)
TestParallelMultiInstanceCompletionCancelsRemainder instance FR-10 — true cancels still-open instances, numberOfTerminatedInstances (existing)
TestParallelMultiInstanceRuntimeAttributes / …NonBoolCompletion instance FR-12 — numberOf* readable by the condition (+ non-bool fault) (existing)
TestParallelMultiInstanceBoundaryInterruptsAll instance FR-13 — interrupting boundary tears down all N (existing)
TestParallelMultiInstance{CardinalityError,InputGetAtError,OpenScopeError,PublishError} instance error paths — white-box (existing, re-pointed at the new loci)
TestRunMIParallel{RequestError,DrainError} instance FR-2/FR-6 — fan-out roundtrip on a stopped instance faults; a mid-barrier evtCh close unblocks the runner (new white-box)
TestDrivesOwnIteration instance FR-2 — parallel MI now self-drives (update: parallel → true)
TestParallelMultiInstanceE2E thresher FR-2–FR-12 end-to-end, the voting quorum (existing)

§7 Milestones

# Scope Files
M1 The off-loop parallel driver, switch and seam removal in one commit (the driver cannot half-switch — the moment drivesOwnIteration covers parallel and executeStep routes it, the old onScopeOpen fan-out + parallelInstanceDrained become unreachable and the unused linter fails on them, so their deletion folds into the switch, as SRD-054's M1+M2 folded): runMIParallel + parallelBarrierStep; the scopeFanOut/scopeReArm/scopeComplete protocol ops + handlers (scopeExchange full-reply); the completeScope group-branch rewrite (capture + dispatch-or-queue); drivesOwnIteration covers parallel; executeStep routes it; remove the onScopeOpen fan-out dispatch; cancelScope fact ordinal (FR-14); bindParallelCounters → off-loop bindMICounters. Delete fanOutParallelMI / parallelInstanceDrained / the loop-side bindParallelCounters. Reuses openParallelInstance/captureParallelOutput/cancelOpenInstances/cancelParallelGroup. Existing parallel suite + e2e green under -race; new white-box tests cover the decorator error/guard branches. mi_parallel.go, scope_decorator.go, scope_runtime.go, mi.go, std_loop.go
M2 CHANGELOG note; SRD-056.A §10; doc sync; Accepted flip. docs

§8 Cross-doc

§9 Definition of Done

  • FR-1…FR-15 wired; FR-2/FR-3 via runMIParallel + scopeFanOut, FR-5/FR-6 via the handshake, FR-10/FR-11 via scopeComplete, FR-8 via the reused loop-side capture.
  • §6 tests exist and pass; the landed parallel suite + e2e + the SRD-054/SRD-055 suites stay green (NFR-5); examples/multi-instance-parallel/ runs and exits 0.
  • The old loop-side fan-out + barrier is removed (M2); sequential MI + Standard Loop are unaffected.
  • Single-writer preserved (NFR-2); the handshake fence documented (NFR-3) and -race green on the concurrent e2e.
  • make ci green (verify the gate's own completion markers, not a wrapper exit); CHANGELOG [Unreleased] notes the internal rework.
  • /check-srd PASS before flipping status; ADR-025 v.2 stays Draft until the whole re-landing (SRD-056.B behavior) completes.

§10 Implementation summary

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

Stage Commit Scope Tests
doc e21d7f9 SRD-056.A rewritten (delete-and-reuse) for the off-loop decorator
M1 14bdd10 off-loop parallel driver — runMIParallel + parallelBarrierStep; scopeOp protocol (scopeFanOut/scopeReArm/scopeComplete) + handlers + scopeExchange; completeScope group-branch (capture + dispatch-or-queue); drivesOwnIteration covers parallel; executeStep routing; cancelScope fact ordinal (FR-14); off-loop bindMICounters. Deleted fanOutParallelMI/parallelInstanceDrained/loop-side bindParallelCounters. Switch + seam removal folded. 5 new white-box + 2 re-pointed; landed suite + e2e green under -race; make ci diff-cov 100%

§10.2 Empirical findings vs the draft

  • The cap-1 handshake held exactly as §4.2 predicted. The per-drain dispatch-or-queue + scopeReArm serialized N concurrent drains onto the runner's single-slot park with no loop stall; -race on the concurrent voting e2e is clean.
  • The switch could not half-land — M1+M2 folded. The moment drivesOwnIteration covered parallel and executeStep routed it, fanOutParallelMI / the onScopeOpen branch / parallelInstanceDrained went unreachable, and the unused linter fails on a dead unexported method — so the seam deletion had to land in the switch commit (the same fold SRD-054 took). §7 M2 collapsed to docs.
  • The reused primitives were not counted as changed. Although mi_parallel.go was rewritten wholesale, git diff recognized openParallelInstance / captureParallelOutput / cancelOpenInstances / cancelParallelGroup as moved-identical, so covercheck scored only the genuinely new driver/handler lines — reaching 100% needed 5 focused white-box tests plus re-pointing the two old white-box tests (PublishErrorhandleComplete, OpenScopeErrorhandleFanOut) at the new loci.
  • runMIParallel split for cognitive complexity. The inline barrier loop hit gocognit 32 (> 30); extracting parallelBarrierStep (one delivered drain: bind, eval, finalize or re-arm) brought both functions under the bar and reads cleaner.

§10.3 Backlog

  • SRD-056.B — the Multi-Instance behavior throw (the original trigger for the decorator rework) is now an ordinary off-loop throw at parallelBarrierStep's completion point (and sequential MI's), deterministic and boundary-catchable.
  • ADR-025 v.2 flips Accepted once SRD-056.B lands — the whole §2.12 re-landing complete.

Open questions

None.