The shape
An iterator node fans out the downstream sub-graph once per element of a JSONPath-resolved array. A merge node closes the innermost open iteration scope and reduces every iteration's upstream output via its mode.
// iterator config { "category": "iterator", "config": { "source": "$.pax", "as": "pax" } } // merge config { "category": "merge", "config": { "mode": "collect" } }
The simplest end-to-end shape — the per-passenger tax engine that ships in fixtures/rules/rule-pnr-taxes.v1.json:
input
→ iterator($.pax, as: pax)
→ constant({ code: "GB1" })
→ mutator (set paxId from $pax.id)
→ mutator (lookup amount from ref-tax-rates by origin + ageCategory + code)
→ merge (mode: collect)
→ output
Result for two pax (one ADT, one CHD departing LHR):
{
"decision": "apply",
"result": [
{ "code": "GB1", "amount": 26, "currency": "GBP", "paxId": "p1" },
{ "code": "GB1", "amount": 13, "currency": "GBP", "paxId": "p2" }
]
}
Iteration variables
Inside an iteration scope, three new JSONPath roots become available, derived from the iterator's as name:
| Path | Resolves to |
|---|---|
$pax | The current element of the source array |
$paxIndex | 0-based index in this iteration |
$paxCount | Total element count of the source array |
$.X | The original request, never shadowed |
$ctx.X | The execution context — also unaffected |
Variables are resolved innermost-first, so an inner iterator with as: pax shadows an outer one with the same name. Use distinct names (journey, segment, pax) when nesting.
Merge modes
The merge node's mode controls how upstream outputs are reduced. collect is the default — array of every iteration's upstream output, in source order.
| Mode | Output | Field required? |
|---|---|---|
collect | Array of upstream outputs (default) | — |
count | Number of iterations whose upstream produced output | — |
sum | Sum of field across iterations | yes |
avg | Mean of field (0 on empty) | yes |
min / max | Extremum of field | yes |
first / last | First / last upstream output object | — |
The field is a JSONPath rooted at each upstream output. $.amount reads amount from each emitted object; $ uses the upstream output itself when it's a number.
Nested iterators
Iterators can be nested freely. The runner threads a frame stack through every node; merges pop one level. The fixture rule-seat-assignments.v1.json uses three:
input
→ iterator($.journeys, as: journey)
→ iterator($journey.segments, as: segment)
→ iterator($.pax, as: pax)
→ constant ({ seat: "auto", class: "" })
→ mutator (set journeyId from $journey.id)
→ mutator (set segmentId from $segment.id)
→ mutator (set paxId from $pax.id)
→ mutator (lookup class name from ref-cabin-class by $segment.cabin)
→ merge (collect) // per-segment array of pax results
→ merge (collect) // per-journey array of segment-arrays
→ merge (collect) // outer journey-arrays
→ output
For 2 journeys with (2, 1) segments and 2 pax, the result is a 2-deep nested array, e.g.:
[ [/* j1 */ [/* s1 */ {"paxId": "p1", "class": "Economy", …}, {"paxId": "p2", …} ], [/* s2 */ {"paxId": "p1", "class": "Economy", …}, {"paxId": "p2", …} ] ], [/* j2 */ [/* s3 */ {"paxId": "p1", "class": "Business", …}, {"paxId": "p2", …} ] ] ]
To get a flat array instead, drop the inner merges. The output node implicitly collects every leaf emission into one array.
Empty sources
An iterator over an empty array produces no body iterations. The closing merge still fires and emits an empty array (collect), zero (count / sum / avg), or null (first / last). The envelope decision stays apply — empty isn't a failure.
Sub-rule fan-out — the alternative
If your iteration body is large enough to deserve its own versioned rule, use subRuleCall.forEach instead. The host node invokes the sub-rule once per element and accumulates the mapped results into an array.
{
"category": "ruleRef",
"data": {
"subRuleCall": {
"ruleId": "rule-pax-tax",
"pinnedVersion": 1,
"forEach": "$.pax",
"as": "pax",
"inputMapping": { "pax": "$pax", "orig": "$.orig" },
"outputMapping": { "paxId": "$pax.id", "taxes": "result.taxes" },
"onError": "default",
"defaultValue": { "taxes": [] }
}
}
}
Same effect (an array of mapped objects) but the body lives in another rule that's separately versioned and tested. Use the in-graph iterator for ad-hoc cases; use sub-rule fan-out when the body is shared across rules.
Patterns
Count pax matching a predicate
iterator($.pax) → filter ($pax.tier IN [GOLD, PLAT])
→ constant({})
→ merge (mode: count) → output
Iterations whose filter fails terminate (no matching edge from fail); the merge counts only the survivors. Result is the count of qualifying pax — no JSONPath predicates required.
Sum a field across pax
iterator($.pax) → calc (no target, expression: $pax.fareUSD)
→ merge (mode: sum, field: $) → output
Each iteration's calc emits the bare scalar; merge sums them.
Lookup all matching reference rows
reference (ref-lounges, matchOn: { airport: $.orig })
→ product (output: { availableLoungePasses: ${input} })
→ output
The reference node returns every matching row as an array — no iteration required. (See Evaluators / Mutator for the older single-row lookup variant.)