SRD-054 — Standard Loop¶
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-07-21 |
| Owner | Ruslan Gabitov |
| Implements | ADR-025 v.2 §2.2–§2.3 (the Standard Loop slice), §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-010 v.2 (the execution frame that isolates each leaf iteration), ADR-023 v.2 (the composite child-scope open/drain/close lifecycle), ADR-018 v.1 (a boundary arms once and guards the whole loop), ADR-001 v.6 (the loop owns node execution) |
| Refines | — |
§1 Background¶
BPMN 2.0 §13.3.6 lets an activity carry StandardLoopCharacteristics — a
structured while/until loop that re-runs the inner activity sequentially
while a boolean loopCondition holds. This SRD lands Standard Loop on both a
leaf Task and a composite (Sub-Process / Call Activity).
This is the first slice of the ADR-025 v.2 decorator re-landing (§2.12). The
prior landing drove composite iteration control — resolve the continuation,
re-open the scope — on the per-instance loop goroutine, via a
loop-side compositeIterator seam (firstOpen/afterDrain) called from
onScopeOpen/resumeScopeHost. ADR-025 v.2 §2.12 decided that control belongs on
the activity's own off-loop execution — an iteration decorator — which
requests scope operations from the single-writer loop rather than performing
them. Standard Loop is the simplest composite iteration, so it is the slice that
introduces the decorator engine (the request/response scope protocol); the
sequential-MI, parallel-MI, and behavior slices re-land on it afterward.
The leaf-Task loop is unchanged — it already runs in place on the task's own
runner goroutine (a bounded for around executeNode), which is off the loop
goroutine and needs no protocol. §2.12's scope is composite activities only.
This SRD deletes and reuses the SRD-054 slot: its prior (loop-goroutine-driven composite) content is replaced in place with the decorator design; the model and leaf-Task requirements below are the landed reality, restated so the document is self-sufficient.
ADR-025 §2.2 chose the iteration mechanism by activity kind: a leaf Task iterates in place (fresh frame per pass, ADR-010); a composite iterates by re-opening its child scope per iteration (the ADR-023 open/drain/close lifecycle). §2.12 fixes who drives the composite mechanism — the decorator, off the loop. Both kinds follow the activity's single outgoing flow once, at loop exit, and let a boundary event on the looped activity arm once and guard every iteration.
§2 Requirements¶
Functional — the model (landed, unchanged)¶
- FR-1 —
StandardLoopCharacteristicstype.LoopCharacteristicsis a sealed marker interface;StandardLoopCharacteristicscarriesloopCondition(adata.FormalExpression, bool),testBefore(bool, defaultfalse), and an optionalloopMaximum(*int, nil = unbounded); it embedsfoundation.BaseElement. - FR-2 — construction validates all inputs.
NewStandardLoop(loopCondition, opts…)rejects a nilloopCondition, a non-boolResultType(), and aloopMaximum ≤ 0, at construction, with a self-identifying error. - FR-3 —
Activity.Validatecross-check. An activity carrying both a Standard-Loop and a Multi-Instance characteristic is rejected (ADR-025 §2.1). - FR-3a — an Event Sub-Process rejects iteration. A
triggeredByEventSubProcesscarrying anyLoopCharacteristicsfails validation (an event-instantiated handler has no token-driven activation to iterate).
Functional — leaf-Task execution, in place (landed, unchanged)¶
- FR-4 — in-place re-execution. A leaf activity carrying
StandardLoopCharacteristicsis re-executed once per pass; each pass opens a fresh execution frame, so iterations are isolated with no new construct. - FR-5 —
testBeforesemantics.false(default) → post-tested (do…while);true→ pre-tested (while, zero iterations possible). - FR-6 —
loopMaximumcap. At mostloopMaximumiterations run when set. - FR-7 — single outgoing flow at exit. The activity's outgoing flow is followed once, after the loop terminates.
Functional — composite execution, the off-loop decorator (reworked)¶
- FR-8 — the composite host drives its own iteration off the loop. A looped
composite activity iterates on its own runner goroutine (a new
runCompositeLoop, invoked fromexecuteStepbefore the park path, mirroring howrunStandardLoopintercepts a leaf loop). Per pass the decorator: (a) requests a scope-open and blocks for the loop's acknowledgement; (b) parks for drain — the inner scope drains and the loop deliversscopeDoneon the host'sevtCh(the existing mechanism); (c) evaluates the continuation (loopCounter++,loopMaximum,loopConditionwithtestBefore) off the loop; (d) repeats, or completes and follows the outgoing flow once. The host no longer parks for control between passes — only for each pass's drain. - FR-8a — the request/response scope protocol. The decorator never mutates
loop-owned state (scopes, positions, arming) directly. It sends a
scopeRequeston a new loop-servicedscopeReqchannel and blocks on a per-request buffered reply channel; the loop performs the mutation on its own goroutine and replies. For Standard Loop the only roundtrip isreqOpenScope(open the child scope, seed the inner tracks, arm the scope handlers, reply with the opened path). Scope close stays on the existing drain path (completeScope), so no close-roundtrip is needed and there is no double-close. The protocol clones the existingtaskReq/taskRoundtrip(andcallReq) pattern verbatim. - FR-9 — boundary arms once. A boundary event on the looped composite arms
once and guards every iteration: the host still parks between passes and
emits no
evMoved/evEndeduntil loop exit, soarmBoundariesfires once (on arrival) anddisarmBoundariesonce (at exit) — unchanged from today.
Functional — loopCounter, observability & front door (landed, unchanged)¶
- FR-10 —
loopCounter. A 0-based per-iteration ordinal is published so theloopConditionand the inner activity's expressions read it by name; read-only, engine-maintained, each iteration sees its own value. - FR-11 — observability. An iteration Fact per pass (loop enter / each
iteration with
loopCounter/ loop exit) through the ADR-013 v.2 reporter. - FR-12 — front door.
examples/standard-loop/, the iteration guide,CHANGELOG.md, the conformance tracker row, and the READMEs (EN + RU) reflect the capability (already landed; the decorator rework does not change the user-visible behavior, so these need no user-facing change beyond a CHANGELOG note).
Non-functional¶
- NFR-1 — no new event kinds for the leaf path. The leaf loop stays a bounded
foraroundexecuteNode; notrackEventkind, no loop round-trip. - NFR-2 — reuse the expression mechanism.
loopConditionis evaluated through the existingExpressionEngine().Evaluate+boolpath via a transient frame (evalLoopCond), which already runs off the loop goroutine — the decorator needs no loop coordination to test the condition. - NFR-3 — the single-writer invariant is preserved (ADR-017 v.1). The loop
remains the sole writer of
ls.scopes, the data plane,ls.waiting, positions, and boundary/handler arming. The decorator only requests mutations; those methods stay reachable only from loop-goroutine code. No lock, no shared mutable state. - NFR-4 — deadlock-free by construction. The decorator blocks only on channels
the loop writes (the reply channel,
evtCh), both buffered and both honoringctx.Done()/inst.loopDone; the loop never blocks on the decorator (the reply send is to a cap-1 buffer). The wait graph is a DAG (decorator→loop), never a cycle. - NFR-5 — the existing suites are the safety net. The landed Standard-Loop tests (leaf + composite, unit + thresher e2e + the example smoke) stay green throughout the rework; behavior is unchanged, only the composite execution mechanism moves.
- NFR-6 — coverage. Every file this SRD creates/updates finishes at ≥95%
diff-coverage (aim 100%), delivered with the change;
make cigreen.
§3 Models¶
§3.1 Model type family (pkg/model/activities/loop.go) — landed, unchanged¶
LoopCharacteristics is a sealed marker interface; StandardLoopCharacteristics
(embedding foundation.BaseElement) carries loopCondition / testBefore /
loopMaximum, built by NewStandardLoop(loopCondition, opts…) with the FR-2
guards and the WithTestBefore() / WithLoopMaximum(n) options. Accessors
LoopCondition() / TestBefore() / LoopMaximum() (int, bool) expose them to the
runtime. Activity.Validate carries the loop⊕MI-exclusivity guard (FR-3);
subprocess.go's event-sub validator carries FR-3a. No model change in this
SRD — the decorator rework is runtime-only.
§3.2 Runtime deltas (internal/instance/) — the decorator engine¶
-
scope_decorator.go(new) — the protocol types + the runner.A single request shape (open) is all Standard Loop needs — the scope close stays on the drain path (§4.3), and there is no counter-bind roundtrip: the decorator bindstype scopeRequest struct { host *track node flow.Node reply chan scopeReply } type scopeReply struct { err error scopePath scope.DataPath // opened child path }loopCounteritself off the loop (§4.6). No request-kind enum is needed yet; the sequential/parallel-MI slices add kinds as they need them.(*track).runCompositeLoop(ctx, step, sl) ([]*flow.SequenceFlow, error)— the off-loop await-each driver (FR-8): for each pass it setst.loopCounter = passandbindLoopCounterAt(t.scopePath, pass)(off the loop, like the leaf), pre- tests iftestBefore(reuseevalLoopCond),requestScope{}(one open-roundtrip),awaitScopeDrained(park onevtChforscopeDone), then theloopMaximumcap; on exit it callsexecuteNodeonce so the composite selects its single outgoing flow (SubProcess.Exec→selectOutgoing).requestScopeclonestaskRoundtrip(send oninst.scopeReq, block on the cap-1 reply, select onctx/loopDone);awaitScopeDrainedmirrorsrun()'sevtChpark (honorsctx/channel-close for interrupt/terminate). -
instance.go— the request channel.scopeReq chan scopeRequestadded besidetaskReq/jobReq/callReqand initialized in the constructor. -
loop.go— the loop-side handler. Acase req := <-inst.scopeReq:arm in the loopselect, dispatching tohandleScopeRequest(loop goroutine), whichOpenScopes the child, records thescopeEntry, marks the hostwaiting,seedScopes the inner tracks,armScopeHandlers, and replies with the opened path — the exact single-writer shape ofhandleTaskRequest. (The counter is already bound by the decorator, §4.6.) -
track.go— the interception (checkNodeType). AscopeHostnode that also carriesstandardLoopOf(node) != nildoes not park (return nil) — it drives itself viarunCompositeLoop. Every other composite (plain, Multi- Instance) stillparkScopeHosts for the loop-driven scope. Leaf loops and non-composites are untouched. -
std_loop.go— the executeStep route.executeSteproutes astandardLoopOf(node) != nilnode that is ascopeHosttorunCompositeLoopand a leaf torunStandardLoop(unchanged). The Standard-Loop continuation logic (evalLoopCond,loopMaximum,testBefore) is reused byrunCompositeLoop. LeafrunStandardLoopis untouched. -
scope_runtime.go— the drain delivers to the decorator.resumeScopeHostgains a top guard: for astandardLoopOf(entry.node) != nilcomposite it justdispatchToParked(scopeDone)(noafterDrain— the decorator drives re-entry), before the Multi-InstancecompositeIterator/afterDrainseam.completeScopestill closes the drained scope (FR-8a). The old-seam removal (theonScopeOpenfirstOpenshort-circuit, thestandardLoopIteratorcallbacks — now dead for a Standard-Loop composite) is M2 (kept live here only for sequential MI until it re-lands). A plain (non-looped) composite keeps the currentevScopeOpen→onScopeOpen→resumeScopeHostpath unchanged.
§4 Analysis¶
§4.1 The decorator realizes ADR-025 v.2 §2.12¶
§2.12 prescribes that composite iteration control runs on the activity's own
off-loop execution, requesting scope operations from the single-writer loop.
runCompositeLoop runs on the host's runner goroutine (where every node's Exec
already runs); it drives the loop with ordinary control flow (an await-each for)
and touches loop-owned state only through scopeReq. This is the locus §2.12
dictates — the SRD realizes the ADR, it does not deviate.
§4.2 The protocol clones an existing pattern (FR-8a)¶
The engine already round-trips a runner→loop request and blocks for a
single-writer reply: taskReq/taskRoundtrip (UserTask distribution) and
callReq/callRequest (Call Activity completion). scopeReq is the same shape —
a request channel serviced in the loop select, a per-request cap-1 reply channel,
the caller selecting on ctx.Done()/loopDone. No new synchronization primitive
is invented; the decorator reuses the proven roundtrip.
§4.3 Only reqOpenScope; the scope close stays on the drain path¶
A pass opens a scope, the inner graph runs, the scope drains (inner tracks
decScope → completeScope), and the host resumes. The drain and close already
happen on the loop in completeScope; the decorator learns of the drain via the
existing scopeDone delivery. So the decorator needs no reqCloseScope — adding
one would double-close (the drain path already closed the scope). The only
mutation the decorator must initiate is the open (there is no drain-path
trigger for it), hence a single reqOpenScope roundtrip (plus a reqBindCounter
for the loop-owned counter write). This is the minimal correct protocol.
§4.4 Deadlock-freedom (NFR-4)¶
The decorator waits on: (i) the reply channel — written only by the loop, cap-1
buffered; (ii) evtCh for scopeDone — written only by the loop via
dispatchToParked (the buffered slot guarantees the loop's send never blocks).
Both waits select on ctx.Done() and inst.loopDone, so a terminate/interrupt
unblocks the decorator. The loop, in handleScopeRequest, never blocks on the
decorator (its reply send is non-blocking into the cap-1 buffer). The wait graph
is therefore a DAG rooted at the decorator pointing to the loop — no cycle, no
self-emit-on-the-loop-goroutine (the class of bug §2.12 removes). The loop's own
emit into inst.events remains guarded by <-inst.loopDone.
§4.5 Boundary arms once across iterations (FR-9)¶
A boundary arms on evMoved onto the composite and disarms on evEnded. Because
the looped host parks on evtCh for each pass's drain and stays on the same
step (no evMoved/evEnded mid-loop), armBoundaries fires once on arrival and
disarmBoundaries once when runCompositeLoop returns the outgoing flows at loop
exit — the desired BPMN semantic (a boundary timer spans the whole loop),
unchanged from the prior landing.
§4.6 The continuation test runs off the loop; loopCounter binds at the host scope (NFR-2)¶
evalLoopCond evaluates loopCondition against a transient read-only frame
(openFrameAt + Discard); it performs no loop-owned mutation and already runs on
the runner goroutine. The decorator calls it directly between passes — no protocol
roundtrip for the test. loopMaximum and testBefore are plain arithmetic/branch
on the decorator.
loopCounter must be bound at the host scope, not (only) the child: a
post-tested loop evaluates loopCondition after the pass's child scope has
drained and closed, so a counter bound only in the child would be gone by the test.
The decorator binds it itself, off the loop — runCompositeLoop calls
bindLoopCounterAt(host.scopePath, pass) at the top of each pass, exactly as the
leaf runStandardLoop does. This is a data-plane write (mutex-protected), not a
scope-lifecycle mutation, so it is safe off the loop; and it must be off the loop
because the continuation test reads loopCounter before the scope-open request
(the bind→test→open order, matching runStandardLoop). Only the scope-lifecycle
operations (open / seed / arm) go through the reqOpenScope roundtrip; the counter
bind does not. This is where the design refined during implementation from "the
loop binds on open" to "the decorator binds off-loop," keeping the leaf and
composite loops symmetric.
§4.7 Scope guard — Standard-Loop composite only¶
runCompositeLoop is entered only for a looped composite Standard Loop
(standardLoopOf(node) != nil and the node is a scopeHost). A plain
(non-looped) composite keeps parkScopeHost → onScopeOpen → single resume; the
Multi-Instance seam (mi.go, mi_parallel.go) is untouched by this SRD and
re-lands on the decorator in the sequential/parallel slices. This bounds the blast
radius to the one path the decorator proves.
§6 Test scenarios¶
| Test | Level | Covers |
|---|---|---|
TestScopeRequestRoundtripOpens |
instance | FR-8a — handleScopeRequest(reqOpenScope) opens the scope, seeds tracks, arms handlers, replies with the path (mirrors the handleTaskRequest unit tests) |
TestScopeRequestBindCounter |
instance | FR-8a — reqBindCounter binds loopCounter at the host path on the loop goroutine |
TestScopeRequestUnblocksOnTerminate |
instance | NFR-4 — a pending roundtrip returns on ctx/loopDone cancel, no goroutine leak |
TestLoopedSubProcessReopensPerIteration |
instance | FR-8 — the decorator re-opens the child scope N times (existing test, stays green) |
TestLoopedSubProcessPreTestedZero |
instance | FR-5/FR-8 — a pre-tested false condition runs zero passes (existing, green) |
TestLoopedSubProcessMaximumCaps |
instance | FR-6/FR-8 — loopMaximum caps composite passes (existing, green) |
TestLoopedSubProcessEmitsIterationFacts |
instance | FR-11 — one iteration Fact per pass (existing, green) |
TestLoopedSubProcessBoundarySpansIterations |
instance | FR-9 — a boundary on a looped composite arms once and fires across ≥2 passes |
TestLoopedSubProcessInterruptMidIteration |
instance | NFR-4 — an interrupting boundary / terminate mid-pass unblocks the decorator and tears the inner scope down |
TestStandardLoopSubProcessE2E |
thresher | FR-8/FR-10 end-to-end through the public engine (existing, green) |
TestStandardLoopLeafE2E |
thresher | FR-4–FR-7 leaf loop unchanged (existing, green) |
examples/standard-loop smoke |
example | FR-12 — runs to completion, exits 0 (existing) |
The leaf-path unit tests (TestStandardLoopRunsWhileConditionHolds, …) must be
untouched — the leaf path does not change.
§7 Milestones¶
| # | Scope | Files |
|---|---|---|
| M1 | The protocol + the decorator runner, together (folded — the protocol has no production caller until the runner, so landing it alone leaves the loop-side scopeReq arm uncoverable): the scopeReq channel, scopeRequest/scopeReply types, the scopeReq select arm + handleScopeRequest (loop goroutine); plus runCompositeLoop + requestScope/awaitScopeDrained on track, wiring the interception gated on a looped composite Standard Loop (reusing evalLoopCond/loopMaximum). The existing composite-loop tests + e2e go green on the decorator end-to-end, which exercises the whole protocol. |
scope_decorator.go (new), instance.go, loop.go, track.go, std_loop.go |
| M2 | Remove the loop-side composite-loop seam (onScopeOpen firstOpen short-circuit, resumeScopeHost afterDrain/reopen branch, standardLoopIterator callbacks); confirm the whole suite + examples/standard-loop green; CHANGELOG note; update ADR-025 cross-refs / conformance tracker. |
scope_runtime.go, std_loop.go, composite_iter.go, docs |
Milestone fold note: the original M1 (protocol only) / M2 (runner) split was merged because the protocol plumbing has no production caller until the runner, so an M1-only landing leaves the loop-side
scopeReqarm at 0% coverage. Landing them together lets the existingTestLoopedSubProcess*suite exercise the whole protocol end-to-end. The old-seam removal (now M2) stays separate.
§8 Cross-doc¶
- Implements ADR-025 v.2 §2.2–§2.3 (Standard Loop), §2.12 (the off-loop decorator).
- Upstream ADR-017 v.1 (single-writer loop), ADR-023 v.2 (scope lifecycle), ADR-018 v.1 (boundary arm-once), ADR-010 v.2 (leaf frame), ADR-001 v.6.
- Direction: SRD → ADR only (up), all version-pinned. No downward reference.
§9 Definition of Done¶
- FR-1…FR-12 wired; FR-8/FR-8a via
runCompositeLoop+ thescopeReqprotocol; FR-4–FR-7 leaf path unchanged. - §6 tests exist and pass; the landed composite + leaf + e2e suites stay green
(NFR-5);
examples/standard-loopruns and exits 0. - The loop-side composite-loop seam is removed (M3); a plain composite and the MI seam are unaffected.
- Single-writer invariant preserved (NFR-3): no decorator-side mutation of loop-owned state; deadlock-freedom argued (NFR-4) and exercised by the terminate/interrupt tests.
make cigreen (tidy · lint · build ·-race· diff-coverage ≥95% on touched files · govulncheck); CHANGELOG[Unreleased]notes the internal rework./check-srdPASS before flipping status; ADR-025 v.2 stays Draft until the whole re-landing completes (owner: flip after implementation).
§10 Implementation summary¶
§10.1 Stages by commit (branch feat/loop-mi-decorator-engine)¶
| Stage | Commit | Scope | Tests |
|---|---|---|---|
| M1+M2 (folded) | 13d4de4 |
the scope protocol (scopeReq channel, scopeRoundtrip, handleScopeRequest) + the runner (runCompositeLoop, awaitScopeDrained); checkNodeType carve-out (a looped SL composite no longer parks); executeStep route; resumeScopeHost decorator-bypass |
TestLoopedSubProcess* (integration), scope_decorator_test.go (roundtrip 4 paths / handler / runner-err / await-cancel), thresher TestStandardLoopSubProcessE2E |
| M2 (seam removal) | 7d0d804 |
remove standardLoopIterator + the SL branch of compositeIteratorOf; scopeLoopCounter recognizes the decorator-driven SL; CHANGELOG |
full loop / MI / boundary suites + e2e stay green |
Doc: ADR-025 v.2 33e7913, SRD-054 (this doc) c495579.
§10.2 Empirical findings vs the draft¶
- M1/M2 fold. The draft split M1 (protocol) / M2 (runner); the protocol has
no production caller until the runner, so an M1-only landing left the
loop.goscopeReqarm at 0 % coverage (measured 80.3 % FAIL). Folded into one milestone; the old M3 (seam removal) became M2. - Off-loop counter bind. The draft had the loop bind
loopCounteronreqOpenScope; implementation moved it intorunCompositeLoop(off the loop, like the leafrunStandardLoop) because the pre-test eval must readloopCounterbefore the open request, andbindLoopCounterAtis a plane-mutex-safe data write, not a scope-lifecycle mutation.scopeRequest.nwas dropped. scopeLoopCounterregression caught in M2. Removing the SL branch fromcompositeIteratorOfsilently droppedloopCounterfrom SL iteration facts (scopeLoopCountergated oncompositeIteratorOf);TestLoopedSubProcessEmitsIterationFactscaught it, fixed to also recognizestandardLoopOf.
§10.3 Backlog¶
- One defensive line uncovered (
runCompositeLoop'sawaitScopeDrained-error return — the mid-loop interrupt/terminate path); hard to trigger deterministically without a racy interrupt, accepted at 98.4 % diff-coverage (theawaitScopeDrainedcancel/close branches themselves are covered white-box). - Sequential Multi-Instance still rides the
compositeIterator/afterDrainseam; it re-lands on the decorator in the next slice (SRD-055).
Open questions¶
None.