SRD-028 — Loop-owned token positions (ADR-017 outbound slice)¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-06-26 |
| Owner | Ruslan Gabitov |
| Implements | ADR-017 v.1 Channel-based event processing §2 Rule 2 (outbound slice) |
This SRD lands the outbound slice of ADR-017: the per-instance loop becomes the sole owner of
the token-position / join view that reachability and joins consult. A track emits every
position move to the loop instead of leaving its position for the loop to read; the loop maintains
its own position and parked-at-join maps and the join machinery reads those maps, never another
track's live currentStep()/state. This removes the loop-reads-track-state cross-goroutine reads
by construction — the residual race class behind the Complex / OR-join transient spurious abort
that SRD-027 §3.8 cured only with a single-snapshot band-aid (which still cross-read track state).
The inbound slice (channel-park delivery — ADR-017 Rule 1) is SRD-027, already landed on this
branch.
1. Background & current state (verified against the code)¶
ADR-001 v.5 makes the per-instance loop the single writer of lifecycle state, and SRD-027 made it the single dispatcher of inbound events. One seam still violates the single-writer model in the read direction: when a reachability/Complex join is rechecked, the loop reads other tracks' mutable position and state cross-goroutine to compute which nodes hold live tokens.
joinPositionsscans every track's live state (the core read).joinPositions(internal/instance/reachability.go:83-105) iteratesinst.tracksand, per track, readst.inState(TrackMerged, TrackEnded, TrackCanceled, TrackFailed)andt.currentStep().node.ID()— the position and liveness of tracks running on other goroutines. It feeds both the occupied-node set for reachability and the in-transit guard.recheckAwaitingJoinsscans for parked joins by reading state. (internal/instance/instance.go:997-1013) iteratesinst.tracks, readst.inState(TrackAwaitSync)andt.currentStep().nodeof every track to find which reachability joins currently hold a parked token.recheckParkedreads the just-parked track's position. (internal/instance/instance.go:1020-1031)node := t.currentStep().node.fireOrJoinreads the survivor's position (logging). (internal/instance/instance.go:1089-1104)survivor.currentStep().node.ID().
All four reads are guarded by t.m (track.go:188, via inState/currentStep), so they are not
data races — but they are cross-goroutine reads of another track's mutable state, which is
exactly what ADR-017 Rule 2 forbids. Guarding them does not make the view consistent: a token
slipping from a branch (reachable) to the join (arrived-pending) between two such reads is what
produced the spurious "activation rule unsatisfiable" abort. SRD-027 §3.8 narrowed that to a single
joinPositions snapshot (fixedFlowChecker, reachability.go:27-37), but the snapshot is still
built by cross-reading every track — the band-aid removed the double read, not the read.
The loop learns lifecycle, never position moves. The eight trackEventKinds
(internal/instance/event.go:67-100) report fork / ended / awaiting / merged / parked / failed /
waiting / deliver — no position move. A single-flow advance appends a step in checkFlows
(track.go:799-810) and emits nothing (only an extra forked flow emits evFork,
track.go:831-833); an Event-Based-gateway arm advance appends in advanceToArm
(track.go:996-1002) and emits nothing. So today the loop has no choice but to read
currentStep() on demand.
The live FlowChecker path is already dead. After SRD-027, recheckJoin builds a
fixedFlowChecker from one joinPositions snapshot and passes that to every j.Recheck(...)
(instance.go:1046-1082). The only FlowChecker.CheckFlows call sites
(pkg/model/gateways/inclusive.go:146, pkg/model/gateways/complex.go:319) receive that fixed
checker. Nothing constructs the live inst.CheckFlows (reachability.go:15-20) →
occupiedNodes (reachability.go:69-73) path anymore; the exec.FlowChecker = (*Instance)(nil)
assertion (reachability.go:143) is the only thing keeping it compiled. It is dead surface this
slice removes (cf. the audit-stale-interfaces house rule).
Track state is already near-local. Post-SRD-027, ProcessEvent only emits (it no longer
calls updateState/record — track.go:898-905, instance.go:375-389), so the "a synchronous
waiter writes t.steps from its own goroutine" concern noted at record (track.go:196) and
checkFlows (track.go:805) no longer applies. After this slice removes the four loop reads
above, the only remaining cross-goroutine touch of t.steps/t.state is the loop finalizing a
quiescent merged track (applyMerged/recheckParked write TrackMerged, then record reads
t.steps, after the track has returned or parked on parkCh — a channel happens-before). t.m is
kept to guard that handoff (§3.6).
ADR-017 (Draft) decided the structural fix. This SRD implements its outbound half.
2. Requirements¶
Functional¶
- FR-1 — The loop owns the token-position view.
loop()maintains a loop-localposition map[trackID]flow.Node(the current node of every live track) and a loop-localparked map[trackID]flow.Node(the join node of every track parked at a reachability/Complex join,TrackAwaitSync). Both are loop-goroutine-only — no lock, likewaiting/msgIdx(SRD-027). The loop never reads another track'scurrentStep()/inState()to build them. - FR-2 — A track emits its position moves. Whenever a track advances onto a new node it emits an
evMovedtrackEventcarrying that node (the track knows it — it just appended the step on its own goroutine). The two move sites arecheckFlows(the ordinary advance,track.go:799-810) andadvanceToArm(the Event-Based-gateway arm advance,track.go:996-1002). The loop setsposition[track] = nodeonevMoved. The initial position is seeded by the loop atspawnbefore the track's goroutine starts (a sequential construction-time read, not a concurrent one — §3.3). - FR-3 — The loop owns the parked-at-join view from
evParked. OnevParkedthe loop recordsparked[track] = ev.node— the join node carried in the event — guarded so it records only a live, non-terminating track (the shutdown and merge-race edge cases — ADR-017 v.1 §“Rule 2 mechanics”). A track leavesparkedwhen it resumes and moves (evMovedclears it), is merged away (evMerged), or ends.recheckAwaitingJoinsiteratesparked, notinst.tracks. - FR-4 —
joinPositionsis a pure function over the loop-owned maps. It derives the occupied-node set and the in-transit flag fromposition/parkedonly — noinst.tracksscan, nocurrentStep(), noinState(). Membership and timing are identical to today's snapshot (§4): occupied = every live track's node; in-transit = a track whose node is the join but which is not inparked. - FR-5 —
recheckParkedandfireOrJoinread the loop-owned position. The parked track's join node comes fromparked[track](orposition[track]); the survivor's node for logging comes fromposition[survivor]. Neither callscurrentStep(). - FR-6 — Liveness comes from lifecycle events, not state reads. A track is removed from
position(andparked) when it dies:evEnded/evFailed(the track), and the absorbed ids onevMerged.evAwaiting(Parallel-joinTrackAwaitingMerge) keeps the track inposition(its token is still Alive at the join) but does not add it toparked(that isTrackAwaitSynconly). This reproduces today'sjoinPositionsdead-filter exactly (reachability.go:89). - FR-7 — The dead live
FlowCheckeris removed. Deleteinst.CheckFlows(reachability.go:15-20),occupiedNodes(reachability.go:69-73), and theexec.FlowChecker = (*Instance)(nil)assertion (reachability.go:143).fixedFlowChecker(built from the loop-owned occupied snapshot) stays the onlyFlowChecker;checkFlowsWith/reachesOccupiedare unchanged.
Non-functional¶
- NFR-1 — No loop-side read of a LIVE track's position/state. After this slice no loop function
reads
currentStep()/inState()of a running track; reachability and joins read the loop-ownedposition/parkedmaps. The loop still finalizes a quiescent merged track viarecord()(the ADR-001 single-writer handoff, §3.6) — that is the established pattern, not a Rule-2 violation. Verified by an audit (grep) recorded in §10 and by-race. - NFR-2 — Reachability / join semantics byte-for-byte unchanged. The OR-join death-trigger
(SRD-022) and the Complex fire/abort (SRD-023) decide identically: the loop-owned occupied/
in-transit view has the same membership and the same observable timing as today's
joinPositionssnapshot (§4 proves the equivalence). All existing gateway tests pass unmodified except those that call the removed/relocated internals directly. - NFR-3 —
t.mretained; the merge-path seam stays guarded.t.steps/t.stateare not purely track-goroutine-local — the loop finalizes a quiescent merged track viarecord()(§3.6) — so the per-track lockt.mis kept (now uncontended, the loop having left the reachability hot path in §3.1–§3.4). Removing it is a deliberate non-goal: it would rest correctness on theemit/parkChhappens-before without structural enforcement. The-racesuite stays clean (T-6). - NFR-4 — Diff coverage ≥ COVER_MIN (95%) on touched functions, aiming 100%.
3. Models¶
3.1 trackEvent — evMoved kind and a node field (internal/instance/event.go)¶
Add one kind and one field:
type trackEvent struct {
track *track
node flow.Node // evMoved: node advanced onto; evParked: the join node
eDef flow.EventDefinition
flows []*flow.SequenceFlow
mergedIDs []string
msgDefIDs []string
kind trackEventKind
}
const (
evFork trackEventKind = iota
// …existing kinds…
evDeliver
// evMoved: the track advanced onto a new node (node carries it). The loop updates its
// own position view; it never reads the track's currentStep to learn the move.
evMoved
)
node is carried in the event precisely so the loop need not read ev.track.currentStep() —
that read is the violation being removed. The loop keeps the flow.Node (not just its id) because
the join machinery type-switches on it (node.(exec.ReachabilityJoin) / exec.ActivationJoin) and
reads node.ID(). Field order keeps govet/fieldalignment happy (interface/pointer fields first).
3.2 Track emits on every move (internal/instance/track.go)¶
checkFlows, right after it appends the next step under t.m (track.go:808-810):
t.m.Lock()
t.steps = append(t.steps, &nextStep)
t.m.Unlock()
// Report the advance to the loop, the sole owner of the position view (ADR-017 Rule 2).
t.instance.emit(trackEvent{kind: evMoved, track: t, node: nextStep.node})
advanceToArm emits the same after its append (the winning arm node). Both run only while the
instance is Active (checkFlows/advanceToArm are reached only from run/deliver), so — unlike
evWaiting — no construction-time gating is needed; emit's <-loopDone arm still bounds the send.
The track does not emit for its initial node (it has no prior position to leave); the loop
seeds that at spawn (§3.3).
3.3 loop() — the position and parked maps (internal/instance/instance.go)¶
Two loop-locals next to waiting/msgIdx, owned by the loop goroutine (no lock):
position := map[string]flow.Node{} // live trackID → current node
parked := map[string]flow.Node{} // trackID → join node, for AwaitSync-parked tracks
spawn seeds the initial position before starting the run goroutine:
spawn := func(t *track) {
inst.tracks[t.ID()] = t
inst.addToSnap(t)
active++
// Seed the initial position on the loop goroutine, BEFORE the run goroutine starts
// (the `go` below). This read is sequential — the track has no other goroutine yet —
// so it is not a Rule-2 cross-read; every later move arrives as evMoved.
position[t.ID()] = t.currentStep().node
if t.inState(TrackWaitForEvent) { … } // unchanged (SRD-027)
go func(t *track) { … }(t)
}
applyEvent threads position/parked like waiting/msgIdx and updates them:
| event | position | parked |
|---|---|---|
evMoved |
position[t] = ev.node |
delete(parked, t) (moving ⟹ not parked) |
evParked |
— | parked[t] = ev.node — iff not stopping and t still in position (ADR-017 v.1 §“Rule 2 mechanics”) |
evAwaiting |
keep (alive at join) | — (AwaitingMerge ≠ AwaitSync) |
evMerged |
delete absorbed ids |
delete absorbed ids |
evEnded / evFailed |
delete(position, t) |
delete(parked, t) |
evParked carries the join node in the event (ev.node), so the recorded park never depends on
position timing; the two guards on it (shutdown, and the merge-race where the completing arrival's
evMerged clears t before its own evParked is applied) are detailed in ADR-017 v.1 §“Rule 2 mechanics”.
stopAll clears both (like waiting/msgIdx). The recheck helpers (recheckAwaitingJoins,
recheckParked, recheckJoin, fireOrJoin) take position/parked as parameters — the same
loop-local-threading pattern applyEvent/dispatchToParked already use for waiting/msgIdx.
3.4 joinPositions — pure over the maps (internal/instance/reachability.go)¶
// joinPositions derives the occupied-node set and the imminent-arrival flag for a join recheck
// from the loop-owned position/parked maps — no track is read. occupied = every live track's node;
// inTransit = a live token already on joinNode but not yet parked there (between its evMoved onto
// the join and its evParked). Identical membership/timing to the old cross-read snapshot.
func joinPositions(
joinNode flow.Node,
position, parked map[string]flow.Node,
) (occupied map[string]bool, inTransit bool) {
occupied = make(map[string]bool, len(position))
for id, n := range position {
occupied[n.ID()] = true
if joinNode != nil && n.ID() == joinNode.ID() {
if _, isParked := parked[id]; !isParked {
inTransit = true
}
}
}
return occupied, inTransit
}
recheckJoin calls joinPositions(node, position, parked) and builds fixedFlowChecker{occupied}
as today (instance.go:1046-1051); the in-transit defer is unchanged.
3.5 Remove the dead live FlowChecker (internal/instance/reachability.go)¶
Delete inst.CheckFlows and occupiedNodes (no caller after SRD-027 — §1) and the
exec.FlowChecker = (*Instance)(nil) assertion. fixedFlowChecker, checkFlowsWith, and
reachesOccupied stay. The exec.FlowChecker interface (pkg/exec/exec.go:40) is unchanged —
fixedFlowChecker still implements it.
3.6 t.m is retained — the merge-path finalizes a quiescent track (internal/instance/track.go)¶
After §3.1–§3.5 the loop no longer reads a live track's steps/state: the reachability and
join machinery read the loop-owned position/parked maps (§3.4). That fulfils ADR-017 Rule 2's
actual requirement — no cross-goroutine read of a running track's position/state.
t.steps/t.state are not, however, purely track-goroutine-local. The loop still finalizes a
quiescent merged track: applyMerged / recheckParked call updateState(TrackMerged) →
record(), which reads steps[last] on the loop goroutine. That track's own goroutine has
already returned (AwaitingMerge) or is suspended on parkCh (AwaitSync), so the read is ordered
after the track's last write by the emit / parkCh handoff — the ADR-001 single-writer pattern
applied to a quiescent track, not a concurrent read.
Because of that merge-path access, the per-track lock t.m is retained, not removed:
- It is now uncontended (the loop took it out of the reachability hot path in §3.1–§3.4) and guards the one remaining seam uniformly across both goroutines, so keeping it is nearly free.
trackis an unexported entity of theinstancepackage — the loop and the track are two goroutines cooperating inside one internal abstraction, not a public boundary that must promise goroutine isolation. A guarded quiescent handoff between them is a legitimate intra-package design, not a leaked invariant.- Full lock removal would make correctness rest on the
emit/parkChhappens-before reasoning without structural enforcement — a deliberate non-goal (it trades a free, safe guard for subtle-race exposure on any future change to the merge/resume paths).
The stale doc comments at record and deliver — which justified the guard by the removed
occupiedNodes and by path() (which actually reads the lock-free hist, never t.steps) — are
corrected to name the real merge-path reader.
4. Analysis¶
Why emit-on-move (chosen). Reachability needs the current node of every live token, including
in-flight (not-parked) ones — an actively-moving upstream token is a potential arrival the join must
wait for. Most moves emit no event today (§1), so for the loop to own the occupied view it must learn
each move; ADR-017 §3 already accepts this ("the track's ordinary post-delivery emit reporting its
advance back to the loop … that outbound notify is unavoidable in any design"). Token-gathering has
the same need — the instance's live tokens are the per-track positions — so the loop-owned view is
the natural home for both (a forward synergy; this slice does not re-point GetTokens, which keeps
reading the lock-free hist projection — §5).
Equivalence to the old snapshot (NFR-2). Today joinPositions includes a track iff it is not in
{Merged, Ended, Canceled, Failed} and reads its currentStep(). The loop-owned position map
holds a track from its spawn seed until an evEnded/evFailed/evMerged-absorbed removes it —
i.e. exactly the not-dead set (Canceled only happens under stopAll, which clears the maps). Each
move updates the node via evMoved, and inst.events is FIFO, so the loop's view of a track's node
is its real node as of the last move it has drained — the same value currentStep() would return at
the moment the loop processes the triggering lifecycle event. The in-transit window (node = join,
not yet parked) is the interval between the track's evMoved onto the join and its evParked,
which the loop sees in that order — the same window !inState(TrackAwaitSync) detected. Membership
and timing therefore match.
Alternatives considered.
- A — Emit-on-move, loop-owned
position/parkedmaps (chosen). Each advance emitsevMoved; the loop derives occupied/in-transit from its own maps. Removes all four cross-reads and the dead liveFlowChecker. Cost: oneemitper node step (cheap — the loop only updates a map; node execution stays on the track goroutine). - B — Carry the position only in the existing lifecycle events. Rejected: most advances emit no lifecycle event, so the loop would miss in-flight tokens between joins — precisely the tokens reachability must see. It cannot reconstruct the occupied set from lifecycle events alone.
- C — Maintain positions only when the process graph has a reachability/Complex join. A real optimisation (a parallel-only or join-free process never consults occupancy), but speculative — it conditions a hot path on a graph property to save a map write. Deferred behind a forward note; add only on measured cost (cf. the no-speculative-universality house rule).
5. Public API surface¶
None. position/parked are loop-locals; the evMoved/evParked node field is
package-internal. GetTokens / TokenHistory (instance.go:1126-1158) are unchanged — they
derive from the lock-free atomic hist projection, which is what makes them safe to call from an
external observer goroutine (SRD-018). They are deliberately not re-pointed at the loop-owned
position map, for two reasons:
- Safety.
positionis loop-goroutine-only (no lock), so an external read would be a data race. Exposing it would require the loop to publish an atomic position snapshot — redundant withhist. - They legitimately differ.
hist's last entry is recorded when a node starts executing (record(TrackExecutingStep)inprepareNodeExecution), so it lagsposition(set atcheckFlows/evMoved) by up to one step during a move. Reachability needs the more-currentposition— an in-flight token must count at its new node — whereas observation is correctly served by the recordedhist(an external reader sees a valid, eventually-consistent snapshot).
Unifying the two (the loop publishing an atomic position view that GetTokens reads) is a possible
future change but is out of scope here and of questionable benefit.
6. Test scenarios¶
- T-1 —
evMovedupdates the loop position. Drive a track through two nodes; assert the loop'sposition[track]follows eachevMovedand that nocurrentStep()is read by the loop (the helper observes only the maps). - T-2 —
joinPositionsis pure over the maps. Table test on the free function: givenposition/parkedmaps (no*Instance, no tracks), assertoccupiedandinTransitfor: token on the join + not parked → in-transit; token on the join + parked → not in-transit; token elsewhere → occupied, not in-transit; empty maps → empty/false. ReplacesTestJoinPositionsInTransit(reachability_loop_test.go:11-30), which built the state by mutating real tracks. - T-3 — OR-join fires identically (regression). The SRD-022 diamond: assert fire timing and survivor/merged outcome are unchanged with the loop-owned view.
- T-4 —
recheckAwaitingJoinsiteratesparked. With two tracks parked at one OR-join recorded inparked, a token death triggers exactly onerecheckJoinfor that node — noinst.tracksscan, noinStateread. - T-5 — Complex fire/abort unchanged (regression). SRD-023 scenarios
(
TestComplexRequiredGate,TestComplexAbortOnDeath,TestComplexAbortInstance): fire on satisfied rule, abort + deterministic terminate on unsatisfiable, under the new view. - T-6 —
-racestress, no cross-read.pkg/thresherunder-race×40 stays green (the SRD-027 baseline), confirming the removed reads introduced no regression and thet.mreduction is clean. - T-7 — Dead
FlowCheckerremoved. Compile-time:inst.CheckFlows/occupiedNodes/the(*Instance)assertion are gone;fixedFlowCheckerremains the soleexec.FlowChecker. - T-8 —
evParkedshutdown guard.applyEvent(evParked)withstopping = truerecords nothing (parkedstays empty) and does not panic (ADR-017 v.1 §“Rule 2 mechanics”, shutdown). - T-9 —
evParkedmerge-race guard.applyEvent(evParked)for a track absent fromposition(already merged by the completing arrival) drops the park —parkedstays empty (ADR-017 v.1 §“Rule 2 mechanics”, merge race). Found by the T-6-racestress (TestORJoinAllBranchesArrivenil-panicked before the guard).
8. Cross-doc¶
- Implements ADR-017 v.1 §2 Rule 2 (outbound slice), §3 (race eliminated by construction), §7 (slice 2 of 2).
- Reachability machinery is ADR-005 v.4 §2.10
(OR-join) / §2.11 (Complex
ActivationJoin); this slice changes only where the occupied set comes from, not theFlowCheckercontract (pkg/exec/exec.go). - Relates to SRD-022 (OR-join death-trigger), SRD-023 (Complex gateway), and SRD-027 (the inbound slice) — this slice supersedes SRD-027 §3.8's single-snapshot band-aid by removing the cross-read entirely. (SRD refs carry no version pin — SRD/FIX are single-shot.)
- Hierarchy: SRD → ADR | SAD | SRD only (up/sideways); version pins on ADR/SAD refs only.
9. Definition of Done¶
- [x] FR-1…FR-7 wired;
position/parkedloop-owned;evMovedemitted at both move sites; liveFlowCheckerremoved. - [x] NFR-1 audit: no loop-side
currentStep()/inState()/t.steps/t.stateread of another track remains (grep recorded in §10). - [x] §6 tests added and passing; existing gateway/instance suites green (NFR-2).
- [x]
make cigreen across all modules; diff-coverage ≥ 95% on touched functions (NFR-4), aiming 100% (99.7%, §10 V-5). - [x] Examples build and run (the SRD-027 runtime-smoke discipline).
- [x] §10 filled (files/lines, V-results, milestone SHAs); status flip is the owner's call.
10. Implementation summary¶
Landed on feat/adr-017-eps-rework in two milestones.
Files touched
| File | Change |
|---|---|
internal/instance/event.go |
evMoved kind + String() arm; node flow.Node field on trackEvent (carries the move node for evMoved, the join node for evParked). |
internal/instance/track.go |
checkFlows/advanceToArm emit evMoved after appending the step; synchronize/synchronizeActivation emit evParked carrying the join node; corrected record()/deliver() doc comments (quiescent merged-track finalization). |
internal/instance/instance.go |
loop-owned position/parked maps; spawn seeds the initial position; applyEvent threads + updates both (incl. the evParked shutdown + merge-race guards); clearPosition/nodeIDOf helpers; applyMerged/recheckAwaitingJoins/recheckParked/recheckJoin/fireOrJoin read the maps. |
internal/instance/reachability.go |
joinPositions rewritten as a pure free function over the maps; dead live FlowChecker (inst.CheckFlows/occupiedNodes/the (*Instance) assertion) removed; fixedFlowChecker/checkFlowsWith/reachesOccupied kept. |
internal/instance/reachability_loop_test.go, reachability_test.go |
T-1…T-9 (pure joinPositions table, evMoved, parked-iteration, trailing-park, shutdown + merge-race guards, nodeIDOf, checkFlowsWith). |
Milestone commits
| Milestone | SHA | Scope |
|---|---|---|
| M1 — loop-owned positions (emit-on-move) | 333b419 |
evMoved, the two maps, pure joinPositions, dead FlowChecker removed, T-1…T-7. |
M2 — evParked guards (merge race + shutdown) |
b361236 |
join node carried in evParked; the two guards; T-8/T-9. The -race stress (TestORJoinAllBranchesArrive) surfaced the merge-race nil before the guard. |
| M2 — doc alignment | 98a7b76 |
ADR-017 Rule 2 + Rule 2 mechanics subsection; SRD-028 (this doc) to the single-writer reality; t.m retained (Option A). |
Verification results
- NFR-1 grep audit. No loop-goroutine read of a live track's
currentStep()/inState()remains. The residual reaches are (a) the construction-time spawn seed (instance.go:689/696, sequential, before the track'sgo func), and (b)trackEndKind(instance.go:969-979), called at:711inside the track's own goroutine afterrun()returns — a quiesced self-read, not a loop read of a live track. - V-1 (T-1…T-9).
go test -race -run 'TestJoinPositions|TestApplyEvent|TestRecheck|TestNodeIDOf|TestCheckFlowsWith' ./internal/instance/— green. - V-2 (regression). OR-join (SRD-022) + Complex (SRD-023) gateway suites green under
make ci -race. - V-3 (
-racestress, T-6).pkg/thresherunder-race×40 —THR_EXIT=0, no data race. - V-4 (
make ci). Green across all modules (tidy, lint 0 issues, build,-racetests, diff-coverage, govulncheck). - V-5 (diff coverage, NFR-4).
covercheck -min 95 -base origin/master: 99.7% of 346 changed coverable lines — PASS. instance.go / event.go / reachability.go 100%, track.go 98.7% (76/77). - V-6 (examples build + run). All 16
examples/*modules run to exit 0 (go run .per module, ≤40 s each) — CI only builds them; this slice's runtime smoke ran the full set, including the gateway-relevantinclusive-join/complex-gateway/parallel-gateway/gateway-routing/event-based-gateway.
Open questions¶
None.