SRD-089.H — BPMN import: loop characteristics¶
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-08-18 |
| Owner | Ruslan Gabitov |
| Implements | ADR-024 v.5 §2.9 (the element set), §2.14 (the report contract) |
| Upstream | ADR-025 v.5.1 (the iteration model the imported markers drive), ADR-030 v.1 §2.8 (a collection DataObject feeds loopDataInputRef by name), ADR-024 §2.16 (refusal classes) |
| Related | SRD-089.G §4.7a (the MI-indirection handoff this stage inherits), SRD-089.D (the event-definition builders M4 reuses), SRD-090.A (the decorator dispatch that executes what this stage imports), SRD-063 (name-based scope resolution), SRD-054 (the landed Standard Loop) |
| Tracking | #284 — Part of; the import half of #335 |
The engine iterates activities two ways — the condition-driven Standard Loop
and the count-driven Multi-Instance — through one decorator dispatch
(execFor, SRD-090.A M3c). A BPMN file carries the same two markers, and the
importer refuses both today. This stage imports them, completing the loop
column of ADR-024 v.5 §2.9's element set with no pkg/model change:
every marker maps onto NewStandardLoop / NewMultiInstance and rides
WithLoop, all existing.
§1 Background (verified)¶
Both markers are refused with a wrong §. No parser table claims either
tag, so the zero-value disposition refuses them (dispatch.go:56-60), and
sections pins both at "§13.3.5" (dispatch.go:223-224) — a number no
extract line supports: the extract's own heading is "§13.3.6 + §13.3.7
(spec p432–433)" (semantics/multi-instance.md:3) and conformance.md:11
says "§13.3, including … Loop and Multi-Instance". The pin arrived on master
with the sections table and predates the grounding rule; FR-6 corrects it —
Standard Loop §13.3.6, Multi-Instance §13.3.7 — because the stage that
imports the family cannot leave the § beside it wrong (the SRD-089.F FR-8
precedent, exactly).
The model has both kinds, complete. NewStandardLoop(loopCondition,
opts...) (loop.go:70-73) with WithTestBefore (loop.go:40-46; "without
it the loop is post-tested (a do-while, the BPMN §13.3.6 default)") and
WithLoopMaximum (loop.go:51-65, positive only). NewMultiInstance(opts...)
(multiinstance.go:259-261) with WithSequential, WithCardinality,
WithInputCollection, WithOutputCollection, WithCompletionCondition,
WithBehavior, WithNoneBehaviorEvent, WithOneBehaviorEvent,
WithComplexBehavior (multiinstance.go:105-253). An activity takes either
through WithLoop (activity_options.go:138-152), which every task kind,
sub-process and call activity accepts through the options funnel — the same
door SRD-089.G used for parameters.
The model's guards are stricter than the grammar, in four places:
NewStandardLooprefuses a nilloopCondition(loop.go:74-78), while the extract makes it0..1(elements/activities.md:568-579). §4.2 decides the refusal.NewMultiInstancedemands exactly one cardinality source —WithCardinalityorWithInputCollection, "not both" (multiinstance.go:272-287), and NEITHER trips the same guard. The extract makes both0..1. §4.3 decides both directions.WithInputCollection/WithOutputCollectionrequire both the ref and the item name (multiinstance.go:133-166); the extract makesloopDataInputRefandinputDataItemindependently0..1.loopCardinalitymust be an integer expression andloopCondition/completionConditionboolean ones (multiinstance.go:283-295,loop.go:80-85) — result types the expression machinery already mints (lite.Conddeclares bool,language.go:143-151;lite.Expr+data.WithResultType("int")is the integer form,lite.go:152-157).
The collection refs are names at the model, IDREFs in the file.
WithInputCollection(ref, item): "ref names the input collection datum
in scope, item names the per-instance datum bound to element i"
(multiinstance.go:130-132); the runtime resolves it by name through the
scope plane (mi.go:209-221) and publishes the split item at the host
scope by name (mi.go:224-250, SRD-055). The file's loopDataInputRef is
an IDREF to an ItemAwareElement (elements/activities.md:591-612), and the
extract's own constraint narrows the target: "The MI Activity's
loopDataInput MUST be linked to a process-scope DataObject"
(semantics/multi-instance.md:108). An imported DataObject is scope-resident
under its Name() with the id fallback (SRD-063 FR-3/FR-5). §4.4 maps the
id to the name.
The engine executes all of it. execFor routes a standard loop to
loopDecorator and an MI to iterDecorator (activity_exec.go:109-135);
the behavior events are thrown from mi_behavior.go:24,28,56. Two
executions the engine refuses are refused AFTER import: an event
sub-process with a loop fails the process's own validation
(subprocess.go:438-444, "instantiated by its event trigger, not
iterated"), which build reaches through asm.proc.Validate() — so the
import surfaces it; and an iterated waiting leaf fails at
snapshot/registration (snapshot.go:349-374, naming #313 and the
remodeling advice). §4.6 decides what the import does about the second.
What the model does NOT hold. A loop marker's own XML id has no home
(NewStandardLoop builds over foundation.EmptyBaseElement(),
loop.go:91; NewMultiInstance likewise) — the id joins the ledger and is
not preserved, exactly as a set's id is not (SRD-089.G §4.4). ADR-025
§2.6.1's result strategies are model-absent (no ResultStrategy anywhere)
— nothing to import, and BPMN carries no attribute for them anyway.
inputDataItem/outputDataItem are <dataInput>/<dataOutput> child
elements whose itemSubjectRef the model has no slot for — the MI item
binding is a name, its type comes from the collection's elements at run
time. §4.5 reports the attribute rather than dropping it silently.
Behavior events need definitions-level event definitions. The
noneBehaviorEventRef/oneBehaviorEventRef attributes reference an
<*EventDefinition> declared as a root element — a position the importer
never parses (event definitions parse only inside events, SRD-089.D). The
defBuilders machinery is reusable for it; M4 does. NewImplicitThrowEvent
exists (implicit_throw.go:22) for the complex behavior's event.
§2 Requirements¶
FR-1 — <standardLoopCharacteristics> imports onto every activity kind
the extract lists (all twelve, elements/activities.md): testBefore →
WithTestBefore, loopMaximum → WithLoopMaximum, the loopCondition
child through the expression machinery (language resolution, JUEL
translation, the refusals by name — all SRD-089.B's).
FR-2 — <multiInstanceLoopCharacteristics> imports: isSequential →
WithSequential; loopCardinality as an integer-typed expression →
WithCardinality; loopDataInputRef+inputDataItem →
WithInputCollection and the output pair → WithOutputCollection, the
IDREFs resolved to the names the model wants (§4.4);
completionCondition → WithCompletionCondition.
FR-3 — the behaviors import (M4): behavior → WithBehavior
(default All), noneBehaviorEventRef/oneBehaviorEventRef resolved
through definitions-level event-definition parsing to the
flow.EventDefinition the model options take;
<complexBehaviorDefinition> with both its condition and its implicit
throw event → NewComplexBehaviorDefinition.
FR-4 — the model's stricter-than-grammar guards surface as import
refusals with the file's ids attached (§4.2, §4.3): a condition-less
standard loop, an MI with neither or both cardinality sources, a collection
ref without its item (or the reverse), a non-integer cardinality — each a
named refusal, not a construction error surfacing from deep inside build.
FR-5 — one marker per activity: loopCharacteristics is 0..1 on
Activity (elements/activities.md:29), so a second marker of either kind
refuses citing the cardinality.
FR-6 — the § pins are corrected: standardLoopCharacteristics →
§13.3.6, multiInstanceLoopCharacteristics → §13.3.7, on the extract's own
heading; the rows stay for the contexts that do not claim the elements
(the SRD-089.E lesson).
NFR-1 — no pkg/model change. NFR-2 — diff-coverage ≥95% per
committed milestone. NFR-3 — no fabricated content (no invented condition,
cardinality or item name). NFR-4 — model rules are not copied: type
mismatches and option contradictions the model can voice keep the model's
voice, wrapped with the file's ids; only rules the model states too late
for good feedback get converter wording, each grounded (§4.2, §4.3).
NFR-5 — every § the code emits is one the extract supports.
§3 Models¶
// loopSpec is an activity's loop marker as read — one of the two kinds,
// at most one per activity (FR-5). Which fields mean what follows the
// kind, the dataSpec pattern.
type loopSpec struct {
kind string // tagStandardLoop or tagMultiInstance
id string
// testBefore, isSequential are the two boolean attributes.
testBefore, isSequential bool
// loopMaximum is the attribute's raw text, "" when absent.
loopMaximum string
// condition, cardinality, completion are expression children, in
// the exprSpec shape the language machinery consumes.
condition, cardinality, completion *exprSpec
// inputRef/inputItem, outputRef/outputItem are the two collection
// pairs: the IDREF and the item's name-or-id.
inputRef, inputItem string
outputRef, outputItem string
// behavior and the two event refs; complex holds the parsed
// complexBehaviorDefinition children.
behavior string
noneRef, oneRef string
complex []complexSpec
}
nodeBody gains loop *loopSpec. Definitions-level event definitions
(M4) are parsed into the existing defSpec shape, registered by id in the
catalog beside the messages they resemble.
§4 Analysis & decisions¶
§4.1 The marker rides the node body, like everything that decides construction¶
A loop characteristic reaches the model only as a construction option
(WithLoop), so it parses into nodeBody through nodeChildParsers and
builds in buildNode — the children-before-constructor order every stage
since .C has used. Both tags register in the one table; buildNode
produces the WithLoop option for the kinds the model accepts, and the
model itself refuses the one owner it forbids (the event sub-process,
through Validate, which build already calls — NFR-4).
§4.2 A condition-less standard loop refuses, in the converter's words¶
BPMN makes loopCondition 0..1; the model refuses nil. Passing the nil
through would surface "NewStandardLoop: a nil loopCondition isn't
allowed" — true, and useless to a modeler who doesn't know what
gobpm's constructor is. The converter refuses first, with the § and the
fix: a loop with no condition never decides to stop, the model requires
the condition, write one (or use a Multi-Instance with a cardinality).
This is a documented narrowing of the grammar, not a copy of a model rule
the model would voice comprehensibly (NFR-4's carve-out, the SRD-089.G
§4.3a precedent).
loopMaximum stays the model's: WithLoopMaximum refuses a non-positive
value with a message that names the option and the number — wrapped with
the file's activity id, that is complete feedback, so the converter adds
no wording of its own (NFR-4 proper). A non-integer loopMaximum
attribute is the converter's to refuse (the model never sees the raw
text).
§4.3 The MI cardinality-source matrix¶
Four grammar-legal shapes, one model contract ("exactly one cardinality source"):
loopCardinality |
loopDataInputRef |
Import |
|---|---|---|
| present | absent | WithCardinality (integer-typed expression) |
| absent | present | WithInputCollection (§4.4) |
| present | present | refused — the standard itself says the count is determined "by one of" the two (semantics/multi-instance.md:23-30), and the model enforces it |
| absent | absent | refused — an MI with no instance count has no execution meaning; the refusal says to declare one of the two |
Both refusals are converter-worded (the model's "exactly one cardinality
source is required — WithCardinality or WithInputCollection" names Go
options, not the file's elements) and carry the activity's id. The
half-pair cases — a collection ref without inputDataItem, or the item
without the ref — refuse the same way: the model requires the pair
(multiinstance.go:133-147), and the message names the missing half.
The output pair mirrors it, except that no output pair at all is fine
(collecting results is optional).
§4.4 The IDREF becomes the name the scope resolves¶
loopDataInputRef names an ItemAwareElement by id; the model wants the
scope-datum name (multiinstance.go:72-78). The mapping:
- Resolve the id through the data-element specs, a
<dataObjectReference>retargeting to its object (SAD-001 §14.1 rule 2, the SRD-089.G mechanism reused). - The target must be a data object: the extract's own constraint
("MUST be linked to a process-scope
DataObject",semantics/multi-instance.md:108) — a store reference is not scope-resident and a property is not a collection carrier; both refuse with the constraint cited, wrongKind-style. - Pass the object's import name —
fallbackName(id, name), the same name SRD-063 keys the scope by — toWithInputCollection.
An engine note the constraint's full sentence forces: the extract links
the collection "via DataInputAssociation", but gobpm's mediator reads
the collection by name with no association (ADR-030 v.1 §2.8's
recorded choice, mi.go:209-221). So the import does not require an
accompanying association — a file carrying one wires it normally through
SRD-089.G's pass, and a file without one still runs. The DataObject
half of the constraint is enforced (§4.4 step 2); the association half
is the standard's formality for a mechanism this engine replaced.
inputDataItem is a <dataInput> child; its name-or-id is the item
binding. The output pair mirrors all three steps.
§4.5 What the marker cannot keep is claimed, reported, or both¶
The marker's own id joins the §4.11 ledger and is not preserved — the
model's loop types carry an empty BaseElement, and nothing references a
loop marker's id (the set-id precedent, SRD-089.G §4.4). An
itemSubjectRef on inputDataItem/outputDataItem is reported: the
model's item binding is a name whose type comes from the collection's
elements at run time, so the declared type has no slot — a Dropped
entry, not silence (ADR-024 §2.14). Dialect attributes on the marker ride
the standard funnel.
§4.6 The iterated waiting leaf is the engine's refusal, passed through¶
An imported <receiveTask> (or user task, external service task, catch
event) carrying a loop imports cleanly and fails at
snapshot/registration: "activity %q both iterates and waits … is not
supported yet (#313). Model it as an iterated Sub-Process containing the
wait" (snapshot.go:349-374). The import does not pre-empt it:
- Duplicating the check is a converter-local copy of an engine rule (NFR-4), and the engine's own message already names the issue and the remodeling — nothing a converter wording would add.
- The rule is an engine capability boundary (#313), not a document defect: the day the engine supports it, the same file registers with no converter change — precisely the property ADR-024 §2.13 wants.
The SRD-089.F §4.2 precedent ("a converter whose output fails at registration has reported nothing useful") does not transfer: there the converter chose the failing value and a truthful alternative existed; here the document itself asks for an unsupported execution, and the refusal is loud, early (registration, not run time) and instructive. A test pins the pass-through path so the message's arrival stays part of this stage's contract.
§4.7 Behaviors: the last event-definition position¶
behavior="All" (the default) needs nothing. None/One name an
<*EventDefinition> declared as a root element — the one position
SRD-089.D never parsed, because nothing referenced it until now. M4
registers the definitions-level event-definition tags in
definitionsParsers, parses them into the .D defSpec shape keyed by id,
and resolves the two *Ref attributes through the existing defBuilders
to the flow.EventDefinition the model options take. A ref naming a
definition of the wrong kind, or nothing, gets the converter's reference
refusals. <complexBehaviorDefinition> imports when it carries both its
condition and its <implicitThrowEvent> (the model requires both,
multiinstance.go:41-53); a partial one refuses naming the missing half.
The model's behavior/source consistency rules (validateBehavior) stay
the model's voice, wrapped with the activity's id (NFR-4).
§4.8 Two contracts no expression could satisfy together¶
Added at M3a, when the first imported cardinality reached a runtime.
The e2e run exposed a contradiction older than this stage:
NewMultiInstance requires a declared-int cardinality
(multiinstance.go:283-287), while the lite evaluator — the language
every imported expression carries — unifies every numeric to float64
(ADR-032 v.1 §2.3) and then checkedResult rejects the produced
float64 against the declared int by exact string match. So ANY
declared-int lite expression faulted at evaluation; no BPMN
loopCardinality could ever activate. The engine's own tests never met
it because they mint goexpr expressions, which produce real Go ints.
The runtime's resolveActivation compounded it with a bare .(int)
assertion (mi.go:196) that no lite result could pass either.
Two fixes, both defect-class — they make landed contracts coherent, not
new capability, which is why NFR-1's letter ("no pkg/model change") is
deliberately broken here and said out loud rather than around:
- lite coerces an integral
float64to the declaredint(lite.go, thepackResultfloat64 arm): the declaration asks for an integer VALUE, which an integral float64 is under §2.3's unification; a fractional one keeps the mismatch the check exists for. Pinned byTestDeclaredIntIsSatisfiable. resolveActivationreads what an engine actually returns (mi.gocardinalityCount):int, or an integralfloat64— defense in depth for any future evaluator with lite's unification.
The importer itself is unchanged: newIntExpression was already
minting exactly what the model demanded. The stage's e2e is the
end-to-end regression pin; the lite unit test is the layer-local one.
§4a Worked example¶
<bpmn:definitions xmlns:bpmn="…" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<bpmn:itemDefinition id="idOrders" structureRef="xsd:string" isCollection="true"/>
<bpmn:process id="P" name="P">
<bpmn:dataObject id="do1" name="orders" itemSubjectRef="idOrders"/>
<bpmn:startEvent id="s1"/>
<bpmn:task id="t1" name="Handle">
<bpmn:multiInstanceLoopCharacteristics id="mi1" isSequential="true">
<bpmn:loopDataInputRef>do1</bpmn:loopDataInputRef>
<bpmn:inputDataItem id="item1" name="order"/>
<bpmn:completionCondition>loopCounter >= 2</bpmn:completionCondition>
</bpmn:multiInstanceLoopCharacteristics>
</bpmn:task>
<bpmn:endEvent id="e1"/>
<bpmn:sequenceFlow id="f1" sourceRef="s1" targetRef="t1"/>
<bpmn:sequenceFlow id="f2" sourceRef="t1" targetRef="e1"/>
</bpmn:process>
</bpmn:definitions>
yields: t1.LoopCharacteristics() is a sequential
MultiInstanceLoopCharacteristics with LoopDataInputRef() == "orders"
(the object's name, not do1), InputDataItem() == "order", a
boolean completion condition, behavior All; res.Dropped is empty; and
the engine runs it — each instance sees order and loopCounter in
scope by name (SRD-055), fed from the collection the data object holds.
§5 API deltas¶
None.
§6 Test scenarios¶
| # | Scenario | Asserts |
|---|---|---|
| T-1 | standard loop, condition only | post-tested, unbounded, condition attached |
| T-2 | testBefore="true", loopMaximum="3" |
both survive to the accessors |
| T-3 | condition-less standard loop | refused, converter wording, § cited (§4.2) |
| T-4 | loopMaximum="0" / non-integer text |
the model's message wrapped / the converter's parse refusal |
| T-5 | JUEL loop condition | translated, as a flow condition is |
| T-6 | MI with loopCardinality |
integer-typed expression on the model |
| T-7 | MI with the collection pair | names resolved per §4.4, e2e below |
| T-8 | the §4.3 matrix's two refusal rows | each wording, activity id attached |
| T-9 | half a collection pair (each half) | refused naming the missing half |
| T-10 | loopDataInputRef → a reference |
retargets to the object (rule 2) |
| T-11 | loopDataInputRef → a store ref / property / nothing |
refused citing the extract's DataObject constraint / notFound |
| T-12 | completionCondition |
attached, boolean |
| T-13 | non-boolean condition / non-integer cardinality | the model's type message, id attached |
| T-14 | a second marker on one activity | refused citing 0..1 (FR-5) |
| T-15 | marker on a sub-process and a call activity | imports (the extract lists all twelve owners) |
| T-16 | marker on an event sub-process | the model's own validation refusal surfaces from build |
| T-17 | MI on a <receiveTask> |
imports; RegisterProcess fails with the engine's #313 message (§4.6 pass-through) |
| T-18 | marker ids join the ledger; dialect attrs reported | both funnels |
| T-19 | itemSubjectRef on inputDataItem |
reported (§4.5) |
| T-20 | behavior="None" + a definitions-level <signalEventDefinition> |
resolved to the event definition; wrong-kind and dangling refs refused |
| T-21 | <complexBehaviorDefinition> complete / partial |
imports / refused naming the missing half |
| T-22 | sections rows |
§13.3.6 / §13.3.7 (FR-6) |
| T-23 | e2e: the §4a document on a thresher | sequential MI over the imported collection runs; completion condition stops it early |
§7 Milestones¶
| # | Scope |
|---|---|
| M1 | Parse both kinds into nodeBody; the ledger claims; FR-6's § corrections |
| M2 | Standard loop build: WithLoop, the §4.2 refusals (FR-1, FR-4) |
| M3 | MI core build: sequential, cardinality, the collection pairs with §4.4 resolution, completion condition, the §4.3 matrix (FR-2, FR-4, FR-5) |
| M4 | The behaviors: definitions-level event definitions, both refs, complex (FR-3) |
| M5 | The §4.6 pass-through test, the e2e, the sweeps, §10 |
§8 Cross-doc¶
Outgoing: ADR-024 v.5, ADR-025 v.5.1, ADR-030 v.1, ADR-024 §2.16, SAD-001 v.1.1 (rule 2, via the .G mechanism), siblings SRD-089.D/.G, SRD-090.A, SRD-063, SRD-054, SRD-055 — all up or sideways. No ADR bump: the loop import consumes existing contracts.
§9 Definition of Done¶
- FR-1…FR-6 wired and covered by §6.
make ciPASS on the committed branch, judged by.ci/last-run.json.- Diff-coverage ≥95% per committed milestone (NFR-2).
- No
pkg/modelchange (NFR-1). - §10 filled at landing.
§10 Implementation summary¶
Four milestones landed for the five §7 planned — M3 absorbed the behaviors and the final milestone renumbered, the .D M2/M3 re-split precedent — plus the unplanned M3a.
| M | Commit | What landed |
|---|---|---|
| M1 | 93a5f2e2 |
the parse layer for both kinds, the ledger claims, FR-6's §13.3.6/§13.3.7 corrections |
| M2 | d4d6172e |
the standard loop: WithLoop, the §4.2 refusals, the .B language policy end to end |
| M3 | ad03d93c |
the multi-instance whole, behaviors included: the §4.3 matrix, the §4.4 IDREF→name resolution, definitions-level event definitions for the None/One refs, complex definitions |
| M3a+M4 | 93f94bd4 |
(M3a unplanned) the lite/MI contract contradiction fixed at both layers (§4.8); the runtime proofs — both loop kinds to completion on a thresher, and the §4.6 #313 pass-through pinned |
NFR-1 broken deliberately, once, and §4.8 says why: the M3a fixes
touch pkg/model/expression/lite and internal/instance — defect-class
changes making two landed contracts mutually satisfiable (the model's
declared-int demand vs the evaluator's float64 unification), not new
capability. Every import mapping used only pre-existing constructors, as
planned.
NFR-2 held. The stage's files are fully covered; the branch gate is re-measured at the endgame.
The §-pin correction propagated cleanly (FR-6): the sections table, its pin test, and the two elements' refusal contexts all moved to the extract's own §13.3.6/§13.3.7 in M1, with the wrong §13.3.5 recorded in §1 for the next reader.
The behaviors' fold-in (M3) bought one asset: definitions-level event definitions now parse into a registry — the last event-definition position, reusable by anything that later references a root definition.
Open questions¶
None.