Structure
Structural elements group flow nodes into reusable scopes, invoke other processes, and iterate over collections.
| Element | When to use it |
|---|---|
| Embedded sub-process | Group steps into an inline scope with isolated variables |
| Event sub-process | React to events inside a scope without disrupting the main flow |
| Ad-hoc sub-process | Activate a dynamic set of activities chosen at runtime |
| Call activity | Invoke another, separately-defined process as a child |
| Multi-instance loop | Run an activity once per item in a collection |
| Standard loop | Repeat an activity while a condition holds |
Embedded sub-process
An inline scope. The sub-process has its own start and end events and contains any flow elements, including nested sub-processes.
Attributes
| Attribute | Default | Notes |
|---|---|---|
id | — | Required |
name | — | Optional |
triggeredByEvent | false | true makes this an event sub-process |
default | — | ID of the outgoing flow taken when every other outgoing condition is false. See Sequence Flow → Default flows |
Child loop elements
| Element | Effect |
|---|---|
<multiInstanceLoopCharacteristics> | Run the body once per item in a collection (or loopCardinality times) — see Multi-instance loop |
<standardLoopCharacteristics> | Re-run the entire body in series until loopMaximum is hit or loopCondition becomes false — see Standard loop |
The two are mutually exclusive on the same activity.
Scope behavior
- The sub-process scope has isolated variables. I/O mappings on the sub-process control what crosses the boundary.
- Boundary events on the sub-process element interrupt or extend the entire sub-process — see Boundary events.
Example
<bpmn:subProcess id="payment-sub" name="Process Payment">
<bpmn:extensionElements>
<quantum:ioMapping>
<quantum:input source="=orderId" target="order_id" />
<quantum:output source="=paymentStatus" target="status" />
</quantum:ioMapping>
</bpmn:extensionElements>
<bpmn:incoming>f1</bpmn:incoming>
<bpmn:outgoing>f2</bpmn:outgoing>
<bpmn:startEvent id="sub-start"><bpmn:outgoing>sub-f1</bpmn:outgoing></bpmn:startEvent>
<bpmn:serviceTask id="charge" name="Charge Card">
<bpmn:extensionElements>
<quantum:taskDefinition type="payment-worker" />
</bpmn:extensionElements>
<bpmn:incoming>sub-f1</bpmn:incoming>
<bpmn:outgoing>sub-f2</bpmn:outgoing>
</bpmn:serviceTask>
<bpmn:endEvent id="sub-end"><bpmn:incoming>sub-f2</bpmn:incoming></bpmn:endEvent>
<bpmn:sequenceFlow id="sub-f1" sourceRef="sub-start" targetRef="charge" />
<bpmn:sequenceFlow id="sub-f2" sourceRef="charge" targetRef="sub-end" />
</bpmn:subProcess>
Validation
| Check | Severity |
|---|---|
| Sub-process contains no start event | Error |
| Sub-process contains no end event | Warning |
Event sub-process
A <subProcess triggeredByEvent="true"> lives inside a parent scope but isn't connected by sequence flows. It's started by its own typed start event when a matching event happens in the parent scope.
Differences from a normal sub-process
| Property | Normal | Event sub-process |
|---|---|---|
triggeredByEvent | false | true |
| Sequence flow connection | Has incoming/outgoing flows | None — triggered by its start event |
| Start event type | None | Typed (message, timer, signal, error, escalation, conditional, compensation) |
Start event isInterrupting | N/A | true cancels the parent; false runs in parallel |
Examples
<!-- Interrupting message-triggered event sub-process -->
<bpmn:subProcess id="esp-cancel" triggeredByEvent="true">
<bpmn:startEvent id="esp-start" name="Order Cancelled" isInterrupting="true">
<bpmn:outgoing>esp-f1</bpmn:outgoing>
<bpmn:messageEventDefinition messageRef="Msg_Cancel" />
</bpmn:startEvent>
<bpmn:serviceTask id="esp-cleanup" name="Cleanup Order">
<bpmn:extensionElements>
<quantum:taskDefinition type="cleanup-worker" />
</bpmn:extensionElements>
<bpmn:incoming>esp-f1</bpmn:incoming>
<bpmn:outgoing>esp-f2</bpmn:outgoing>
</bpmn:serviceTask>
<bpmn:endEvent id="esp-end"><bpmn:incoming>esp-f2</bpmn:incoming></bpmn:endEvent>
<bpmn:sequenceFlow id="esp-f1" sourceRef="esp-start" targetRef="esp-cleanup" />
<bpmn:sequenceFlow id="esp-f2" sourceRef="esp-cleanup" targetRef="esp-end" />
</bpmn:subProcess>
<!-- Non-interrupting conditional event sub-process: fires on each false→true transition -->
<bpmn:subProcess id="esp-watch" triggeredByEvent="true">
<bpmn:startEvent id="esp-cond-start" isInterrupting="false">
<bpmn:outgoing>esp-c1</bpmn:outgoing>
<bpmn:conditionalEventDefinition>
<bpmn:condition xsi:type="bpmn:tFormalExpression">priority = "high"</bpmn:condition>
</bpmn:conditionalEventDefinition>
</bpmn:startEvent>
<!-- ... -->
</bpmn:subProcess>
Validation
| Check | Severity |
|---|---|
| Event sub-process has incoming or outgoing sequence flows | Error |
| Event sub-process has more or fewer than one typed start event | Error |
Conditional start event has empty condition | Error |
| Error / escalation / compensation start event placed outside an event sub-process (BPMN 2.0 §10.4.2) | Error |
Ad-hoc sub-process
An ad-hoc sub-process contains activities that are activated dynamically — not via fixed sequence flows. Which activities run, and when the sub-process completes, are controlled by FEEL expressions.
Attributes and extensions
| Attribute / Extension | Notes |
|---|---|
default (attribute) | ID of the outgoing flow taken when every other outgoing condition is false. See Sequence Flow → Default flows |
quantum:adHoc activeElementsCollection="..." | FEEL expression returning the list of activity IDs to activate |
<completionCondition> child | FEEL expression evaluated after each activity completes; the sub-process ends when it returns true |
<multiInstanceLoopCharacteristics> | Run the entire ad-hoc once per item in a collection |
<standardLoopCharacteristics> | Re-run the entire ad-hoc until loopMaximum / loopCondition stop. Mutually exclusive with multi-instance |
Each activity inside an ad-hoc sub-process must carry a quantum:taskDefinition (or be one of the supported task types).
Behavior with end events
End events behave differently inside an ad-hoc sub-process:
| End event variant | Effect |
|---|---|
| None / message / signal / escalation | Consumes the token; the ad-hoc keeps running until completionCondition is satisfied |
| Terminate | Ends the ad-hoc scope immediately, cancelling all sibling tokens; the parent process continues from the ad-hoc's outgoing flow |
Example
<bpmn:adHocSubProcess id="ad-hoc-tasks" name="Dynamic Tasks">
<bpmn:extensionElements>
<quantum:adHoc activeElementsCollection="=["task-review", "task-approve"]" />
<quantum:ioMapping>
<quantum:output source="=allCompleted" target="allDone" />
</quantum:ioMapping>
</bpmn:extensionElements>
<bpmn:incoming>f1</bpmn:incoming>
<bpmn:outgoing>f2</bpmn:outgoing>
<bpmn:serviceTask id="task-review" name="Review">
<bpmn:extensionElements>
<quantum:taskDefinition type="review-worker" />
</bpmn:extensionElements>
</bpmn:serviceTask>
<bpmn:serviceTask id="task-approve" name="Approve">
<bpmn:extensionElements>
<quantum:taskDefinition type="approve-worker" />
</bpmn:extensionElements>
</bpmn:serviceTask>
<bpmn:completionCondition xsi:type="bpmn:tFormalExpression">=reviewDone and approveDone</bpmn:completionCondition>
</bpmn:adHocSubProcess>
Validation
| Check | Severity |
|---|---|
| Ad-hoc sub-process contains no activities | Error |
| Ad-hoc sub-process contains a start event | Error |
Call activity
Invokes a separately-defined process as a child. The child runs in its own scope; the parent waits until the child completes.
Attributes and extensions
| Attribute / Extension | Required | Notes |
|---|---|---|
calledElement (attribute) | — | Standard BPMN attribute; ID of the called process. Overridden by the extension when both are present |
default (attribute) | no | ID of the outgoing flow taken when every other outgoing condition is false. See Sequence Flow → Default flows |
quantum:calledElement processId="..." | yes | ID of the process to invoke |
quantum:calledElement propagateAllChildVariables="..." | no | true merges all child output variables into the parent scope. Default is false |
quantum:ioMapping | no | Inputs are passed to the child; outputs are pulled back |
multiInstanceLoopCharacteristics | no | Run the called process once per item — see Multi-instance loop |
standardLoopCharacteristics | no | Re-call the child process serially up to loopMaximum times. Mutually exclusive with multi-instance — see Standard loop |
Variable propagation
| Mode | What flows back |
|---|---|
propagateAllChildVariables="false" (default) | Only variables explicitly mapped via output mappings |
propagateAllChildVariables="true" | All child variables are merged into the parent scope on completion |
Boundary events on the call activity are supported. An interrupting boundary cancels the child workflow.
Example
<bpmn:callActivity id="run-sub-process" name="Run Sub-Process">
<bpmn:extensionElements>
<quantum:calledElement processId="child-process-id" propagateAllChildVariables="false" />
<quantum:ioMapping>
<quantum:input source="=customerId" target="customer_id" />
<quantum:output source="=result" target="subProcessResult" />
</quantum:ioMapping>
</bpmn:extensionElements>
<bpmn:incoming>f1</bpmn:incoming>
<bpmn:outgoing>f2</bpmn:outgoing>
</bpmn:callActivity>
Validation
| Check | Severity |
|---|---|
processId and calledElement are both empty | Error |
Multi-instance loop
Attached as <multiInstanceLoopCharacteristics> to a task, sub-process, or call activity. Runs the host activity multiple times — in parallel or sequentially.
Attributes and child elements
| Attribute / Element | Notes |
|---|---|
isSequential (attribute) | true runs iterations one after another; false (default) runs them in parallel |
<loopCardinality> child | FEEL expression returning the number of iterations (e.g. "5", "=items.size()") |
<completionCondition> child | FEEL expression; the loop exits early when it returns true |
The quantum:loopCharacteristics extension holds collection-based iteration:
| Attribute | Notes |
|---|---|
inputCollection | FEEL expression returning the collection to iterate over (e.g. "=orderItems") |
inputElement | Variable name for the current item in each iteration (e.g. "item") |
outputCollection | Variable name to collect per-iteration results into |
outputElement | FEEL expression extracting the result from each iteration scope (e.g. "=result") |
<loopCardinality> and inputCollection are mutually exclusive — pick one. outputCollection / outputElement only make sense with inputCollection.
Variables visible inside the loop
The engine injects spec-standard counters that any FEEL expression inside the activity can read:
| Variable | Scope | Description |
|---|---|---|
numberOfInstances | Outer | Total inner instances created |
numberOfActiveInstances | Outer | Currently executing instances (always 0 or 1 for sequential) |
numberOfCompletedInstances | Outer | Instances completed normally so far |
numberOfTerminatedInstances | Outer | Instances cancelled or interrupted |
loopCounter | Per instance | 1-based sequence number of the current instance |
A completionCondition can use any of these to exit early:
=numberOfCompletedInstances > 0 // exit after the first finishes
=numberOfCompletedInstances >= 2 // exit once at least two have finished
=loopCounter = 1 and numberOfInstances > 3 // exit after instance 1 in a large batch
Variable visibility after the loop completes
The injected counters and loopCounter live on the multi-instance scopes — they are never propagated into the activity's outer scope when the loop finishes. The only thing that flows out is the configured outputCollection.
If you need a count after the activity, derive it from data that already propagated:
| Want | FEEL on the next activity |
|---|---|
| Total instances run | =length(inputCollection) (your original input collection) |
| Instances that completed normally | =length(outputCollection) |
Instances cancelled by completionCondition | =length(inputCollection) - length(outputCollection) |
Boundary events on a multi-instance activity
A boundary event attached to an MI activity sees the counters via the normal scope chain — at the moment the boundary fires, the firing instance's parent is the MI wrapper, so the boundary handler's input/output FEEL can read numberOfCompletedInstances, numberOfActiveInstances, loopCounter, etc. directly. Once the MI scope cleans up these values are gone, so capture anything you need into the parent scope via the boundary handler's <output> mapping.
<bpmn:boundaryEvent id="mi-cancel" attachedToRef="process-items">
<bpmn:extensionElements>
<quantum:ioMapping>
<quantum:output source="=numberOfCompletedInstances" target="completedBeforeCancel" />
<quantum:output source="=numberOfTerminatedInstances" target="cancelledCount" />
</quantum:ioMapping>
</bpmn:extensionElements>
<bpmn:errorEventDefinition errorRef="ItemErr" />
</bpmn:boundaryEvent>
Examples
<!-- Parallel multi-instance service task iterating a collection -->
<bpmn:serviceTask id="process-items" name="Process Each Item">
<bpmn:extensionElements>
<quantum:taskDefinition type="item-processor" />
<quantum:ioMapping>
<quantum:output source="=itemResult" target="itemResult" />
</quantum:ioMapping>
</bpmn:extensionElements>
<bpmn:incoming>f1</bpmn:incoming>
<bpmn:outgoing>f2</bpmn:outgoing>
<bpmn:multiInstanceLoopCharacteristics>
<bpmn:extensionElements>
<quantum:loopCharacteristics
inputCollection="=orderItems"
inputElement="item"
outputCollection="processedItems"
outputElement="=itemResult" />
</bpmn:extensionElements>
</bpmn:multiInstanceLoopCharacteristics>
</bpmn:serviceTask>
<!-- Sequential multi-instance call activity -->
<bpmn:callActivity id="call-each" name="Call Sub Per Item">
<bpmn:extensionElements>
<quantum:calledElement processId="item-sub-process" propagateAllChildVariables="false" />
<quantum:ioMapping>
<quantum:input source="=item" target="currentItem" />
<quantum:output source="=subResult" target="subResult" />
</quantum:ioMapping>
</bpmn:extensionElements>
<bpmn:incoming>f1</bpmn:incoming>
<bpmn:outgoing>f2</bpmn:outgoing>
<bpmn:multiInstanceLoopCharacteristics isSequential="true">
<bpmn:extensionElements>
<quantum:loopCharacteristics
inputCollection="=batches"
inputElement="item"
outputCollection="results"
outputElement="=subResult" />
</bpmn:extensionElements>
</bpmn:multiInstanceLoopCharacteristics>
</bpmn:callActivity>
Validation
| Check | Severity |
|---|---|
Both loopCardinality and inputCollection are set | Error |
multiInstanceLoopCharacteristics and standardLoopCharacteristics are both set on the same activity | Error |
Standard loop
A standard loop re-executes the same activity sequentially while a FEEL condition holds, capped by a maximum iteration count. Supported on:
- Tasks (service / user / script / business-rule / send / receive / manual / generic)
- Embedded sub-processes — the entire body re-runs each iteration in a fresh scope
- Ad-hoc sub-processes
- Call activities — each iteration starts a fresh child workflow
Attributes and child elements
| Attribute / Element | Notes |
|---|---|
loopMaximum (attribute) | Required. Maximum number of iterations (≥ 1). The loop ends when loopCounter reaches this value, regardless of the condition |
testBefore (attribute) | If true, the condition is checked before the first iteration — the activity is skipped entirely if the condition is false at that point. Default false |
<loopCondition> child | Optional FEEL expression. The loop continues as long as it returns true. Without it, the activity runs exactly loopMaximum times |
Each iteration sets a loopCounter variable in the activity's surrounding scope (1-based). For sub-processes, the body re-enters in a freshly minted scope each iteration; for call activities, a fresh child workflow is started each time.
Behavior
| Setting | Behavior |
|---|---|
testBefore="false" (default) | Run at least once; check the condition after each completion |
testBefore="true" | Check first; skip entirely if the condition is false |
loopMaximum="N" | Hard cap — after N executions the loop exits |
No loopCondition | Run exactly loopMaximum times |
Example
<!-- Run while counter < 5, capped at 10 iterations -->
<bpmn:serviceTask id="retry-task" name="Retry Until Done">
<bpmn:extensionElements>
<quantum:taskDefinition type="retry-processor" />
<quantum:ioMapping>
<quantum:output source="=counter" target="counter" />
</quantum:ioMapping>
</bpmn:extensionElements>
<bpmn:incoming>f1</bpmn:incoming>
<bpmn:outgoing>f2</bpmn:outgoing>
<bpmn:standardLoopCharacteristics loopMaximum="10">
<bpmn:loopCondition>=counter < 5</bpmn:loopCondition>
</bpmn:standardLoopCharacteristics>
</bpmn:serviceTask>
Validation
| Check | Severity |
|---|---|
loopMaximum is missing or ≤ 0 | Error |
Both standardLoopCharacteristics and multiInstanceLoopCharacteristics are set | Error |
When to use which
Use a standard loop when iterations are sequential and condition-driven, and the count isn't known up front. Use a multi-instance loop when you need parallel execution or are iterating a known collection.