Chapter 05

Iteration

For each pax, for each segment, for each journey — without writing a loop. Two graph nodes (iterator and merge) plus a frame stack the engine threads through every node downstream.

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:

PathResolves to
$paxThe current element of the source array
$paxIndex0-based index in this iteration
$paxCountTotal element count of the source array
$.XThe original request, never shadowed
$ctx.XThe 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.

ModeOutputField required?
collectArray of upstream outputs (default)
countNumber of iterations whose upstream produced output
sumSum of field across iterationsyes
avgMean of field (0 on empty)yes
min / maxExtremum of fieldyes
first / lastFirst / 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.)