Skip to content

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:

  1. NewStandardLoop refuses a nil loopCondition (loop.go:74-78), while the extract makes it 0..1 (elements/activities.md:568-579). §4.2 decides the refusal.
  2. NewMultiInstance demands exactly one cardinality source — WithCardinality or WithInputCollection, "not both" (multiinstance.go:272-287), and NEITHER trips the same guard. The extract makes both 0..1. §4.3 decides both directions.
  3. WithInputCollection/WithOutputCollection require both the ref and the item name (multiinstance.go:133-166); the extract makes loopDataInputRef and inputDataItem independently 0..1.
  4. loopCardinality must be an integer expression and loopCondition/completionCondition boolean ones (multiinstance.go:283-295, loop.go:80-85) — result types the expression machinery already mints (lite.Cond declares 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): testBeforeWithTestBefore, loopMaximumWithLoopMaximum, the loopCondition child through the expression machinery (language resolution, JUEL translation, the refusals by name — all SRD-089.B's).

FR-2 — <multiInstanceLoopCharacteristics> imports: isSequentialWithSequential; loopCardinality as an integer-typed expression → WithCardinality; loopDataInputRef+inputDataItemWithInputCollection and the output pair → WithOutputCollection, the IDREFs resolved to the names the model wants (§4.4); completionConditionWithCompletionCondition.

FR-3 — the behaviors import (M4): behaviorWithBehavior (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:

  1. 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).
  2. 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.
  3. Pass the object's import name — fallbackName(id, name), the same name SRD-063 keys the scope by — to WithInputCollection.

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:

  1. lite coerces an integral float64 to the declared int (lite.go, the packResult float64 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 by TestDeclaredIntIsSatisfiable.
  2. resolveActivation reads what an engine actually returns (mi.go cardinalityCount): int, or an integral float64 — 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 &gt;= 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 ci PASS on the committed branch, judged by .ci/last-run.json.
  • Diff-coverage ≥95% per committed milestone (NFR-2).
  • No pkg/model change (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.