Skip to content

SRD-090.B — The waiting instance

Field Value
Status Accepted
Date 2026-08-18
Owner Ruslan Gabitov
Implements ADR-006 §2.9.5 (for an iterated node the subscriber is the decorator; one subscription per definition, fanned out by trigger nature), §2.9.2 (multiplicity by kind), §2.9.3 (iteration-granularity Message correlation); ADR-025 §2.13a (decorator transparency — the downward half), §2.13b.1 (the arm and the announce move into the unit, routing does not) — closes #313
Upstream ADR-007 §2.4 (per-wait releasability, now per waiting instance), ADR-017 (the single-writer loop that routes a delivery), ADR-023 §2.4
Related SRD-090.A (the node execution model this builds on — its FR-10 refusal is what retires here), SRD-090.C (#339 — the token and incident surfaces), SRD-090.D (#340 — the runtime attributes and result strategies), SRD-085 (iteration correlation, whose declaration this makes load-bearing), SRD-027 (the wait/deliver protocol), SRD-071 (residency)

This slice makes an iterated waiting activity buildable: a Multi-Instance or Standard Loop over a ReceiveTask, a User Task, an external-worker Service Task or an event catch. SRD-090.A built the model that makes it expressible — one dispatch, a decorator that is a peer of what it decorates, a unit that owns the whole execution sequence including the arm — and deliberately kept the build-time refusal (its FR-10), because changing who executes an instance is not the same as changing who owns its wait. This slice changes the second.

It is not behaviour-preserving: it enables a construct that is refused today. That is the whole point, and it is why the refusal and its test are deleted by the same commit that lands the ownership — see §4.

§1 Background (verified at 9c38d7c6)

  • The refusal, and exactly what it says. checkIteratedWaitingLeaf (internal/instance/snapshot/snapshot.go:350) rejects at registration any node that both iterates and parksOnExecution, with the message "activity %q both iterates and waits … its passes after the first would run without waiting (#313). Model it as an iterated Sub-Process containing the wait." parksOnExecution (snapshot.go:388) is the taxonomy: a interactor.HumanTask, a tasks.ExternalWorker with a topic, or an flow.EventNode carrying any non-Conditional definition. Pinned by TestIteratedWaitingLeafRefused (snapshot_test.go:481).

  • A wait is armed on ARRIVAL, which is the defect. checkNodeType (track.go:531) runs when a track reaches a node; it emits evWaiting and calls armWaiters (track.go:672). An in-place iteration re-executes the same node on the same track without re-arriving, so passes after the first arm nothing.

  • The registered processor is chosen by trigger, not by the activity. armWaiters registers the track for every trigger except Message, which registers the Instance ("it owns correlation"), and Conditional, which registers nothing — its subscription is loop-owned (SRD-048 FR-7).

  • A parallel MI host is skipped outright. checkNodeType:566 returns early for a non-sequential Multi-Instance: "registering the host would evaluate iteration correlation at the HOST scope, where no split item exists … SRD-090.B makes the decorator the registered processor and retires that refusal." This is the one live FR-11 violation SRD-090.A's §9 records as belonging here.

  • The hub identifies a processor by VALUE, and dedupes. A message waiter holds processors []eventproc.EventProcessor and joins with slices.IndexFunc(mw.processors, sameProcessor(ep)) (waiters/message.go:236) — IndexFunc rather than Index because comparing an uncomparable dynamic type panics (SRD-090.A M2d). A second registration by the same processor therefore adds nothing.

  • Unregistration names a (processor, definition) pair. EventHub.UnregisterEvent(ep, eDefID) (eventhub.go:629); track.unregisterEvent (track.go:1817) calls it per definition, skipping Conditional.

  • A delivery carries the payload but no discriminator. EventProcessor.ProcessEvent(ctx, eDef) (pkg/eventproc/eventproc.go:15) receives the fired definition and nothing else. The payload IS reachable from it — t.receivedItem = msgflow.CaptureItem(eDef) (track.go:1927) — so a processor holding several waits can re-derive which is meant, but the hub does not tell it.

  • The model can already distinguish concurrent iterations. activities.WithIterationCorrelation(keyName, expr) (receive_task_options.go:65) declares "how a concurrently-waiting iteration of this ReceiveTask (a parallel leaf Multi-Instance) is addressed by an arriving message"; track.iterationKey (track.go:645) evaluates it over the execution's own scope, "where the iteration's split item is bound".

  • The executor already reports that it awaits an event. awaitEvent exists in awaitKind (activity_exec.go:30) and nodeExec.awaits() (activity_exec.go:1042) returns it from the track's state. Residency reads the executor since SRD-090.A M3d, and awaitEvent is the one kind that contributes to residency.

§2 Requirements

  • FR-1 — arrival resolves the node to its EXECUTOR, and the executor is the subscriber. A token arriving at a node resolves it once — a plain node to its own executor, an activity carrying loop characteristics to its decorator — and everything afterwards addresses that object: classification, arming, execution, unregistration. The EventProcessor registered with the hub is therefore the decorator for an iterated activity, and the node's own executor for a plain one (ADR-006 §2.9.5, and its Scope note for the plain case).

This is the arming chain catching up with the execution chain. SRD-090.A FR-2 collapsed execution to one dispatch — execFor(t, step).run(ctx), with no driver branching on whether the node iterates — while arming still asks the raw NODE (checkNodeType). Asking the executor instead is what closes FR-11's downward half, and it makes this slice a removal rather than an addition: no new callback from an instance up into its decorator, because the decorator is already what the track holds.

  • FR-2 — one subscription per definition, alive while any instance awaits it. The decorator registers a definition when its first instance waits on it and unregisters when none does. It does not register per pass, and it does not hold a subscription across passes during which no instance waits.

The subscription belongs to the ACTIVITY rather than to an arrival, and that is what removes the §1 defect instead of compensating for it: arming is keyed to a token moving onto a node (track.go:1771, and its own comment says so), and an in-place iteration never moves again — so there was never a per-pass arm to lose. The decorator is what arrival resolved to, so it outlives every pass by construction.

The park moves into the unit, for every node. Owning the subscription is necessary and not sufficient: parking is a separate act, and it happens in the run loop's pre-step gate (if t.inState(TrackWaitForEvent), track.go:1264) — once per STEP, above executeStep. Every pass of an iterated activity runs below that gate, so after the first delivery the track is Ready and the remaining passes execute their node without waiting at all. That is #313's defect surviving the ownership change, and T-2 catches it.

So the unit parks: before executing its node it classifies, arms and waits for its own delivery. For a plain activity this is the same act in a new place — one instance, one park, identical behaviour — and the run loop's gate goes with it, leaving nothing keyed to arrival. For an iterated one each pass parks as itself, which is what makes the set's membership true rather than aspirational.

Arm on park, withdraw on delivery, per pass. The withdrawal deliver already performs becomes "withdraw MY ordinal" (FR-2's stopped), and the hub unregistration follows only when the set empties. A sequential iteration therefore registers and unregisters once per pass — cheap, and correct at every instant, since between passes nothing is in fact waiting. A parallel one arms all N before any delivers, so its set never empties mid-flight.

The engine-level HOLD follows the same rule. A subscription keeps a resident instance reachable; a hold (exec.WaitHolders) keeps a RELEASED one wakeable, and the two answer the same question — what makes this activity's wait survivable. So the decorator takes one hold when its first instance waits and releases it when the last stops, exactly as it does for the subscription.

This matters because a hold is keyed (instanceID, trackID) with no room for an ordinal, and ReleaseWaits withdraws every hold taken for a track — which deliver calls on each delivery. With N instances of a parallel activity holding against one host track, the first delivery would withdraw its siblings' holds and leave them waiting with nothing able to wake a released instance: the sibling-teardown failure ADR-006 §2.9.5 names, one layer below the hub, where the subscription bookkeeping does not reach. Under one hold per activity there is nothing to withdraw by accident.

Two alternatives were rejected. Keying holds by the decorator's identity breaks the wake: PendingTrigger.TrackID names "the dehydrated track being woken (its recorded node is the wait node the continuation fork re-enters)", and a decorator identity resolves to no track. Adding a discriminator to ReleaseWaits widens a published interface every adapter implements, and re-introduces ordinals in a driver — the shape §4 rejects for the hub's own registry, which cannot be wrong there and right here.

One question stays open and belongs to M4: whether a parallel Multi-Instance over a CORRELATED Message needs a hold per key rather than one per activity, since the wake carries one trigger and the decorator routes it.

The executor is therefore built ONCE, at arrival, and reused. Today execFor runs inside executeStep, so a decorator is constructed at execution time; resolving at arrival and constructing again at execution would produce two objects with the same identity, and the waiting set would live on the one that is not dispatching. The handle that retains it already exists — track.exec, added by SRD-090.A M3d so residency could ask the executor what it awaits — and its lifetime widens from "while run is in flight" to "from arrival until the activity exits".

  • FR-3 — dispatch is by the trigger's nature, and only one nature is reachable. On a delivery the decorator routes to the instances waiting on that definition. ADR-006 §2.9.2 states the multiplicity per kind: a Signal serves every waiter, a Message exactly one — the correlated one (§2.9.3).

For an iterated activity only the Message case can occur, and that is a fact about the model rather than about this engine. ReceiveTask is the only iterable activity that is a flow.EventNode (receive_task.go:182), and its single definition is a Message; a UserTask and an external-worker ServiceTask park through capabilities and never reach armWaiters; an event catch cannot iterate at all, because loopCharacteristics belongs to an Activity — in BPMN as much as here. So the fan-out-to-every-instance half has no expressible case, and is left decided in the ADR rather than written here as code nothing can exercise.

So: exactly one instance receives an occurrence — the one whose iteration correlation matches (§2.9.3), or, when the instances declare no correlation, the first still waiting in ascending ordinal order.

Ordinal order is normative, not incidental: when nothing distinguishes the instances, nothing but their ordinals can decide, and two runs of one model must not disagree.

  • FR-4 — the refusal narrows to what cannot work, and no further. checkIteratedWaitingLeaf is deleted. Two shapes stay refused, both PARALLEL, because both need per-instance identity the engine does not yet hold:

  • a Multi-Instance over a Message catch declaring no iteration correlation — a Message is point-to-point, so N instances waiting at once need something to say which envelope is whose;

  • a Multi-Instance over work that parks outside the event system — a User Task or an external-worker Service Task. These park through a capability, and the identity addressing the parked work (track.taskID) is one slot on the host track, so N instances announce ONE task between them.

The second was found by measurement, not review, and it was reachable between M3 and M5c: a three-item parallel Multi-Instance over a User Task announced a single task and ran to completion with nobody completing it. Three approvals modeled, none performed — the silent wrong answer the blanket refusal existed to prevent. M3 deleted that refusal on the strength of the Message analysis and did not check this shape.

Everything else builds and runs: sequential Multi-Instance and Standard Loop over any wait, and a parallel Multi-Instance over a correlated Message (once M5c completes).

A sequential iteration is unaffected throughout, and for one reason: only one instance parks at a time, so every identity the engine holds one of is sufficient.

  • FR-5 — a restart re-arms before any delivery is accepted. A restored iterated activity whose instances were waiting re-registers its one subscription during rebuild, before the loop accepts deliveries. With one subscription this is satisfied by construction rather than by ordering care across N (ADR-006 §2.9.5).

  • FR-6 — the parallel-MI skip retires, and FR-11's downward half closes. checkNodeType's early return for a non-sequential Multi-Instance (track.go:566) goes: the decorator registers, so there is no host-scope correlation evaluation to avoid. After this slice the iteration vocabulary appears in no driver at all — the event chain converging is what SRD-090.A §8 records as belonging here.

  • FR-7 — residency is per waiting instance. An instance awaiting an event holds a subscription and contributes to residency, exactly as a plain waiting node does; an activity whose instances all await releasable waits is releasable, and one unholdable instance keeps its instance resident (ADR-007 §2.4, at iteration granularity — the conjunction SRD-090.A FR-8 states, now over awaitEvent rather than awaitScope).

  • NFR-1 — nothing observable moves for a node that is not iterated. Its registration, its delivery, its facts and its timings are byte-identical. The existing suites are the oracle for that half; a test needing an edit is a finding.

  • NFR-2 — race-clean; diff-coverage ≥95% (aim 100%).

§3 Models

// internal/instance — the decorator's subscription set.
//
// One entry per DEFINITION the activity waits on, not per instance: the hub
// sees one processor holding one subscription, exactly as it sees a plain
// node (ADR-006 §2.9.5). The waiting set is what makes it dispatchable.
type iterSubscription struct {
    def flow.EventDefinition
    // waiting is the ordinals currently parked on this definition, ascending.
    // Empty means the subscription is due to be unregistered — FR-2's
    // lifetime is "while any instance awaits", not "while the activity runs".
    waiting []int
}

The decorator gains ProcessEvent and an identity, which is all eventproc.EventProcessor requires:

func (d *iterDecorator) ID() string   // the ACTIVITY's identity, stable across passes
func (d *iterDecorator) ProcessEvent(ctx context.Context, eDef flow.EventDefinition) error

ID() is the activity's, not an instance's: it is the identity the hub dedupes on, and it must be the same object across every pass or FR-2's single subscription becomes N by accident.

§4 Analysis & decisions

  • Why one subscription rather than N with a discriminator. The alternative — a decorator holding N subscriptions on one definition, with an ordinal discriminator in the hub's entry identity to tell them apart — is what ADR-006 §2.9.5 first reached for, and it is decided against there. A discriminator puts ordinals — iteration vocabulary — into registration, matching and unregistration, which is iteration knowledge in a driver: the precise thing ADR-025 §2.13a removes everywhere else, and which SRD-090.A spent two milestones removing from the execution and scope chains. One subscription keeps the hub seeing what it sees for a plain node.

It is also strictly less code: no hub change at all, where the discriminator reaches registration, the waiter queue, and unregistration.

  • Why arming resolves the executor rather than calling back into the decorator. The alternative — leave checkNodeType classifying the node, and have an instance tell its decorator "I am parking on definition D as ordinal N" — works, and was this document's first shape. It is rejected because it adds a second mechanism to a chain that already has one: the track would drive executors for execution and nodes for arming, and the callback exists only to reconcile the two. Resolving once at arrival leaves ONE object the track addresses for everything, which is what SRD-090.A did for execution and what ADR-025 §2.13a asks for generally. The per-pass re-arm question then has no place to be asked, rather than being answered.

  • Why the broadcast rule is not implemented. FR-3 states the multiplicity per kind because ADR-006 §2.9.2 does, and then implements only the Message half. That is deliberate: no model can express an iterated activity whose wait is a Signal, a Timer or a Conditional, so the fan-out-to-every-instance branch would be code no test could fail against. Untested code that looks correct is worse than absent code that is specified, because the next reader trusts it.

This was found by writing the test for it (T-5) and discovering the model would not build: activities.WithLoop is an ActivityOption, and an IntermediateCatchEvent is not an activity. /review-srd did not catch it because it verifies claims against the CODE, and this claim was false about the model layer's shape rather than about any symbol's existence — a gap worth remembering in that skill's own terms.

  • Why the hold is one per activity rather than one per instance. The same reasoning as the subscription, and reached by elimination. A hold is keyed (instanceID, trackID) (pkg/exec/waitholders.go), so an instance-granular hold needs either a different key or a wider contract. Keying by the decorator breaks the wake — it resolves a TrackID to a track and re-enters its recorded node. Widening ReleaseWaits changes a published interface and puts ordinals back into a driver. One hold per activity needs neither, and makes the collision impossible rather than managed: ReleaseWaits withdraws every hold for a track, and there is only one.

It costs one thing, stated rather than discovered: the wake carries a single trigger for the whole activity, so which instance receives it is the decorator's decision at wake time, not the holder's at arm time. That is the same routing question FR-3 answers for a live delivery, which is the argument that it should not be answered twice.

  • Why the fan-out is typed by trigger rather than uniform. The multiplicity per kind is already contract: ADR-006 §2.9.2 states that an occurrence serves every waiter for a Signal and exactly one — the correlated one — for a Message. Only the Signal half is the standard's: docs/bpmn-spec/semantics/event-handling.md §10.5.1 lists Message and Signal together under Publication and says signals are "broadcast within and across Pools, Processes, and diagrams", while for Messages it says only that correlation identifies which Process instance the message reaches (§8.4.2). One-message-one-execution is therefore gobpm's rule, decided in §2.9.2, not a BPMN mandate — §2.9.2 says so itself. This slice applies the existing rule at iteration granularity rather than inventing one.

Delivering one message to N instances would let three instances consume one envelope and all complete — a silent wrong answer of exactly the class the current refusal exists to prevent.

  • Why parallel-MI-over-Message needs a declared key rather than a default. "First in ordinal order" is a defensible rule for a sequential iteration: exactly one instance waits at a time, so each pass consumes one envelope and the order is the iteration's own. With N waiting at once it is arbitrary — the model has N receivers and no statement of which envelope belongs to which. The declaration already exists (WithIterationCorrelation), it was built for this case, and requiring it converts an arbitrary answer into a modelled one.

  • Why the refusal and the ownership land in one commit. Removing the guard first ships the silent wrong answer it exists to prevent. Landing the ownership first is unverifiable — no test can build the model while the guard stands. So the guard, its test, and the ownership move together, and the acceptance test goes from "refused at registration" to green in one step.

  • Rejected: each instance registers as itself. Distinct executor objects would keep the hub's (subscriber, definition) pair unambiguous with no changes anywhere. ADR-006 §2.9.5 rejects it and the reason is re-armability: "a set of subscriptions with no owner is a set nobody can re-arm" — a sequential iteration must re-arm every pass and a restart must re-arm the whole set before accepting a delivery, and with each execution registering as itself there is no party responsible for either. It also multiplies the hub's entries by the cardinality for no gain.

  • Rejected: the decorator re-matches every delivery by payload. The decorator could evaluate its instances' correlation keys against an arriving envelope itself, which would work without any hub involvement in matching. It duplicates the matching the hub already performs (§2.9.3), so the two could disagree — and a disagreement about which instance owns a message is exactly the silent cross-iteration bug §2.9.1 designed out for payloads. The decorator routes on what the hub matched, and only falls back to ordinal order where the model declared nothing to match on.

Worked example — #313's acceptance, end to end

The model T-2 builds, the messages it consumes, and what a reader can observe. This is the shape that is refused at registration today.

// A sequential Multi-Instance ReceiveTask over a 2-item collection.
// No iteration correlation is declared, and none is needed: exactly one
// instance waits at a time, so each pass consumes one envelope (FR-3).
mi, _ := activities.NewMultiInstance(
    activities.WithSequential(),
    activities.WithInputCollection("orders", "order"))

recv, _ := activities.NewReceiveTask("await-approval", msg,
    activities.WithLoop(mi))

What happens, pass by pass — the sequence FR-2 and FR-3 produce together:

# Event Who holds the subscription
1 instance 0 executes and parks the DECORATOR registers msg (its first waiting instance)
2 envelope A arrives the hub delivers to the decorator; it routes to instance 0 — the first waiting in ordinal order
3 instance 0 completes, instance 1 parks the decorator's subscription stands: it was never per-pass, so there is nothing to re-arm (FR-2)
4 envelope B arrives routed to instance 1
5 instance 1 completes; none waits the decorator unregisters msg
6 the activity exits one outgoing flow followed once, as for any iterated activity

Today, step 1 never happens: snapshot.New refuses the model, and the observable is the error text quoted in §1. After M3 the observable is that the activity completes after envelope B, not after A — which is the assertion T-2 makes and the sentence #313 was opened to fix.

Run it: go test ./pkg/thresher/ -run TestSequentialMIReceiveTaskConsumesOnePerPass

§5 API deltas

  • activities.WithIterationCorrelation becomes required for a parallel Multi-Instance over a Message catch. Its absence is a build-time refusal naming the option; no signature changes.
  • No new public surface. eventproc.EventProcessor is unchanged — the decorator implements the interface that already exists.

§6 Test scenarios

# Test Verifies
T-1 the existing event, MI and loop suites, unedited (internal/instance, internal/eventproc/eventhub, pkg/thresher) NFR-1: a node that is not iterated registers, waits, receives and unregisters exactly as before. Any test needing an edit is a finding
T-2 a sequential MI ReceiveTask over 2 items receives 2 messages (pkg/thresher) FR-1/FR-2/FR-3 — #313's acceptance: the activity completes after the SECOND message, one per pass, not after the first
T-3 a Standard Loop over an event catch (pkg/thresher) FR-2: the subscription outlives the passes; every pass waits
T-4 kill/restore mid-iteration (pkg/thresher) FR-5: the restored activity re-arms its one subscription and the next envelope reaches the pass that was waiting
T-5 retired — the shape is not expressible. A signal reaching every waiting instance of a parallel MI would need an iterated activity whose wait is a Signal, and no such model can be built (FR-3): loop characteristics belong to an Activity, and the only iterable activity that waits on an event definition is a ReceiveTask, whose definition is a Message
T-6 a correlated parallel MI ReceiveTask (pkg/thresher) FR-3 point-to-point half: three envelopes, three instances, each bound to the one its key names
T-7 a parallel MI over an uncorrelated Message catch is refused (internal/instance/snapshot) FR-4: refused at registration, naming WithIterationCorrelation — and every OTHER iterated waiting shape builds
T-8 an instance completing does not unregister a sibling's wait (internal/instance) FR-2: the subscription survives while any instance awaits, and goes when the last stops
T-9 the fan-out order is the ordinal order (internal/instance) FR-3: deterministic and reproducible, asserted over a set whose arrival order differs from its ordinal order
T-10 residency over waiting instances (internal/instance) FR-7: all-releasable releases; one unholdable instance keeps the instance resident

§7 Milestones

  • M1 — the decorator is an EventProcessor. ID(), ProcessEvent, and the subscription set. Registered by nobody yet, so behaviour is unchanged and T-1 is the oracle. feat(instance): the decorator can be an event processor (SRD-090.B M1)
  • M2 — arrival resolves the executor; the unit arms, parks and waits. checkNodeType stops classifying the raw node and classifies what arrival resolved to; track.exec is populated there rather than in executeStep, and executeStep reuses it. A plain node's path is byte-identical — its executor answers exactly what the node answered. A decorator registers on its first instance's wait and unregisters when the last stops.

The run loop's pre-step park gate moves into the unit with it — one mechanism rather than two doing one job, and the last thing keyed to a token's arrival.

Sequential first: one waiting instance at a time is the case with no dispatch ambiguity, so the routing question is M4's and this milestone is only about ownership and parking. T-2, T-3, T-8. feat(instance): arming asks the executor, not the node (SRD-090.B M2) feat(instance): the unit parks its own pass (SRD-090.B M2) - M3 — the refusal narrows. checkIteratedWaitingLeaf and TestIteratedWaitingLeafRefused are deleted, the parallel-MI skip in checkNodeType retires, and the new narrow refusal lands — all in ONE commit with the ownership that makes the construct correct (§4). T-7, and T-2 flips from refused to green here. feat(instance): an iterated waiting activity is buildable (SRD-090.B M3, closes #313) - M4 — the activity's single hold. Taken when the first instance waits, released when the last stops — the same rule FR-2 states for the subscription, applied to exec.WaitHolders.

Ordered before the fan-out, and that is a change from this document's first sequence. The collision it fixes — ReleaseWaits withdrawing a sibling's hold — is real and currently UNREACHABLE: it needs several instances waiting at once, and the only shapes that can do that are a parallel Multi-Instance over a broadcast trigger or over a correlated Message, neither of which the fan-out has wired yet. Landing the hold first means the parallel case becomes reachable and correct in one step rather than reachable-then-repaired, which is the same reason M3 deleted the refusal in the commit that made the construct work. feat(instance): an iterated activity holds its wait once (SRD-090.B M4) - M5a — the delivery is the instance's. The payload leaves the track's single slot and travels on the unit that received it, staged into that instance's own frame. Behaviour-preserving; it is what lets N wait at once without overwriting each other's item. feat(instance): a delivery's payload belongs to the instance that received it (SRD-090.B M5a) - M5b — each waiting instance has its own delivery box, and the loop routes. One occurrence reaches exactly one instance: the first still waiting in ordinal order. Retires T-5 (see §6) — the broadcast half has no expressible case. T-9. feat(instance): a delivery reaches the instance waiting for it (SRD-090.B M5b)

What was attempted past M5b, and reverted

M5c, M5d and a further milestone (per-instance wait state) were implemented and then reverted. They are recorded here because the analysis is the useful part, and because the next attempt should not rediscover it.

What each did, and how far it got. M5c gave a message subscription its instance ordinal so a correlated envelope could name one; it could not run, because track.iterationKey evaluates the subscription key over the HOST scope while a leaf Multi-Instance binds its split item frame-local to the instance. M5d minted the parked-work identity per instance and routed a task completion to the instance that owned it. The last converted the WAIT itself — a per-instance parked flag, per-instance classification, the ordinal carried on the call — and measurably improved the parallel User Task case from "announces one task and completes with nobody doing the work" to "announces three tasks with three identities and waits for all of them".

Why they were reverted. Three reasons, in order of severity:

  1. A regression. The last milestone broke the SEQUENTIAL iterated User Task, which had worked since M3. A User Task carries no event definition, so armWaiters never opens a delivery box for it — and the completion was routed to that box, dropped, and the second pass never announced. No test caught it because that path had none; writing the test it should have had is what found it.
  2. A race. With N instances genuinely waiting, deliver races: it is track-owned and binds the payload, unregisters the node's definitions and can advance the step onto an Event-Based Gateway's winning arm. Two goroutines inside awaitTrigger under -race.
  3. Untested by construction. The machinery exists for a shape the engine refuses, so the diff-coverage gate fell to 92.4% with no honest way to raise it — the same objection this document makes against implementing FR-3's broadcast half.

What the next attempt needs to know. The blocker is not identity and not the wait flag; both are solved and were straightforward. It is that applying a delivery is track-owned. Until deliver is per-instance — step list, unregistration, arm advancement, hold release — no amount of per-instance state upstream makes a parallel fan-out safe.

And a caution about sizing. This work was estimated three times, at "four mechanical changes", then "the wait state is the last layer", then "two layers further". All three were wrong the same way: each was made after checking the layer in front and not the one behind. The next estimate should come from reading deliver and everything it writes, first.

  • M5c — routing by iteration correlation. Tells concurrently-waiting instances APART, which is what makes a parallel Multi-Instance over a Message buildable rather than refused (FR-4). Needs the correlation key per INSTANCE: track.msgIterKey is one slot and corr.addIterKey is keyed by track id, so N instances of one activity currently overwrite each other's key and an envelope resolves to a track rather than to the instance that declared it. Decides the question M4 leaves open — whether such an activity needs a hold per key. T-6. feat(instance): an envelope reaches the iteration that declared its key (SRD-090.B M5c)
  • M6 — residency and restore. FR-5's restored subscription and FR-7's releasability over waiting instances. T-4, T-10. feat(instance): residency and restore over waiting instances (SRD-090.B M6)

§8 Cross-doc

  • Implements ADR-006 §2.9.5, §2.9.2, §2.9.3; ADR-025 §2.13a's downward half and §2.13b.1.
  • ADR-006 §2.9.5 is edited with this slice, while it is still Draft: the decorator holds one subscription per definition and fans a delivery out, rather than holding N with an ordinal discriminator. §2.9.4's pair identity is untouched, and the amendment it was going to need is not needed.
  • SRD-090.A FR-10 ("the refusal stays … it retires in SRD-090.B, with the registration ownership that makes the construct correct") is discharged by M3. SRD-090.A's §9 records one live FR-11 violation — checkNodeType's parallel-MI skip — as belonging here; FR-6 closes it.
  • SRD-090.C (#339) and SRD-090.D (#340) are unaffected: this slice adds no observable surface and publishes no new iteration value.
  • SRD-085's iteration correlation becomes load-bearing rather than optional for one shape (FR-4). Its declaration and evaluation are unchanged.

§9 Definition of Done

  • [ ] FR-1…FR-7 implemented; every §6 test exists and passes.
  • [ ] #313's acceptance test passes: a sequential Multi-Instance over a ReceiveTask with a 2-item collection receives both messages, one per pass, and completes after the second.
  • [ ] checkIteratedWaitingLeaf, TestIteratedWaitingLeafRefused and checkNodeType's parallel-MI early return are absent from the tree, not merely unreachable — and were deleted in the same commit as the ownership.
  • [ ] FR-11's absence check passes with the event chain included: no iteration vocabulary outside the executor/decorator files, and the one violation SRD-090.A assigned here is gone.
  • [ ] T-1 passes with no test edited. Any edit is reported and justified before the slice lands.
  • [ ] make ci green; diff-coverage ≥95% (aim 100%); suites race-clean.
  • [ ] §10 filled.
  • [ ] #313 closed — by the merge, with the acceptance test named in the PR.

§10 Implementation summary

Landed. An iterated activity that waits is buildable and correct for every shape except the two §4 refuses. The decorator is the activity's single registered event processor: it holds one subscription per definition while any instance awaits it, and routes each delivery to the instance that was waiting.

Milestone Commit
M1 — the decorator is an EventProcessor ea0ce82c
M2 — arming asks the executor, not the node d3990e43
M3 — the refusal narrows (closes #313) e28ec41e
M4 — the activity holds its wait once 5218a0a5
M5a — the payload belongs to the instance that received it 7ad4f1c7
M5b — each waiting instance has its own delivery box f0780a8e
decorator-owned wait coverage 655a3916
the narrow refusal for capability-parked parallel work daac981e
M5c / M5d / M6, and their revert 84fd1639 57f604bc 4e67f489108d17ec

#313's acceptance test is TestSequentialMIReceiveTaskConsumesOnePerPass (pkg/thresher/iterated_wait_test.go): a sequential Multi-Instance over a ReceiveTask with a two-item collection consumes one message per pass and completes after the second. Verified passing at this landing.

What is NOT done, stated plainly because the FR text is broader than the result. FR-4 says the refusal narrows "to what cannot work, and no further". Two shapes are still refused, and only one of them qualifies:

  • a parallel fan-out over an uncorrelated Message genuinely cannot work — a point-to-point envelope with N waiting instances is ambiguous by construction, and declaring WithIterationCorrelation builds it;
  • a parallel fan-out over capability-parked work (a User Task, an external-worker Service Task) is refused for want of a mechanism, not for ambiguity. Its instances would share one parked-work identity. That refusal is a stopgap, and §7's "What was attempted past M5b" records why the three milestones that tried to lift it were reverted.

The design for lifting it landed after this slice, in ADR-025 §2.15/§2.15a/ §2.15b and ADR-020 §2.12 — per-instance identity and eligibility, with the decorator applying completions serially as the node's single execution context. Implementing it is the next slice, and it depends on the RUNTIME addressing seam (#340) that ITERATION_OWNERS needs.

DoD status. Met: #313's acceptance test; checkIteratedWaitingLeaf, TestIteratedWaitingLeafRefused and the parallel-MI early return absent from the tree (grep-verified, deleted in M3 alongside the ownership); make ci green with diff-coverage 98.4% of 382 changed lines; §10 filled.

FR-11's absence sweep is clean, checked against SRD-090.A §9's carve-out list rather than by eye. drivesOwnIteration / multiInstanceOf / standardLoopOf / fansOut appear outside the executor and decorator files in exactly four places, and each is a listed carve-out: adoptRestoredGroups and restoredScopeHost (both schema read paths), scopeLoopCounter (derives the ordinal a scope's facts carry) and recordScopeCompletion (a rule about the node, retiring with #327). Two further hits are comments naming the symbols. The one genuine violation SRD-090.A assigned to this slice — checkNodeType skipping hub registration for a parallel Multi-Instance host — is gone: the function no longer mentions Multi-Instance at all, which is FR-6's downward half closed.

Partial: FR-4, as above — one of the two surviving refusals is a stopgap rather than an impossibility.