Skip to content

SRD-090.A — The node execution model and its record

Field Value
Status Draft
Date 2026-08-10
Owner Ruslan Gabitov
Implements ADR-025 §2.13 (node execution — the executor, the decorator, the closed composition), §2.13a (decorator transparency, both directions), §2.13b (a node execution is one unit; capability passthrough; arm/announce move, routing does not), §2.2 (one isolation rule), §2.12 (off-loop iteration, scope widened), §2.9.3a (the instance identity as the scope segment) — the model and record slice; the waiting instance → SRD-090.B, the observable surfaces → SRD-090.C, the declared result strategies and the RUNTIME iteration values incl. ITERATION_ID/ITERATION_MODE (§2.6.1, §2.9.2, §2.9.3) → SRD-090.D; #313
Upstream ADR-017 (the single-writer loop the decorator requests scope operations from), ADR-023 §2.4/§2.7 (the scope lifecycle a sub-process executor drives, the child instance a call executor owns), ADR-033 §2.10 (composite fidelity — the record this slice moves), ADR-007 §2.4 (per-wait releasability, applied at iteration granularity), ADR-010 (the frame that isolates an iteration)
Related SRD-090.B (#313 — the waiting instance: registration ownership, the refusal retired), SRD-090.C (#339 — the token and incident surfaces), SRD-090.D (#340 — array/map/reduce results and the engine-published iteration values, which this slice's last-wins default is the floor for), SRD-086 (leaf MI, whose in-place mechanisms this replaces), SRD-056.A (the parallel decorator and its miGroup barrier this retires)

This slice is behaviour-preserving. It changes what object executes an activity and what the checkpoint records about it; it enables no new construct and changes nothing a host can observe. That constraint is what makes it testable: the existing suites are the oracle, and a test that needs editing is a finding, not a chore.

§1 Background (verified at d0a86371)

  • One dispatch point, five iteration branches. track.executeStep (internal/instance/std_loop.go:47-90) routes a node to runCompositeLoop, runStandardLoop, runMISequential, runMIParallel or runLeafMISequential, falling through to executeNode. Three mechanisms are visible in that one function: a composite re-opens a child scope, a parallel leaf forks tracks, a sequential leaf re-runs in place.
  • A spawned iteration is a track. mi_parallel.go:474 sets nt.leafPlain = true on each spawned leaf track, and scope_runtime.go:479 does the same on restore. leafPlain exists only to stop a spawned track re-entering the iteration branch of executeStep (std_loop.go:84) — a marker whose whole job is to suppress a routing decision that should not have been reachable.
  • The barrier is loop-owned. miGroup (mi_parallel.go:16-21) is "the loop-owned barrier substrate for a parallel Multi-Instance", advanced by per-scope drains the loop delivers.
  • The record is track-shaped. checkpoint.TrackRecord (internal/instance/checkpoint/document.go) carries NodeID, ScopePath, ScopeSeg, LoopCounter and an optional MI *MIRecord (N, Completed, ConditionMet, Staging) — so a parallel iteration persists as a track record and a sequential one as a mirror on its host's. CurrentSchema = 5 (document.go:38).
  • exec.NodeExecutor is taken. pkg/exec/exec.go:18-23 defines it as the interface a node implements to execute itself (Exec(ctx, renv) ([]*flow.SequenceFlow, error)), asserted at track.go:1387. ADR-025's concept "node executor" therefore cannot take that name in code.
  • Isolation differs by kind today. A sequential leaf pass gets a fresh frame (mi_leaf.go:9-19); a parallel leaf gets a per-instance scope (SRD-086 FR-2). ADR-025 §2.2 replaces this with one rule.

§2 Requirements

  • FR-1 — one interface, four realizations. An activityExec interface expresses: run or resume one instance of an activity at a recorded position; report what it awaits and of which kind; report its state in the iteration vocabulary; cancel. Realized by a node executor (a leaf activity), a sub-process executor (opens the child scope; the body's tokens remain ordinary tracks), a call executor (owns the child instance), and the decorator, which holds N of the others and implements the same interface — closing the composition (ADR-025 §2.13).
  • FR-2 — executeStep collapses to one decision. It builds the executor for the node — a decorator when the node carries loop characteristics, a bare executor otherwise — and runs it. The five branches, runLeafMISequential, the parallel-leaf fan-out and the leafPlain marker are removed, not bypassed.
  • FR-3 — a track is a token again. Nothing spawns a track to iterate. The tracks a run creates are the body tokens of a sub-process executor and nothing else.
  • FR-4 — one isolation rule (ADR-025 §2.2): every executor owns its frame; a child scope exists only where the activity is itself a scope host. A leaf activity gets no per-instance scope, sequential or parallel. A frame bounds the execution — inputs, properties, locals — and not its results: what an instance commits goes to the enclosing scope, per §2.6.1's last-wins default. This slice implements that default only; the declared strategies (array, map, explicit reduce) and the RUNTIME iteration values are SRD-090.D.
  • FR-5 — the barrier moves off the loop. The decorator awaits its own executors with ordinary control flow; miGroup and the loop-side per-instance drain accounting for leaves are removed. The loop keeps serving scope requests for the kinds that genuinely open scopes.
  • FR-6 — the record describes instances, not tracks. Schema 5 → 7 (6 at M2, 7 at M3c, when the scope record took the instance identity): an iterated activity's live instances persist as an executor set keyed by ordinal, carrying each instance's state and — for a call executor — the child instance id it owns. Per-iteration TrackRecords and the TrackRecord.MI mirror are retired. Frames are not persisted: the split item is the collection element at the ordinal and the counter is the ordinal, both recomputed (ADR-025 §2.4 fixes cardinality once).
  • FR-7 — restore rebuilds executors at their positions. A completed ordinal never re-runs. A schema-5 document still restores (the compatibility path), producing the same live state it produces today.
  • FR-8 — residency asks what an executor awaits. A sub-process executor awaiting a scope drain and a call executor awaiting a child instance contribute nothing to residency; the body's own tracks decide, as they do today. Releasability over a decorator is the conjunction of its executors' — ADR-007 §2.4's per-wait rule ("per-wait, not per-token") at iteration granularity.
  • FR-9 — the instance↔track protocol is unchanged. No new trackEvent kind, no new loop-owned state, no direct mutation of loop-owned state from a decorator. The loop simply receives no per-iteration track lifecycle events, because none exist.
  • FR-10 — the refusal stays. snapshot.New continues to refuse an activity that both iterates and parks on execution. This slice changes who executes an instance, not who owns its wait; the refusal retires in SRD-090.B, with the registration ownership that makes the construct correct. Removing it here would enable a construct whose waits nothing re-arms.
  • NFR-1 — nothing observable moves, with ONE named exception. Token counts, facts, flows and timings are as before for every construct that works today, and the declared MI output collection is assembled positionally as before (§2.6).

The exception is a parallel leaf's undeclared writes. Today each parallel instance runs in its own scope, so anything it Puts — or commits beyond the declared outputDataItem — dies with that scope. Under FR-4 those writes reach the enclosing scope, last-wins, which for a parallel activity means order-dependent (ADR-025 §2.6.1). This is a real behaviour change and is the deliberate one: it is the default the amended ADR decides, and the alternative — keeping a per-instance scope alive purely to swallow writes — is the construct §2.2 retired.

Two consequences follow, and T-11 pins them. A model that needs every instance's result declares a strategy (SRD-090.D) rather than relying on scope death, and a model that relied on writes vanishing now sees the last one. Both are stated in the CHANGELOG at this slice's landing, because a silent semantic change is worse than a loud one.

Per-iteration scope paths for a parallel leaf also disappear, which is internal: nothing outside the engine addresses them. - FR-11 — the decorator is transparent in both directions (ADR-025 §2.13a). Added after M3b, when planning M3c showed FR-2's "one decision" had never been reached and the reason was general rather than local.

Upward: no driver — track, loop, instance, event hub — branches on whether a node iterates. Concretely: one execution dispatch (execFor), one scope-open path, no drivesOwnIteration probe outside the executors, no loop-side mirror of decorator position, and no flag by which a decorator asks a driver to suppress its own bookkeeping.

Downward: a decorated node's registration call, frame, data resolution, delivery and boundary arming are byte-identical to the undecorated case. The decorator interposes by substitution in a chain that already exists (ADR-025 §2.13a.2), never by a node-kind test.

The one sanctioned channel is data (§2.13a.1): the decorator publishes iteration values as ordinary scope data — which §2.9 requires — and answers no questions. The publication seam is therefore in scope to preserve, never to remove as driver knowledge.

Acceptance is mechanical: iteration vocabulary (drivesOwnIteration, multiInstanceOf, standardLoopOf, fansOut, miState, miSeed, the iteration mirror) must not appear outside the executor/decorator files and the publication seam. §9 carries the absence check. - NFR-2 — race-clean; diff-coverage ≥95% (aim 100%).

§3 Models

// internal/instance — the runtime unit ADR-025 §2.13 calls a node
// executor. NOT exec.NodeExecutor (pkg/exec), which is what a NODE
// implements to execute itself; this is what executes an INSTANCE of an
// activity and owns whatever that instance awaits.
type activityExec interface {
    // run executes this instance, resuming at a recorded position when
    // one was restored. It returns when the instance completes or parks.
    run(ctx context.Context) error

    // awaits reports what this instance is waiting on, which decides both
    // registration (only awaitEvent registers with the hub) and residency
    // (only awaitEvent contributes to it) — FR-8.
    awaits() awaitKind

    // state reports the instance in the iteration vocabulary: its ordinal
    // and what it is doing. The record persists it (FR-6); SRD-090.C
    // projects and records it further.
    state() instanceState

    // cancel abandons this instance — for a fired completion condition, an
    // interrupting boundary, or a terminate cascade.
    cancel(ctx context.Context) error
}

type awaitKind uint8

const (
    awaitNothing awaitKind = iota // executing
    awaitEvent                    // a hub subscription (SRD-090.B arms it)
    awaitScopeDrain               // a sub-process executor's body
    awaitChildInstance            // a call executor's callee
)
// internal/instance/checkpoint — schema 6. The live instances of an
// iterated activity, replacing per-iteration TrackRecords and the
// TrackRecord.MI mirror (FR-6).
type IterationRecord struct {
    Kind      string            `json:"kind"`      // loop | mi_sequential | mi_parallel
    N         int               `json:"n,omitempty"`
    Completed int               `json:"completed"`
    ConditionMet bool           `json:"condition_met,omitempty"`
    Staging   json.RawMessage   `json:"staging,omitempty"`
    Instances []IterationInstance `json:"instances,omitempty"`
}

// IterationInstance — NOT InstanceRecord: repository.InstanceRecord
// (pkg/repository/repository.go:68) is the durable row of a whole process
// instance, and recovery handles both in one call path.
type IterationInstance struct {
    Ordinal int    `json:"ordinal"`
    State   string `json:"state"`              // running | waiting | completed
    ChildID string `json:"child_id,omitempty"` // a call executor's callee
}

Worked trace (parallel leaf, the case that loses its scopes). charge is a ServiceTask under parallel MI over orders=[o1,o2,o3]. executeStep builds a decorator holding three node executors. Each binds order and loopCounter in its own frame at the host's scope path — no /p/sp-charge-N scopes are opened — and runs charge's Exec. The decorator awaits all three, assembles output positionally, and follows the activity's single outgoing flow once. A kill after o2 completes records IterationRecord{Kind: mi_parallel, N: 3, Completed: 1, Instances: [{0, running}, {2, running}]}; restore rebuilds two executors at ordinals 0 and 2, and ordinal 1 never runs again.

Worked trace (iterated Call Activity, the record's subtlest case). settle is a Call Activity under parallel MI over invoices=[i1,i2,i3]. The decorator holds three call executors; each launches a child instance of the called process with its own input bound from its ordinal, and awaits that child's terminal state (ADR-023 §2.7). The caller records IterationRecord{Kind: mi_parallel, N: 3, Completed: 0, Instances: [{0, waiting, ChildID: c-a}, {1, waiting, ChildID: c-b}, {2, waiting, ChildID: c-c}]} — the child id sits on the ordinal that owns it, which is what a recovered caller needs: without it, a completing child cannot be matched to the slot its output belongs in, and positional assembly (ADR-025 §2.6) would bind the right output to the wrong invoice, silently. Recovery claims all three children with the caller (ADR-033 §2.10's transitive claim) and re-links each to its ordinal. A fired completion condition cancels the remaining instances, which for this kind means terminating their child instances — each a durable record of its own.

§4 Analysis & decisions

  • Why all three kinds in one slice. Converting leaves alone would leave composite iterations recording per-iteration TrackRecords while leaves record an executor set — two shapes for one concept, and a second schema move when composites follow. Converting them together moves the record once and deletes miGroup once.
  • Why the record moves with the model, not after it. The alternative — keep writing schema 5 while executing on the new model — means synthesizing TrackRecords for objects that are not tracks, and testing that synthesis. That is scaffolding written to be deleted, and pins a shape we intend to discard.
  • Why behaviour-preserving is the constraint. With no new construct enabled, every existing test is an oracle. This is the cheapest strong test available for a refactor of this size, and it disappears the moment the slice also enables something new — which is why the waiting instance is SRD-090.B.
  • Naming. exec.NodeExecutor is the node-side interface (pkg/exec/exec.go:18); the runtime unit takes activityExec to avoid a collision that would otherwise read as the same concept in two places.
  • leafPlain is deleted, not preserved. It exists to suppress a routing decision (std_loop.go:84); with one decision there is nothing to suppress.

§5 API deltas

None public. activityExec and its realizations are internal/instance; the checkpoint schema is internal to the engine's own persistence. The token view and the incident surface are SRD-090.C.

§6 Test scenarios

# Test Verifies
T-1 the existing MI/loop suites, unedited (internal/instance, pkg/thresher) NFR-1: leaf sequential/parallel, composite sequential/parallel, Standard Loop, completion conditions, cancellation and behavior events all behave as before. Any test needing an edit is a finding
T-2 no track is spawned to iterate (internal/instance) FR-3: a parallel MI over 3 items creates no additional tracks; the only tracks are body tokens
T-3 a parallel leaf opens no scope (internal/instance) FR-4: the scope plane holds no /…/sp-<node>-N path for a leaf MI
T-4 kill/restore at position, all kinds (internal/instance, pkg/thresher) FR-6/FR-7: the executor set records the open ordinals; completed ordinals never re-run
T-5 schema-5 document restores (internal/instance/checkpoint) FR-7: a document written before this slice rebuilds the same live state
T-6 an iterated Sub-Process dehydrates (internal/instance) FR-8: three iterations each holding a parked User Task release the instance — the case a naive "executing keeps it resident" rule would pin forever
T-7 releasability is the conjunction (internal/instance) FR-8: one unholdable wait among N keeps the instance resident
T-8 the loop sees no per-iteration track events (internal/instance) FR-9: the recorded trackEvent stream over a parallel MI names only the host and the body tokens
T-9 an iterated Call Activity, new (pkg/thresher) FR-1/FR-6: no such test exists today (NewCallActivity never appears with WithLoop), so T-1 is not an oracle for it — N children launch, each recorded against its ordinal, a kill/restore re-links each child to the ordinal that owns it, and positional assembly binds each child's output to its own slot
T-10 cancelling an iterated call (pkg/thresher) FR-1: a fired completion condition terminates the remaining instances' child instances, each closing durably — the cost ADR-025 §2.13 names
T-11 a parallel leaf's undeclared write (internal/instance) NFR-1's named exception: a Put from a parallel instance is visible in the enclosing scope after the activity completes, with the last writer's value — the behaviour change stated rather than discovered. A sequential leaf's successive writes accumulate as before (the reduce default), which the same test pins so the two are not confused
T-12 dropped with M2c — a key declared while the waiter is being installed (internal/eventproc/eventhub) M2c, superseded by master's 94b88765; the scenario is master's to test. Originally: with the broker's Subscribe held open, AddEventKey for the same definition lands in the install window — and the finished subscription still carries that key. Deterministic: the window is a released latch, not a sleep
T-13 dropped with M2c — a second processor joins an existing waiter (internal/eventproc/eventhub) M2c, superseded by master's 94b88765. Originally: a processor joining an installed waiter contributes its declared keys to the broker subscription, so a held subscription for a second conversation is reachable
T-14 an uncomparable processor is refused (internal/eventproc/eventhub) M2d: registration rejects an EventProcessor whose dynamic type cannot be compared, naming the type — instead of panicking inside a waiter on the SECOND registration for that definition
T-15 two concurrent hosts on one composite (internal/instance) FR-11/M3c: the case the retired re-entry queue served — a parallel gateway forking two tokens into the same Sub-Process. Both bodies run and both hosts resume, with the instance identity in the segment making the two scopes distinct rather than serializing them. This is M3c's principal regression risk: the queue is deleted, not ported, so the behaviour it protected must be pinned by a test that fails without the identity
T-16 the standard's counter invariant, where it is satisfiable (internal/instance) ADR-025 §2.9. For a parallel MI the sum terminated + completed + active == numberOfInstances holds at every observation point, including after a completionCondition terminates the remainder — pinning by test what the derivation gives by construction, so a future change that tracks the active count separately fails loudly. For a sequential MI it is asserted at TERMINAL states only, which is where the table's cap and its sum stop contradicting each other (§2.9). Extended by M3g: the sequential half fails today, and the first version of this test did not catch it — it exercised bindMICounters directly, the one binder that was already correct
T-17 an instance carrying an open incident does not release (internal/instance) FR-8/M3d: TrackIncident is absent from liveTrackStates, so a host bearing one reaches dehydratableParked's terminal arm and never disqualified the instance — harmless only while the composite host disqualified it through the default arm anyway. Once residency releases a host parked for a drain, an instance with an open incident could release through the dehydration path while parkOnIncidents expects to own the park. The two park paths must not both claim it
T-19 a wait announced after it is armed loses its trigger (internal/instance) M3f/§2.13b.1: the ordering constraint that survives the arm moving into the unit. With the announce suppressed, a fire reaches a loop with nothing to route it to and the delivery is dropped — silently, which is why it is pinned rather than trusted to review. Deterministic: the announce is withheld, not raced
T-20 an iterated activity is ONE step of its token (internal/instance) M3f/§2.13b.1e: three instances, one Executing entry in the token's history — asserted over BOTH kinds in one test, because the property is that they AGREE and asserting them apart is how they drifted. Before M3f a parallel MI suppressed the per-instance transitions through inSet while a sequential one had no such flag and reported N; nothing in the suite noticed
T-21 a Sub-Process host reports hosting a scope (internal/instance) M3f/§2.13b.1e: while the body runs the host reads as HOSTING A SCOPE, not EXECUTING — the correction §2.13 named one level down and fixed only inside the executor. Asserted WHILE the body runs, the only moment the two differ, with an inner task holding the scope open until the assertion is made: a fence, not a sleep

§7 Milestones

The record moves with each conversion, never after it. A milestone that converted execution while capture still wrote TrackRecords would have to synthesize track records for objects that are not tracks — the scaffolding this SRD's own §4 rejects, reproduced one level down. Schema 6 therefore appears in M2, with the first instances that need it, and M3 populates the same structure for the composite kinds.

  • M1 — the interface and the node executor. activityExec, awaitKind, the leaf realization, and executeStep routing a non-iterated node through it. No iterated construct changes, so no record changes: a non-iterated node's TrackRecord is what it is today. T-1 green. feat(instance): activityExec — one unit that executes an activity instance (SRD-090.A M1)
  • M2 — the decorator over node executors, and schema 6. Sequential and parallel leaf iteration on the decorator; runLeafMISequential, the leaf fan-out and leafPlain deleted; IterationRecord introduced and written for leaf iteration; per-iteration TrackRecords and the TrackRecord.MI mirror no longer written for leaves; the schema-5 read path lands with it. T-1, T-2, T-3, T-4 (leaf), T-5. feat(instance): the decorator drives leaf iteration through executors (SRD-090.A M2)
  • M2c — the registration window a correlation key falls into.Dropped — master fixed it first. The milestone landed on this branch (6012af08) and was then superseded by master's 94b88765, which repairs the same window; e87d243d deleted this branch's eventhub_keysync_test.go rather than keep a test file named after a mechanism the tree no longer has. T-12 and T-13 therefore do not exist — the behaviour they described is master's to test. The entry stays because the reason M2c was ever in this slice is still true, and a reader comparing §6 against the tree needs to know why two scenarios are missing.

Not part of this slice's subject and not behaviour-preserving — it is here because T-1 cannot be read until it is fixed: TestIterationCorrelated Routing and TestIterationRoutingKillAndResume fail intermittently on origin/master (measured 1 in 3), so every gate result over M2's oracle is ambiguous. registerWaiter subscribes the broker — reading the processors' declared correlation keys — BEFORE installing the waiter in the registry, and AddEventKey no-ops while it is uninstalled, so a key a sibling iteration declares inside that window reaches neither and its envelope is buffered unrouted forever. The waiter re-reads its processors' keys once the hub has made it reachable, at both points where the reachable processor set changes. T-12, T-13. fix(eventhub): the correlation key declared while a waiter is being installed (SRD-090.A M2c) - M2d — the registration that panics instead of refusing. Found while writing M2c's tests, and fixed where found rather than filed. A waiter identifies its processors by value (slices.Index over the interface), and Go panics rather than reporting false when two interface values of one uncomparable dynamic type meet — so a host implementing the public eventproc.EventProcessor on a struct with a slice or map field crashed the hub on its SECOND registration for a definition, inside the waiter. Registration now refuses such a processor at the boundary, naming the type. Identity by ID() was rejected as the alternative: a snapshot clone preserves element ids, so two instances of one process present distinct processors carrying the same id, and matching on it would let one unregister the other's wait. T-14. fix(eventhub): registration refuses a processor it cannot compare (SRD-090.A M2d) - M3 — the composite kinds, into the same record. Sub-process and call executors; miGroup and the loop-side drain accounting retired; residency by awaits; composite and call instances populate IterationRecord, a call instance carrying its ChildID. T-1, T-4 (composite), T-6, T-7, T-8, T-9, T-10.

It lands in five commits rather than one (four as planned, plus the M3c re-scope FR-11 forced), for the same reason M2 did: the drivers it replaces do not share a mechanism, and a single commit would mix a straight conversion with two behaviour changes and the removal of the loop-owned barrier. The split is by what each part must prove, which is what makes a regression name itself:

  • M3a — the scope executor, and the sequential composite kinds. A conversion, behaviour-preserving, so the existing suites are its whole oracle. scopeExec — one instance of a composite activity: it opens its child scope and parks for that scope's drain. The decorator generalizes over activityExec and the sequential composite kinds drive through it (iterDecorator for a sequential MI, loopDecorator for a Standard Loop); runCompositeLoop and runMISequential go, and a composite's position becomes an IterationRecord — nothing writes TrackRecord.MI any more. T-1 (composite), T-4 (composite). feat(instance): the scope executor, and the sequential composite kinds (SRD-090.A M3a)
  • M3b — the parallel composite, and the loop-owned group retired. Parallel composite MI on the same decorator, each instance holding its own scope and its own drain — which is what removes the reason the group existed: miGroup, the fan-out/re-arm/complete/re-attach handshake and the loop-side drain accounting served a cap-1 park that N instance executors no longer share. T-8. feat(instance): parallel composite instances own their scopes (SRD-090.A M3b)
  • M3c — one dispatch, one scope path (FR-11 upward). Re-scoped after M3b. The original M3c was residency alone; planning it showed FR-2's "one decision" had never been reached, and that porting the missing mechanism onto the second path would have added a third state with two re-entry queues. The milestone is therefore the convergence, and residency moves to M3d.

    A plain composite becomes instance zero of one: it decides at execFor like every other activity instead of earlier, at enterComposite. parkScopeHost, onScopeOpen, resumeScopeHost and evScopeOpen retire with it — one scope-open path, the executor's. The leaf Standard Loop converts here rather than in SRD-090.B: "one dispatch" cannot be true while executeStep carves it out, and a pass is a node executor whether or not it waits. Iteration state leaves the drivers (the loop-side mirror, miState/miSeed, scopeLoopCounter, awaitAttach, the drivesOwnIteration probes), and the flag by which a decorator asked a track to suppress its own bookkeeping goes, because activity-level bookkeeping happens once around the executor.

    The scope record carries the instance identity (ADR-025 §2.9.3a, the recorded-mapping choice). A ScopeRecord names the track that opened the scope and the ordinal it stands for, so restore resolves an owner by lookup and its precedence rule retires — it survives only as the Schema ≤ 6 read path.

    The re-entry queue does NOT retire, and that is the price of the choice. It exists because two concurrent hosts on one activity derive the same path, and a recorded mapping does not change what a path is: the collision is between two live scopes, not two readings of one name. Only spelling the identity into the segment would remove it, and that changes scope paths — which the scope lifecycle facts carry — so it was declined in favour of byte-identical paths (§2.9.3a's engine note records both sides of the trade). The queue is therefore ported onto the surviving path, as a request whose reply is deferred until the scope frees, rather than deleted. T-15 is what proves the port.

    Behaviour-preserving, so the existing suites are the oracle — with one named exception to T-1: the white-box tests of the retired loop-driven path are rewritten, reported as findings, not silently absorbed. T-15 is added rather than inherited, because no existing test covers two concurrent hosts on one composite end to end. T-15, T-16. feat(instance): every activity runs through one dispatch (SRD-090.A M3c) - M3g — the sequential counters, derived (ADR-025 §2.9). Added mid-branch, found by /review-srd on the v.5 draft. A live violation of Table 10.30's invariant, so it is fixed where it was found rather than filed.

    A sequential Multi-Instance publishes numberOfTerminatedInstances as a literal 0 (bindMICounters(n, completed, 0), activity_exec.go) and never binds it at all in bindInstance — including after a completionCondition has fired and the remaining instances are cancelled, which §2.7 and the spec both treat as termination. A sequential MI of five stopping after two completions reports 2 + 0 + 0, where Table 10.30 requires 2 + 3.

    The scope is the terminated count only. numberOfActiveInstances stays a literal 1 while a pass runs, and that is correct: ADR-025 §2.9 records that the table's cap (≤ 1 for sequential) and its sum cannot both hold mid-run, and that the engine honours the cap. The invariant is satisfiable for a sequential activity exactly at terminal states — where nothing runs and every instance is completed or terminated — and that is the case this milestone fixes.

    It lands BEFORE M3f, not inside it. M3f moves these four attributes to the iteration scope; moving a defective binder would carry the defect to a new address and make the diff say "moved" where it should say "fixed". Extends T-16. fix(instance): a sequential Multi-Instance derives its counters (SRD-090.A M3g) - M3h — the completionCondition sees the instance that completed (SRD-055 FR-11 / §4.3). Found by M3g's test, which had to document the wrong behaviour to assert anything at all.

    A sequential Multi-Instance evaluated its condition against the counts bindInstance published at the START of the pass — one completion behind — so numberOfCompletedInstances >= 2 stopped a five-instance activity after three. §13.3.7 says the condition is "evaluated every time an instance completes", and a count excluding the instance that just completed contradicts it.

    This restores a decision rather than making one. SRD-055 FR-11 already prescribes it — "the decorator increments numberOfCompletedInstances and rebinds the §2.9 attributes before the evaluation" — and §4.3 repeats it: "the condition sees the post-drain count." The code drifted from its own accepted SRD. The parallel path never did: parallelStep binds, throws, then evaluates, which is the order this milestone gives the sequential one.

    T-1 finding, reported not absorbed. TestLeafMISequentialCompletionStops asserted the extra pass and called it "the SRD-055 family semantics" — the opposite of what SRD-055 says. A test that holds a drift in place while citing the document it drifts from is worse than no test; it is corrected to the specified behaviour. fix(instance): the completionCondition sees the completed instance (SRD-090.A M3h) - M3f — the node execution unit, and the iteration scope (ADR-025 §2.13b). Inserted before M3d after the ADR-025 v.5 revision, because residency and registration both change shape under it and building them twice is what this branch has already paid for once.

    The unit. Executing a node becomes one object's whole job — frame lifecycle, data binding, the node's own step, the commit, the history — instead of a sequence a driver performs around a node that implements only its step. executeStep reduces to find the unit, run it, follow the flows. The node's model-level contract is untouched: the unit is a runtime object that HOLDS a node, which is what keeps this out of pkg/model.

    inSet goes, and the token's state machine is what retires it (ADR-025 §2.13b.1e). It exists only because an activity is one token's step however many times it executes, and the machine had no state meaning "iterating" — so a decorator had to stop each instance from reporting one. Two new states (iterating, hosting a scope) put per-instance executions BELOW the machine's granularity, so there is nothing left to suppress. The second is a correction as much as an addition: a composite host has read as executing while its body runs, which is the defect §2.13 named and fixed only inside the executor.

    Six additive touch points, one package: the state constant (appended — String() is a positional slice), the String() entry, the trackPhase map, the token's alive-projection list, liveTrackStates so a restored iterating track rebuilds, and the residency switch.

    Both map to the EXISTING executing phase, so no new value reaches a host and NFR-1 holds. A distinct phase is SRD-090.C's call, made with the token projection in hand.

    Two things deliberately NOT here. The iteration attributes on the token and the facts (kind, ordinal, counts) are the observable surfaces — §2.9.1, realized by SRD-090.C; growing the states and the projection together would be one change wearing two hats. And the Call Activity re-labelling (awaiting a child rather than waiting for an event) waits for M3e: WaitForEvent is what the dehydration path keys on, so it is live behaviour, and M3e decides the call executor's residency anyway.

    Capability passthrough (§2.13b.1): the unit answers for the node it holds, so a driver's probes reach an answer through a decorator. That retires checkNodeType's parallel-MI registration skip — the one genuine FR-11 violation §9 names — and makes the decorator the registered subscriber structurally rather than by rule.

    Registration moves; routing does not. The unit ARMS (hold-or-register) and ANNOUNCES (tells the loop a waiter exists, with its definitions); the loop keeps ROUTING, which is single-writer state that also drops a second delivery for one park. Two constraints carry over explicitly, because losing either fails silently rather than loudly: the announce precedes the arm, or a fired event reaches a loop with nothing to route it to; and conditional waits, boundary arming and the durable-holder registry stay outside the unit (§2.13b.1 says why each).

    The iteration scope is NOT here — it was retracted (ADR-025 §2.2a). It would have isolated §2.9's attributes by adding a level to every per-instance scope path; §2.9.2 already isolates them by serving them from the reserved read-only RUNTIME source, which no model can declare into or overwrite. One problem, one mechanism. Relocating the five BPMN-named attributes stays SRD-090.D's, with the per-execution addressing seam (ADR-010) and the migration §2.9.2 measured.

    T-19, T-20, T-21. T-19 pins the consequence of losing the ordering — a wait the loop holds no waiter for drops its fire silently — and then the same delivery landing once the announce arrives. The ordering itself holds today by an unbuffered channel: the announce blocks until the loop has taken it, so the arm cannot precede it. A test cannot assert that from the arming goroutine without racing the loop, which is exactly why the failure mode is what gets pinned. refactor(instance): a node execution is one unit (SRD-090.A M3f) - M3d — residency by what an instance awaits.Landed — separately from the rest of the slice (see the scope note at the end of §7); #336 now carries M3e alone.

    The behaviour change FR-8 states, now over a model where every composite — plain, sequential and fanned-out — reaches it by one route. It is not a predicate change: a host parked for a child's drain is not merely counted differently, its goroutine has to be releasable, which reaches the release path, the runner's unwind and the restore that re-enters the decorator at its recorded position. Today the host disqualifies its whole instance through dehydratableParked's default arm, so an iterated Sub-Process holding parked User Tasks pins it forever.

    It follows the convergence rather than preceding it because both its oracles are parallel-shaped — T-6 is three iterations each holding a parked User Task, T-7 is one unholdable wait among N — and because after M3c the rule covers the plain composite with no special case. A residency landing before either would have stated its rule three times. T-6, T-7. feat(instance): residency asks what an activity instance awaits (SRD-090.A M3d) - M3e — the call executor.Carried forward — not in the landing PR (see the scope note at the end of §7).

    An iterated Call Activity: callExec carrying its ChildID, awaitChild, re-linking each child to the ordinal that owns it across a restore, and cancellation reaching the child instances. T-9, T-10. feat(instance): the call executor, and an iterated Call Activity (SRD-090.A M3e) - M4 — the sweep. The old symbols deleted rather than orphaned, the schema-5 compatibility path proven against documents captured by the previous release, and the §9 absence check enforced. refactor(instance): retire the track-per-iteration mechanisms (SRD-090.A M4) - M4a–M4e — what the independent review found. Not planned work: the pre-merge review of the branch's diff (/pr-review, a non-Claude reviewer reading the code doc-blind) raised notes the author's own gates could not, and they are landed here rather than filed, per the project's no-pre-existing-errors rule.

    M4a — a schema-5 fan-out restores once. A parallel leaf MI captured by the previous release records its instances as child TRACKS; the new restore rebuilt those AND the executor set derived from them, so the activity ran 9 instances where 5 were due. The legacy tracks ARE the executor set — their LoopCounter carries the ordinal — so both halves are needed: drop the records, and adopt their ordinals. Dropping alone turns a duplication into a silent loss (9 → 3), which is what the test caught. fix(instance): a schema-5 parallel fan-out restores once (SRD-090.A M4a)

    M4b — a queued scope request whose host died. A host queued behind a composite's scope waits inside its roundtrip, which honors its context — so a boundary fire or a terminate can take it away mid-wait, leaving a request nobody listens for. Serving it opened the scope and seeded the body, which then ran detached from any live token: real work with no one to receive the result, and silent. The queue now skips dead hosts and serves the first live one. fix(instance): a queued scope open skips a host that died waiting (SRD-090.A M4b)

    M4c — a failed parallel MI tears down like a completed one. Three of awaitParallel's paths returned on the first error while the completionCondition path went through stopRemaining, so the barrier tore its instances down when it stopped on purpose and abandoned them when it stopped because something broke. The barrier becomes an explicit parallelBarrier whose fail records a failure the way a stop is recorded. fix(instance): a failed parallel MI tears down like a completed one (SRD-090.A M4c)

    M4d — the fan-out is handed its captures. runParallel downcast activityExec to *scopeExec to reach a composite instance's capture cell — the decorator asking what KIND of executor it holds, which is the question §2.13a exists to make unanswerable. The builder returns the capture it already had. refactor(instance): the fan-out is handed its captures, not asked for them (SRD-090.A M4d)

    M4e — test integrity. Two tests asserted a unit rather than the behaviour: one called stopRemaining directly (so it could not prove any caller keeps its error), and the broken-collection guard was covered only at the builder. Both now drive the decorator's run, and the parallel drain and bind paths gained the twins the sequential ones already had. Landed with M4c and separately. test(instance): the parallel fan-out faults on a broken split, and does so promptly (SRD-090.A M4e)

    M4f — the §9 check gets its known answer. Running the absence check for real turned up a fifth probe site the carve-out table did not list: ensureIterMirror derived the record's iteration KIND from the node (iterKindOf), loop-side, on the way in. That is the same shape iterating already fixed one call up — a driver asking what kind of iteration a node drives — and scopeRequest was already the place the answer belongs. The decorators state their kind; iterKindOf retires. It predates this slice, and is fixed here because the check is only worth having if its answer is known. refactor(instance): the decorator names its iteration kind (SRD-090.A M4f)

Scope of the landing PRs

This document landed in two PRs, not one. The first carried everything except M3d and M3e: the interface and the node executor (M1), the decorator and schema 6 (M2), the three composite kinds converged onto one dispatch and one scope path (M3a–M3c), the derived counters (M3g), the completionCondition's ordering fix (M3h), the node execution unit (M3f), the sweep (M4) and the review's findings (M4a–M4f). The second carried M3d (residency by what an instance awaits, FR-8) on its own.

Why the split is legitimate rather than an abandoned tail. The first PR's contents are behaviour-preserving by construction — that is §4's stated constraint, and it is what makes every existing test an oracle for them. M3d is the one milestone that deliberately changes behaviour, and M3e adds a construct. Landing them separately kept the refactor's oracle intact and gave the behaviour change a diff a reviewer could actually read, instead of burying it in six thousand lines of conversion. It earned its keep: M3d's own review found five defects, and the end-to-end test it prompted found a sixth that predates the whole slice — a restored scope entry lost the flag that says it belongs to an iteration, so a sequential composite re-ran its first pass on every restore, forever.

What is still undone:

  • An iterated Call Activity has no executor (M3e). T-9, T-10 do not exist yet.

Consequently this document does NOT flip to Accepted yet. It stays Draft until M3e lands, at which point §9's DoD is answerable in full. Tracked in #336.

§8 Cross-doc

  • Implements ADR-025 §2.13/§2.13a/§2.13b/§2.2/§2.12/§2.9.3a. Upstream ADR-017, ADR-023, ADR-033, ADR-007, ADR-010.
  • ADR-006 §2.9.5 (the decorator as subscriber) is realized by SRD-090.B (#313), not here: this slice registers waits exactly as today. FR-11's downward half is consequently only partly closed here — the execution and scope chains converge in M3c, the event chain in B. Stated so the absence check reads as scope, not as a miss.
  • ADR-025 §2.9/§2.9a/§2.9.3 (the runtime attribute set, now pinned to BPMN Tables 10.27/10.30; the 0-based deviation; ITERATION_ID / ITERATION_MODE) are realized by SRD-090.D (#340). This slice must preserve the publication seam FR-11 names as the sanctioned channel, not remove it.

None of D moves here. An earlier v.5 draft gave the five BPMN-named attributes an iteration scope so this slice could fix their address; that is retracted (ADR-025 §2.2a) because §2.9.2 already assigns the problem to the RUNTIME source. The counters keep their current address through this slice, defects included — the fix is D's, together with the addressing seam and the migration. - ADR-013 §2.11 and ADR-036 §2.4a are realized by SRD-090.C (#339). Two constraints FR-11 imposes on it, recorded here because they are cheap to honour and expensive to retrofit: - The token's iteration state is PULLED from the executor. track.Token() projects the track's own history today, which is why an iterated activity's token carries nothing about its instances. C must read the decorator's instance set — activityExec.state() already reports one — and must not mirror it on the track or the loop. A mirror is stale between updates by construction, and this branch has already paid for that three times (the loop-side iteration mirror disagreeing with the decorator, a host's loopCounter standing still through a fan-out, an executor set posted before its instances started and restoring as "all N still to run"). - A per-instance timeline is a FACT, not a track event. Where C needs "instance k started / finished" as a stream rather than a snapshot, it emits observability facts — which already carry AttrOrdinal — from the decorator. A new trackEvent kind would violate FR-9 and hand the loop the per-iteration lifecycle FR-3 removed. - The four ADRs flip Draft → Accepted when SRD-090.C (#339) lands, since that is the point at which every contract they state is true.

§9 Definition of Done

  • [ ] FR-1…FR-11 implemented; every §6 test exists and passes.

    Not answerable until M3e lands (#336). FR-1's fourth realization — the call executor — is M3e's, so T-9 and T-10 do not exist yet. Everything else is implemented: FR-8 landed with M3d, along with T-6, T-7 and T-17. The boxes stay unticked until the whole document is done. See §7's scope note. - [ ] FR-11's absence check. drivesOwnIteration, multiInstanceOf, standardLoopOf and fansOut appear only in the executor/decorator files (activity_exec.go, scope_exec.go, std_loop.go, mi.go) and the §2.9 publication seam. One scope-open path; one execution dispatch.

    Four carve-outs are legitimate and are listed here so the check is a grep with a known answer rather than an aspiration. A site NOT on this list is a finding:

Site Why it survives
adoptRestoredGroups (multiInstanceOf) the Schema-5 read path — it validates a legacy group record's host. Retires with Schema 5.
restoredScopeHost (fansOut) the Schema ≤ 6 derivation the recorded host replaced. Retires with Schema 6.
scopeLoopCounter (drivesOwnIteration) derives the ordinal a scope's lifecycle facts carry. It is read from an entry that may predate any request — a restored scope canceled before its re-attach — so moving it onto the request needs its own answer, not a field.
recordScopeCompletion (drivesOwnIteration) not a transparency violation. It encodes a rule about the NODE — whether an iterated activity compensates at all — not about who drives it, so converting it to a request field would launder a domain rule into a mechanism flag. It is not permanent either: the probe has no job once MI compensation is implemented (#327), and FR-11's check gets shorter then.
  One site is a **genuine** violation and is **SRD-090.B's subject**, not
  this slice's: `checkNodeType` skips hub registration for a parallel MI
  host (`track.go`), because the registered processor is still the track
  or the instance. B makes the decorator the registered processor, and the
  skip goes with it. FR-11's *downward* half closes there, as §8 states.
  • [ ] T-1 passes with no test edited. Any edit is reported and justified before the slice lands, or the change that forced it is reverted.
  • [ ] make ci green; diff-coverage ≥95% (aim 100%); suites race-clean.
  • [ ] runLeafMISequential, the parallel-leaf fan-out, leafPlain, miGroup, enterComposite, parkScopeHost, onScopeOpen, resumeScopeHost, evScopeOpen, runStandardLoop and iterKindOf are absent from the tree, not merely unreachable.
  • [ ] §10 filled.

§10 Implementation summary

Filled at landing.

Open questions

None — §4 records the resolved design points (all kinds at once, the record moving with the model, behaviour-preservation as the constraint, the naming collision).