SRD-090.D — Iteration runtime values, and what an iteration produces¶
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-08-26 |
| Owner | Ruslan Gabitov |
| Implements | ADR-025 §2.9 (the runtime-attribute set is the standard's), §2.9a (the 0-based loopCounter deviation), §2.9.2 (iteration values are engine-published at the address their cardinality allows), §2.9.3 (an iteration's identity is derived), §2.6.1 (one default, three declared result strategies), §2.15/§2.15a/§2.15b (a fan-out over human work assigns its performer per iteration; the host is the node's single execution context) — closes #340, and lifts the stopgap refusal that stands in for §2.15, for human work |
| Upstream | ADR-010 §2.7 (the named-source address space, left unchanged by this slice), ADR-011 (frame-first resolution and the commit target) |
| Related | SRD-090.A (the node execution model, whose FR-4 binds the iteration's locals frame-local), SRD-090.B (the waiting instance), SRD-090.C (#339 — the token and incident surfaces, which read the same iteration vocabulary), SRD-007 (the RUNTIME subtree), SRD-042 (structural paths) |
This slice moves the Multi-Instance counts off the writable data plane and
onto the reserved read-only RUNTIME source, adds the engine's ITERATION_*
family at the address each one's cardinality allows, and gives a model a way to
say what its iterations' results mean.
It carries no breaking change: every BPMN-named attribute keeps resolving exactly as written. What changes is that the engine's names become un-declarable, and that a durable, unambiguous reading of the same facts exists beside them. §4.1 and §4.3 record why the addresses did not move, and they are the findings that shrank this slice.
§1 Background (verified at dfc77d06)¶
1.1 The counts are on the writable plane, and die with the activity¶
BPMN Tables 10.27 and 10.30 define the iteration runtime attributes, and ADR-025 §2.9 pins gobpm's set to them. The four Multi-Instance counts are published by binding them as ordinary scope data at the activity's host scope:
// internal/instance/mi.go:455
func (t *track) bindMICounters(n, active, completed, terminated int) error {
binds := []miBinding{
{name: "numberOfInstances", value: n},
{name: "numberOfActiveInstances", value: active},
{name: "numberOfCompletedInstances", value: completed},
{name: "numberOfTerminatedInstances", value: terminated},
}
for _, b := range binds {
if err := t.instance.sc.bindDataItemAt(
t.scopePath, b.name, b.value); err != nil {
Two consequences follow, neither of them decided:
- A model can declare over them. They live in the default scope, and
nothing refuses a collision:
data.CheckName(pkg/model/data/name.go:23) validates reserved characters —/,.,[,]— and has no notion of a reserved name. - They die with the activity. A host-scope bind goes with the activity, so "how many did we process?" is unanswerable one node later, and a map key (§2.6.1) has nothing durable to key on.
1.2 loopCounter is already per-execution — and already protected¶
The counter is not on the host scope. It is bound frame-local, deliberately:
// internal/instance/activity_exec.go:956
// iterationLocals builds instance ord's own data: the 0-based loopCounter
// and, for a collection-driven Multi-Instance, the element split off at
// that ordinal. They are bound frame-local, so the instances of one
// activity cannot overwrite each other's (SRD-090.A FR-4).
reaching the frame through BindLocal, which stores into f.props
(internal/scope/frame.go:226), and Frame.lookup consults inputs → props →
puts before walking up to the container scopes:
// internal/scope/frame.go:393
func (f *Frame) lookup(finder dataFinder) (data.Data, bool) {
for _, in := range f.inputs { ... }
for _, pr := range f.props { ... }
for _, pt := range f.puts { ... }
But that is one publication path of three, and the distinction matters for what protects the counter:
| Path | Where loopCounter is bound |
Exposed to a model's write? |
|---|---|---|
| parallel leaf MI | frame-local, iterationLocals → BindLocal |
no — frame-first shadows a declaration |
| sequential MI, leaf and composite | the activity's own scope, bindInstance (internal/instance/mi.go:259) |
yes, like the counts |
| Standard Loop | the activity's own scope, bindLoopCounterAt (internal/instance/std_loop.go:184) |
yes, like the counts |
So the counter is positionally protected only on the parallel-leaf path; on
the other two it is protected by the same refusal the counts rely on (FR-6).
What is true on every path is the cardinality: the value differs per
executing instance, so no flat RUNTIME name can serve it. That is the
load-bearing reason in §4.1, and it does not depend on which path binds it.
"Dies with the execution" is correct on all three, not a defect: a
per-execution value should. The durable question is ITERATIONS', which is
activity-keyed.
This is what §4.1 turns into a decision, and it is why this slice does not change the data plane's named-source contract.
1.3 Write-protection already exists and needs nothing¶
// internal/scope/scope.go:647
func (p *Scope) checkWritable(op string, path DataPath) error {
if path == p.rtPath ||
strings.HasPrefix(path.String(), p.rtPath.String()+PathSeparator) {
return errs.New(
errs.M("%s: %q is the reserved read-only runtime subtree", op, path),
reserved "even when no RuntimeVarsSupplier is configured"
(internal/scope/runtimevars.go:9). Anything served from RUNTIME is
unwritable by construction.
1.4 The supplier serves four names, per instance¶
// internal/instance/runtimevars.go:99
func (inst *Instance) RuntimeVarNames() []string {
return []string{StartedAt, CurrState, TracksCount, CompletedBy}
}
Every one has a single answer per process instance — RuntimeVar(name) is a
total function of the instance. COMPLETED_BY is the shape this slice reuses:
a map (node name → completer, internal/instance/performers.go:21) that
keeps the name set closed while carrying per-activity detail.
1.5 An instance's other results have no declared meaning¶
ADR-025 §2.6.1 defines a last-wins default plus array, map and
reduce. None exists — grep -rn "ErrorOnKeyRewrite\|resultStrategy" over
pkg/ and internal/ returns nothing. What exists is the positional assembly
of the declared MI output collection (miIterator.publishOutput,
internal/instance/mi.go:166), which is §2.6's mechanism and what the array
strategy formalizes and extends to a Standard Loop.
1.6 The counts are read by walk-up, from more than one place¶
The completionCondition is not evaluated inside an instance's frame. It gets
a transient frame opened at the activity's own scope, and resolves the
counts by walking up from there:
// internal/instance/mi.go:136 — evalBoolAtHost, shared by the
// completionCondition and a Complex behavior condition
func (t *track) evalBoolAtHost(
ctx context.Context, what, nodeID string, expr data.FormalExpression,
) (bool, error) {
frame, err := t.instance.sc.openFrameAt(what, nodeID, t.scopePath)
whose own comment names the coupling: "a transient frame at the host scope (where the §2.9 attributes are published)". The same walk-up is what lets a node inside the body of a composite Multi-Instance read the counts several scopes down.
So the counts need subtree visibility, which is a third cardinality class:
not one answer per execution (that is loopCounter), and not one per process
instance (that is what a RUNTIME name can serve), but one per activity
activation, readable by everything beneath it.
1.7 What this slice must not break¶
ITERATION_* publication is the one sanctioned channel by which a decorator
tells a node anything (ADR-025 §2.13a.1) — a publication seam, never a query
interface. §2.13a's transparency invariant depends on it staying one-way.
§2 Requirements¶
Functional¶
-
FR-1 — the four counts keep their address. They stay published at the activity's own scope, where
bindMICountersputs them, because they must be readable from anywhere inside the activity — acompletionConditionevaluated in a transient frame at that scope (§1.7), and a node in the body of a composite instance several scopes down. A flatRUNTIME/name cannot serve either: the supplier is handed a name and nothing else, so two concurrent iterated activities in one instance would give it two answers (§4.3). -
FR-2 — those names become un-declarable, which is what closes the overwrite defect §1.1 names. See FR-6.
-
FR-3 —
loopCounteris unchanged, andITERATION_NUMBER,ITERATION_ID,ITERATION_MODEjoin it frame-local, published by the same mechanism (iterationLocals→BindLocal). No named-source contract changes. -
FR-4 —
ITERATIONSandITERATION_OWNERSare served fromRUNTIMEand outlive their activity. Maps keyed by activity id —{kind, total, completed, terminated}and ordinal → actual owner — so a later node can answer "how many did we process?" and §2.6.1's map key has something durable to key on. Keyed likeCOMPLETED_BY, so the RUNTIME name set stays closed.
ITERATION_OWNERS' data source is the decorator, not the engine's task
registry. ADR-025 §2.15 puts the completion account there — "the decorator
holds which assignees have completed" — and the decorator lives in
internal/instance, so the value is served without reaching across the
package boundary into pkg/thresher. FR-10 is what populates it.
-
FR-5 — an instance's identity is derived, not minted.
ITERATION_IDis the enclosing scope path, the activity id and the ordinal (ADR-025 §2.9.3): stable across a restore with nothing stored, resolvable in both directions.ITERATION_MODEpublishes the record's existingkind. -
FR-6 — a colliding declaration is refused at build time. A process property, data object or item-aware element named with a reserved iteration name is rejected when the process is built, naming the offending element. Scoped to the names this slice makes engine-owned — a located error where it is written beats a wrong answer three nodes later.
-
FR-7 — a model can declare what its instances' results mean. The default stays last-wins. A declared array indexes by ordinal; a map keys by a per-instance expression evaluated in the completing instance's frame; reduce names the accumulating default. An empty or missing map key refuses; a duplicate key overwrites by default and faults under
ErrorOnKeyRewrite, naming both ordinals and the key. -
FR-8 — a declared result publishes once, at completion. §2.6's visibility barrier covers a declared array or map: it reaches the enclosing scope at activity completion, never incrementally.
-
FR-9 — the iteration variables are documented in one place. Every name, its address, its lifetime, and why the frame-local group differs from the
RUNTIMEgroup — because a reader today has nowhere to learn what an iteration publishes. The CHANGELOG records the new names and the new refusal. -
FR-10 — a parallel fan-out over human work executes. Each iteration resolves its own performer expression in its own data context (ADR-025 §2.15), mints its own parked-work identity, and is announced, taken, claimed and completed as an ordinary task (ADR-020 §2.12). The host holds the N waits and applies completions serially on its own goroutine (§2.15a), which removes the race rather than synchronising it.
Eligibility is assessed once, at the announcement, and checked thereafter (ADR-020 §2.7) — so the resolved verdict rides the checkpoint beside the identity it was announced under. A restore cannot recompute it: the element the iteration was seeded with is frame-local to an execution that no longer exists, so a re-resolution reads the host's scope and a per-iteration performer resolves to nobody — locking every holder out of a task their inbox is still showing them. A document written before the verdict was recorded restores as it always did.
The guard is a stopgap standing in for this requirement, not a constraint on
it — but it covers TWO shapes, and this requirement is about one. It is
therefore narrowed, not deleted, in the commit that lands the mechanism:
the human-task half goes, and parksOnWorker keeps refusing a parallel
Multi-Instance over an external-worker Service Task, whose job identity is
still the track's rather than the instance's. Deleting it wholesale would
re-open, for worker jobs, the silent wrong answer it was added to close.
That half is tracked by #355, which applies this milestone's mechanism to
the job registry.
Non-functional¶
- NFR-1 — no new write path into
RUNTIME. FR-1/FR-4's values are served, never stored; §1.3's guard remains the only enforcement. - NFR-2 — the publication seam stays one-way (ADR-025 §2.13a.1). Nothing added here lets a node ask whether it is decorated.
- NFR-3 —
data.SourceProvideris untouched. No embedder-implemented provider changes signature or behaviour (§4.1). - NFR-4 — the closed name set stays closed.
RuntimeVarNames()grows by a fixed TWO entries —ITERATIONSandITERATION_OWNERS, six in all — and the reserved data-name set by five, ten in all. No per-activity namespace, no prefix matching: both maps are keyed by activity id rather than answered by a name per activity, which is what keeps the sets closed however many activities iterate. - NFR-5 — a non-iterated model is unaffected except by FR-6's refusal.
§3 Models¶
3.1 The runtime names¶
Six entries join the closed set RuntimeVarNames() serves
(internal/instance/runtimevars.go:99), beside STARTED_AT, STATE,
TRACKS_CNT and COMPLETED_BY:
| Constant | Address | Shape |
|---|---|---|
NumberOfInstances |
numberOfInstances |
int — the activity's frozen total |
NumberOfActiveInstances |
numberOfActiveInstances |
int |
NumberOfCompletedInstances |
numberOfCompletedInstances |
int |
NumberOfTerminatedInstances |
numberOfTerminatedInstances |
int |
Iterations |
ITERATIONS |
map: activity id → {kind, total, completed, terminated} |
IterationOwners |
ITERATION_OWNERS |
map: activity id → (ordinal → actual owner) |
The four counts keep BPMN's spelling; the two maps use the engine's convention, per ADR-025 §2.9.2's naming rule.
The counts are activity-scoped names served per instance, which is only
coherent because at most one iterated activity is executing on a given track
at a time — the reader is inside the activity whose counts it is asking for.
ITERATIONS is the address for any other question, and the one a node
outside the activity must use.
3.2 The frame-local names¶
Published by iterationLocals → BindLocal, alongside the existing
loopCounter and the split input item:
| Name | Shape |
|---|---|
ITERATION_NUMBER |
int — the same 0-based ordinal as loopCounter |
ITERATION_ID |
string — the derived identity of §3.3 |
ITERATION_MODE |
string — std_loop | mi_sequential | mi_parallel |
ITERATION_MODE's vocabulary is the record's existing kind
(internal/instance/iter_mirror.go:48), not a second one.
3.3 The derived identity¶
ITERATION_ID is the enclosing scope path, the activity id and the ordinal,
joined — derived on read, stored nowhere (ADR-025 §2.9.3). All three
components already survive a checkpoint: the scope path is in the scope table,
the activity id is in the graph, the ordinal is in the executor set.
3.4 The result-strategy declaration¶
A MultiInstanceOption beside the existing nine
(pkg/model/activities/multiinstance.go:105), and the same for a Standard
Loop.
An assembling strategy names two things, not one. ADR-025 §2.6 states the
assembly as "the engine writes that instance's outputDataItem into slot
loopCounter of the loopDataOutputRef collection" — where it goes AND which
per-instance value goes into it. An activity may declare more than one output,
so the item is not derivable from the collection's name. Reduce is the
exception: it assembles nothing, and the name it declares IS the accumulating
value in the enclosing scope.
Multi-Instance:
WithResultMap(name, item string, key data.FormalExpression, opts ...MapOption)— results keyed bykey, evaluated in the completing instance's frame.ErrorOnKeyRewrite()is the oneMapOption.WithResultReduce(name string)— names the accumulating default.
A Multi-Instance has no array option, because the array strategy already
exists as the standard's own loopDataOutputRef assembly (§2.6.1's table says
so: "For MI this IS the spec's loopDataOutputRef assembly"), declared with
WithOutputCollection(ref, item). A second spelling of it would be two ways to
say one thing — the confusion "one strategy per activity" exists to prevent.
Standard Loop, which the standard gives no output aggregation at all:
WithLoopResultArray(name, item string)— results indexed by pass ordinal.WithLoopResultMap(name, item string, key data.FormalExpression, opts ...MapOption)WithLoopResultReduce(name string)
Declaring more than one refuses at construction: they are alternative readings of the same instances' results, not composable.
§4 Analysis & decisions¶
4.1 Why the counter stays frame-local — RESOLVED¶
ADR-025 §2.9.2 originally read "every iteration value is engine-published,
without exception", which taken literally moves loopCounter to
RUNTIME/loopCounter. Grounding that against the code (§1.2) showed it would
cost more than it buys:
- The supplier's contract is
RuntimeVar(name)and its caller chain —Frame.GetData→Scope.GetSource→prov.Get(addr)— dispatches the address verbatim, with no frame (frame.go:279,scope.go:277). A per-execution answer is therefore not expressible without carrying the asking execution into the lookup. - Doing so means widening
data.SourceProvider, which is public precisely so "an embedding application can expose its own data … as a named source" (pkg/model/data/source.go:3). Exactly one in-repo provider exists. The change would break every embedder's implementation to serve one engine-owned value. - And it buys nothing: frame-first resolution already makes the counter unoverwritable (§1.2), which is the property the move was for.
Decided: publish by cardinality. A value of the activity (one answer per
process instance) goes to RUNTIME; a value of the execution (N at once) is
published frame-local, where the frame is the per-execution address space.
ADR-025 §2.9.2 is corrected to say so, and records the widened-contract
alternative as rejected.
4.2 Which names are per-execution and which are per-activity¶
Per execution, frame-local: loopCounter, ITERATION_NUMBER, ITERATION_ID,
ITERATION_MODE. Per activity or per instance, RUNTIME: the four counts,
ITERATIONS, ITERATION_OWNERS.
4.3 Why the counts do NOT move either — RESOLVED¶
The counter stays because frame-first resolution already protects it (§4.1). The counts have no such protection, and the exposure is not hypothetical:
// internal/scope/frame.go:364 — a frame's outputs commit to its attachment
// point, which for a Multi-Instance instance IS the host scope the counts
// are bound at.
changes, err := f.plane.Commit(f.at, batch...)
So an instance declaring an output named numberOfCompletedInstances
overwrites the engine's count on commit, and the completionCondition
evaluated at the next completion reads the model's number. "Stop when 3 of 5
approved" then stops on a value the model chose, and nothing reports it.
That is a real defect. But moving the counts to a flat RUNTIME/ name does not
fix it — it makes them unreadable instead, for two independent reasons found in
§1.6:
- Two concurrent iterated activities give one name two answers. A parallel
gateway with a Multi-Instance on each arm is an ordinary model.
RuntimeVar(name)receives a name and nothing else, so it cannot say which activity was meant. This is §4.1's cardinality argument one level up: what keepsloopCounterout of RUNTIME keeps the counts out too. - The readers are not in the instance's frame. The
completionConditionevaluates in a transient frame at the activity's own scope, and a node in a composite instance's body reads by walk-up from several scopes down. Neither is served by a per-instance supplier, and neither is served by binding into one instance's frame.
Decided: the counts stay where they are, and the defect is closed by making the names un-declarable (FR-6). For a model's value to reach that scope, the name must be declared somewhere — a property, a data object, an output parameter, the target of a data association. That is an enumerable, checkable set of sites, and refusing at build time puts the error on the line that wrote it rather than three nodes later.
This is a weaker guarantee than an unwritable address, and the trade is deliberate: an address that cannot be read correctly is worth less than one that can be written incorrectly only by a declaration the engine refuses.
ITERATIONS carries what the counts cannot: it is keyed by activity id, so it
is unambiguous with any number of concurrent iterations, and it outlives the
activity. It is the address a reader outside the activity must use, and the
durable key §2.6.1's map result needs.
4.4 The map key and the visibility barrier — RESOLVED¶
§2.6's barrier withholds the assembled collection from concurrent activities until completion. §2.6.1's key expression is evaluated by the engine, in the completing instance's own frame, as part of assembling that collection. The two do not interact: the barrier governs who may read the result, not whether the engine may compute it.
§5 API deltas¶
Public, additive: the result-strategy option constructors of §3.4.
Public, breaking: none. An earlier draft moved the four counts to
RUNTIME/; §4.3 resolved against it and FR-1 keeps their address, so no
expression in any model changes. What was breaking about this slice became the
refusal below instead.
Public, newly refusing: a model declaring any of the six §3.1 names, or the three §3.2 names, is refused at build time (FR-6) with a classified error naming the element and the reserved name.
Unchanged, deliberately: data.SourceProvider (§4.1),
RuntimeVarsSupplier, Frame.GetData's resolution order, and loopCounter at
its current address.
§6 Test scenarios¶
| # | Scenario | Pins |
|---|---|---|
| T-1 | A completionCondition reading the BARE numberOfCompletedInstances stops a 5-instance activity after 3 — the address is unchanged (§4.3) |
FR-1 |
| T-2 | A model declaring one of the four counts is refused at build time; the engine keeps publishing them at the activity's own scope | FR-2, FR-6 |
| T-3 | An instance declaring an output named numberOfCompletedInstances is refused at build time; and were it not, the count is unwritable — a commit at the RUNTIME path is rejected |
FR-6, §4.3 |
| T-4 | loopCounter still resolves bare inside an instance. The shadowing half is no longer constructible: FR-6 reserves the name, so the process property that would shadow it is refused where it is written (T-3) — a stronger outcome than the test this scenario asked for |
FR-3, §1.2 |
| T-5 | Three parallel instances read ITERATION_NUMBER concurrently and get 0, 1, 2 |
FR-3 |
| T-6 | ITERATION_ID is stable across a dehydrate/hydrate cycle with nothing stored for it |
FR-5 |
| T-7 | ITERATION_MODE reads mi_parallel / mi_sequential / std_loop for the three shapes |
FR-3 |
| T-8 | RUNTIME/ITERATIONS answers for an activity that has already completed, from a later node |
FR-4 |
| T-9 | RUNTIME/ITERATION_OWNERS maps ordinal → owner for a sequential iterated User Task, and for a parallel fan-out three people answer at once |
FR-4 |
| T-10 | A declared array indexes by ordinal regardless of completion order (parallel) | FR-7 |
| T-11 | A declared map keys by the completing instance's expression; an empty key refuses | FR-7 |
| T-12 | A duplicate map key overwrites by default; under ErrorOnKeyRewrite it faults naming both ordinals and the key |
FR-7 |
| T-13 | A declared array/map is invisible to a concurrent activity until completion | FR-8 |
| T-14 | Declaring two strategies on one activity refuses at construction | §3.4 |
| T-15 | The name sets stay closed — RuntimeVarNames() returns exactly the six, and ReservedNames() exactly the ten |
NFR-4 |
| T-16 | A result strategy declaring an engine name is refused at construction — a strategy publishes by name, so it is one more door on the collision T-2 and T-3 close | FR-6, FR-7 |
| T-17 | A released fan-out comes back authorizing the same people: each iteration's assignee can still complete its own task, and the wrong actor is still refused | FR-10 |
§7 Milestones¶
Each leaves the suite green. Each was meant to be one commit; M4 was not, and says so below.
- M1 — an instance can read which instance it is.
ITERATION_NUMBER,ITERATION_IDandITERATION_MODEframe-local (§3.2, §3.3), by the mechanism that already carriesloopCounter;ITERATIONSserved fromRUNTIME, keyed by activity id and outliving the activity. T-5…T-8, T-15.feat(instance): an instance can read which instance it is (SRD-090.D M1) - M2 — the reserved names refuse. FR-6, over every site a model can declare
one of the nine names at: a property, a data object, an output parameter, the
target of a data association. This is what closes §1.1's overwrite defect
now that the counts keep their address. T-3, T-4.
feat(model): a reserved iteration name is refused where it is written (SRD-090.D M2) - M3 — the iteration variables get documented. One place listing every
name, its address and its lifetime: the frame-local four read as plain names
inside an instance, the
RUNTIME/maps read by path, which outlive the activity and which do not, and why the groups differ (§4.1, §4.3). Today onlySTARTED_ATandCOMPLETED_BYappear anywhere in the guides (docs/guides/concepts/scope-and-data.md:117,docs/guides/tasks/user-task.md:249).scope-and-data.mdgains the cross-reference and the glossary gains the terms.docs: the iteration variables, their addresses and their lifetimes (SRD-090.D M3) - M4 — the parallel fan-out over human work. Per-instance performer resolution, per-instance parked identity, the decorator as the node's single execution context applying completions serially (ADR-025 §2.15/§2.15a), and the withdrawal rules of ADR-020 §2.12.
Landed as four commits, not one. The mechanism came first, behind the
standing refusal (98d09e83); the feature and the narrowed refusal followed
(c123d519); serial application was implemented only after a shared-node
defect proved the goroutine-per-instance shape wrong (224d0bd2); the
decorator's own coverage closed it out (9b095fa7). The one-commit plan
assumed the mechanism was understood before it was written, and it was not:
activities.UserTask buffers the completion it is handed ON THE NODE, which
a fan-out's instances share, so one approver's outputs could be bound into
another instance's frame. That is the class ADR-025 §2.15a rules out, and it
is why the ADR prescribes serial application rather than synchronisation.
parksOnCapability is NARROWED, not deleted. It is now parksOnWorker.
An external-worker Service Task is still keyed to the TRACK — ls.jobs maps
a job id to a track, with no ordinal — so N instances would share one job
identity and a single report would complete work nobody performed. That is
the same defect this milestone removed for human work, and the refusal stands
until the worker path gets the same per-instance treatment. Deleting it
wholesale would have re-opened the silent wrong answer daac981e closed.
feat(instance): a fan-out over human work assigns its performer per instance (SRD-090.D M4)
- M5 — ITERATION_OWNERS. Served from the decorator's completion account,
which M4 creates. T-9.
feat(instance): who acted, per instance (SRD-090.D M5)
- M6 — the result strategies. §3.4's declaration, the assembly, the
visibility barrier and the key rules. No dependency on M1–M5, so it may be
reordered if review prefers. T-10…T-14.
feat(activities): a model declares what its instances produce (SRD-090.D M6)
- M7 — the independent review's findings. An external, doc-blind read of
the branch (
/pr-review) raised nine notes; the agreed ones are fixed here rather than filed, per the project's no-pre-existing-errors rule. Three were real defects the self-review chain could not have asked about: the result assembly is written from N goroutines when no iteration parks and needed its own lock; the barrier counted iterations that hold no wait, so it waited for a delivery nobody would send; and a restored fan-out re-resolved eligibility outside the iteration's data, locking every holder out of their own task (FR-10, T-17). The rest are a per-ordinal idempotence guard on the barrier, the engine-name refusal for a strategy (T-16), and the two coverage gaps T-9 and T-10 name.fix(instance): the independent review's findings (SRD-090.D M7)
No migration milestone. The earlier plan had one because the counts were to move; §4.3 keeps their address, so no in-repo reader, example or guide changes its expressions. M3 is documentation of what exists, not a rewrite of what broke.
§8 Cross-doc¶
| Ref | Pinned | Direction |
|---|---|---|
| ADR-025 §2.9, §2.9a, §2.9.2, §2.9.3, §2.6.1 | Draft (in-flight) | SRD → ADR ✓ |
| ADR-010 §2.7 | v.2 | SRD → ADR ✓ |
| ADR-011 | v.7 | SRD → ADR ✓ |
| ADR-020 §2.4.2 (the precedent §4.3 rests on) | v.4 | SRD → ADR ✓ |
ADR-025 is edited by this slice, not merely consumed: §2.9.2 was corrected during authoring (§4.1) from "every iteration value in RUNTIME, without exception" to publication by cardinality. The ADR is Draft, so the correction is an in-place edit with no version bump, per the project's doc-lifecycle rule.
No downward references: nothing in ADR-025 or ADR-010 cites this SRD.
§9 Definition of Done¶
- [x] FR-1…FR-10 implemented; every §6 test exists and passes.
- [x] The counts keep their address (FR-1).
bindMICountersbinds the same four names at the same scope; its string literals became thedata.NumberOf*Nameconstants FR-6 reserves, which is a rename of the spelling and not of the address. - [x]
loopCounterresolves bare, unchanged (T-4). The shadowing test is superseded: the property that would shadow it is refused at build time. - [x]
data.SourceProvideris untouched —git diffonpkg/model/data/source.gois empty (NFR-3). - [x]
RuntimeVarNames()returns exactly SIX names, andReservedNames()exactly ten (NFR-4, T-15). - [x] The HUMAN-TASK half of
parksOnCapabilityis absent from the tree, not merely unreachable — removed in the same commit as FR-10's mechanism. The guard is narrowed rather than deleted: it also covered an external-worker Service Task, whose job identity is still the track's, soparksOnWorkerkeeps refusing that shape (#355). Deleting it wholesale would have re-opened, for worker jobs, the silent wrong answer it closed. - [x] A parallel Multi-Instance over a User Task builds, announces one task per instance with distinct identities, and completes only when each has been completed by hand.
- [x] No expression in the repo changed address — the counts resolve exactly as before (FR-1), verified by the examples running unchanged.
- [x] CHANGELOG records the new names and the new refusal.
- [x] The iteration variables are documented in one place — every name, its
address, its lifetime — and
scope-and-data.mdand the glossary point at it (M2). - [x] A released fan-out authorizes the same people it announced to (FR-10, T-17): the resolved verdict rides the checkpoint beside the identity, and the restore adopts it rather than resolving again outside the iteration's data. Verified to fail with the adoption reverted.
- [x] Every agreed finding of the independent review is fixed in this branch, each with the regression test that pins it (M7); nothing is deferred.
- [x]
make cigreen; diff-coverage ≥95% (aim 100%); suites race-clean. - [x] The examples run end-to-end, not merely build (the
run-examplesgate). - [x] §10 filled.
- [x] #340 closed by the merge (PR #358, 2026-08-29).
§10 Implementation summary¶
Landed over twenty-two commits on feat/iteration-runtime-values. Every FR is
implemented and every §6 scenario has a test, with two amended above: T-1 and
T-2 described a move of the counts that §4.3 resolved against, and T-4's
shadowing half became unconstructible once FR-6 reserved the name.
What went as planned. M1's per-execution names and the ITERATIONS
register, M2's refusals, M3's guide. The counts kept their address, so no
model, example or guide changed an expression — the deliberate non-event this
slice was shaped around (§4.3).
What did not. M4 was planned as one commit and took four, and the reason is
worth recording because it was a mistake of sequencing rather than of design.
The mechanism landed behind the standing refusal; the feature and the narrowed
refusal followed; only then did a shared-node defect show that the
goroutine-per-instance shape was wrong. activities.UserTask buffers the
completion it is handed ON THE NODE, and a fan-out's instances share that node,
so one approver's outputs could be bound into another instance's frame. That is
the class ADR-025 §2.15a rules out, and the rework implemented what the ADR had
said all along: the decorator holds the N waits and applies their completions
serially, on its own goroutine. The instances became state it owns rather than
goroutines running a node they share.
Two further defects surfaced from that rework and are pinned by tests: a mid-parking release froze a partial identity register that every later capture then preferred, and re-querying "does this instance hold a wait" after a completion had arrived let two approvals finish a three-approval activity.
What the slice found in code it did not write. Stopping the token's ARRIVAL from parking an iterated activity — correct, since the instances park themselves — left both sequential shapes with no parking at all, because neither re-classified for its own execution. A sequential Multi-Instance over a User Task offered nothing, and so did a Standard Loop over one. Nothing covered either shape, which is why four commits passed over them; the refusal that told modellers to "make it sequential" was pointing at a fallback that did not work.
What changed shape during implementation. §3.4's signatures: an assembling
strategy names the per-instance item as well as the collection, because ADR-025
§2.6 states the assembly in both halves and an activity may declare more than
one output. A Multi-Instance gained no array option, since that IS the
standard's loopDataOutputRef.
What is deliberately not here. The same fan-out over an external-worker
Service Task: its job identity is still the track's rather than the instance's,
so parksOnWorker keeps refusing it. That is #355, and it has this slice's
mechanism as a worked precedent.
Open questions¶
None — §4.1 and §4.3 record the two that existed. §4.1's resolution changed ADR-025 §2.9.2 rather than the code.