Chapter 07

Node Reference

The complete contract for every node category. JSON Schemas generated from the live runtime, tax-flavoured examples, error categories, and validation rules — everything a UI authoring tool or downstream consumer needs to produce rules the engine actually accepts.

Overview

RuleForge has eleven node categories. Every node has the same outer shell (id, type, position, data) — see Rule Schema for the wrapper. The differences live in data.config: a category-specific record whose schema is given below.

Every section follows the same template:

  1. Purpose — one sentence.
  2. JSON Schema — link to the typed schema file. UIs can codegen TypeScript types or render forms directly from it.
  3. Required fields + defaults.
  4. Inputs and outputs — what the node reads from upstream / request / context / iteration frames; what it emits.
  5. Errors — high-level categories. The engine emits decision: "error" with a per-node trace entry on each.
  6. Examples — real-world tax / ancillary patterns.

Typed schemas

The eleven JSON Schema files below are generated from the live C# runtime types via System.Text.Json.Schema.JsonSchemaExporter. They are the contract — what the engine accepts, what it rejects.

Dump them straight from the binary — no SDK or external tool needed:

.\ruleforge-cli.exe schemas --out .\schemas
# wrote 11 schemas
./ruleforge-cli schemas --out ./schemas
# wrote 11 schemas

Wire the output into your editor (VS Code's json.schemas, IntelliJ JSON Schema mappings, etc.) and you get autocomplete + validation as you author. See Quickstart / Author your own rule for the full walkthrough, or the CLI Reference for every flag.

SchemaCovers
rule.schema.jsonThe whole Rule (top-level — pulls in everything)
envelope.schema.jsonThe engine's response shape
string-filter-config.schema.jsonString filter data.config
number-filter-config.schema.jsonNumber filter data.config
date-filter-config.schema.jsonDate filter data.config
mutator-config.schema.jsonMutator (set + lookup-replace) data.config
calc-config.schema.jsonCalc data.config
iterator-config.schema.jsonIterator data.config
merge-config.schema.jsonMerge data.config
reference-config.schema.jsonReference (multi-row lookup) data.config
sub-rule-call.schema.jsondata.subRuleCall (any node can carry it)

Error categories

When a node fails, the engine emits an envelope with decision: "error" and one trace entry per failed node carrying a category-stable error message. The eight categories below are stable across releases. UIs should switch on these to distinguish bugs ("engine should have handled this") from "rule isn't authored quite right" — the latter become editor lints.

CategoryWhenTreat as
missing-configA filter / mutator / calc / iterator / merge node has no data.config.Author error — show "Configure this node".
legacy-config-shapeFilter config uses the old flat {path, operator, …} form instead of the structured {source, compare, arraySelector, onMissing}.Author error — show "Re-save in editor; legacy shape rejected".
config-parse-errordata.config exists but doesn't match the schema (wrong types, missing required fields).Author error — link to the schema for the node category.
missing-sourceSub-rule call has no SubRuleSource wired up; reference node has no ReferenceSetSource.Deployment error — engine config missing.
missing-ruleSub-rule call's ruleId not found in the rule source.Author or env error — wrong id, or env binding missing.
missing-reference-setMutator / reference node's referenceId doesn't exist.Author or env error.
arity-violationMutator / calc has zero or multiple upstream outputs (one required); NOT logic node has anything other than one input.Author error — fix wiring.
cycleRule graph has a directed cycle (caught at validate time, before evaluation).Author error — admin app should also catch at publish.

Anything else that bubbles up is a genuine bug — please file on the engine repo.


input

Purpose: the entry point. Exactly one per rule, validated at boot.

Config: none.

Output: the request body, untouched. Downstream nodes can read $.X against the request as long as they have an edge from input (or transitively) without an iterator pushing a frame in between.

Errors: none specific to input.

output

Purpose: the exit. Exactly one per rule. Assembles envelope.result.

Config: optional.

{
  // Optional literal — used as-is, with ${ctx.X} placeholders resolved
  "result": { /* any JSON */ }
}

Resolution order (first match wins):

  1. If output.config.result is set → that literal, with ${ctx.X} placeholders resolved.
  2. If exactly one upstream node activated the output → that node's output, with placeholders resolved.
  3. If multiple upstream nodes activated → shallow object-merge of their outputs (later edge wins).
  4. If output never activated → decision: "skip", result: null.

filter (string)

Purpose: string predicate against a JSONPath-resolved value or array.

Schema: string-filter-config.schema.json

Required fields: source, compare, arraySelector, onMissing.

{
  "source": { "kind": "request", "path": "$.pax[*].type" },
  "compare": {
    "operator": "in",
    "values": ["ADT", "CHD"],
    "caseInsensitive": true
  },
  "arraySelector": "any",
  "onMissing":     "fail"
}

Operators: equals · not_equals · starts_with · ends_with · contains · not_contains · in · not_in · regex · is_null · is_empty. Regex pattern lives in compare.value, not a separate pattern field.

Output: none — emits a verdict (pass / fail / skip / error) that drives edge routing. See Evaluators for the reduction matrix.

Errors: missing-config, legacy-config-shape, config-parse-error.

Tax example — "any pax of unaccompanied-minor type":

{
  "source": { "kind": "request", "path": "$.pax[*].type" },
  "compare": { "operator": "equals", "value": "UMR" },
  "arraySelector": "any",
  "onMissing":     "fail"
}

filter (number)

Purpose: numeric predicate. Coerces JSON strings, numbers, and booleans into doubles.

Schema: number-filter-config.schema.json

{
  "source":  { "kind": "request", "path": "$.fareUSD" },
  "compare": {
    "operator": "between",
    "min": 200, "max": 1000,
    "minInclusive": true,
    "maxInclusive": false
  },
  "arraySelector": "first",
  "onMissing":     "fail"
}

Operators: equals · not_equals · gt · gte · lt · lte · between · not_between · in · not_in · is_null.

Optional rounding: compare.round = floor | ceil | round applied before comparison.

filter (date)

Purpose: date / datetime / time predicate.

Schema: date-filter-config.schema.json

{
  "source":  { "kind": "request", "path": "$.depDate" },
  "compare": {
    "operator": "within_next",
    "amount": 14, "unit": "days",
    "granularity": "datetime",
    "timezone": "Asia/Dubai"
  },
  "arraySelector": "first",
  "onMissing":     "fail"
}

Operators: equals · not_equals · before · after · between · not_between · within_last · within_next · is_null.

Granularity: datetime | date | time — see Evaluators / Date filter for the timezone + comparable-scalar rules.

logic

Purpose: aggregate verdicts from upstream nodes.

Config: none — operator is implied by data.templateId (one of sys-and, sys-or, sys-xor, sys-not) or data.label.

Inputs: verdicts of upstream nodes (deduped by source nodeId — multiple edges from same source counted once).

Output: none. Emits a verdict.

OpPass whenNotes
andevery input is passAny error input → error
or≥1 input is passSame error rule
xorexactly one input is passSame error rule
notsingle input is not passRequires exactly one input; throws arity-violation otherwise.

mutator

Purpose: override one field on the upstream object's output. Two flavors share one config.

Schema: mutator-config.schema.json

Required: target. Exactly one of value / from / lookup.

Set property — literal value

{ "target": "surcharge", "value": 15 }

Set property — JSONPath source

{ "target": "paxId", "from": "$pax.id" }

Lookup-and-replace from a reference set

{
  "target": "amount",
  "lookup": {
    "referenceId": "ref-tax-rates",
    "valueColumn": "amount",
    "matchOn": {
      "origin":      "$.orig",
      "ageCategory": "$pax.ageCategory",
      "code":        "$.taxCode"
    }
  },
  "onMissing": "leave"     // leave | clear | error
}

Inputs: exactly one upstream object output. Zero upstream → mutator starts from {}; multiple → arity-violation error.

Output: the upstream object with target replaced.

Errors: missing-config, config-parse-error, arity-violation, missing-reference-set.

calc

Purpose: arithmetic, comparison, and conditional expressions via NCalc.

Schema: calc-config.schema.json

{
  "target":     "fee",
  "expression": "fee * (1 + markup)"
}

Variable namespace (highest-wins on collision):

  1. Upstream object's top-level fields
  2. Iteration frame variables ($pax, $paxIndex, $paxCount; nested for outer iterators)
  3. Execution context (ctx.X)
  4. Request top-level fields

Output: If target is set → upstream object with that field replaced. If unset → the bare computed scalar.

Tax example — US 7.5% federal excise on (fare + surcharges):

{ "target": "federalTax", "expression": "(fare + surcharges) * 0.075" }

Operators / functions: arithmetic + - * / % **, comparison = != < <= > >=, boolean and or not, conditionals if(c, a, b), math (Min, Max, Abs, Round, Floor, Ceiling, Sqrt), aggregates over arrays (Sum, Count, Avg).

iterator

Purpose: fan out the downstream sub-graph once per element of an array.

Schema: iterator-config.schema.json

{
  "source": "$.pax",
  "as":     "pax"
}

Required: source, as. Both strings.

Behavior: resolves source via JSONPath against current frames. For each element e at index i, pushes a frame named as onto the runner's stack with {Item: e, Index: i, Count: N} and walks downstream once. After all iterations, the closing merge or output collects the per-iteration outputs.

Iteration variables (the new JSONPath roots inside scope):

(Substitute the iterator's as name for pax as needed.)

Empty source: the closing merge fires once with empty input — emits [] for collect, 0 for count/sum/avg, null for first/last. Decision stays apply.

Nesting: arbitrary depth supported (frame stack). Use distinct as names per level.

merge

Purpose: close the innermost iteration scope, reduce per-iteration outputs to one.

Schema: merge-config.schema.json

{
  "mode":  "sum",        // collect | count | sum | avg | min | max | first | last
  "field": "$.amount"   // JSONPath rooted at each upstream output; required for sum/avg/min/max
}
ModeOutputfield
collect (default)Array of upstream outputs in source order
countNumber of iterations whose upstream produced output
sumSum of field across iterationsrequired
avgMean of field (0 on empty)required
min / maxExtremum of fieldrequired
first / lastFirst / last upstream output object

Tax example — total tax across all pax:

iterator($.pax) → ... → product (emit { amount, ... }) → merge (mode: sum, field: $.amount)

reference

Purpose: multi-row reference-set lookup. Returns every matching row as an array.

Schema: reference-config.schema.json

{
  "referenceId": "ref-tax-rates",
  "matchOn": { "origin": "$.orig" }
}

Output: array of matching rows (each row a JSON object with the ref-set's columns as keys). Empty array if no matches.

Errors: missing-source, missing-reference-set.

Tax example — every applicable rate for departures from LHR:

{ "referenceId": "ref-tax-rates", "matchOn": { "origin": "$.orig" } }
// → [ { code: "GB1", ageCategory: "ADT", amount: 26, ... }, … six rows ]

constant

Purpose: emit a literal value as the node's output. Useful for seeding mutator chains and for fixed product templates.

Config:

{ "value": /* any JSON */ }

Output: data.config.value verbatim.

Tax example — seed a tax-line shell that mutators then fill in:

{ "value": { "code": "GB1", "amount": 0, "currency": "GBP" } }

product

Purpose: emit a structured product/result object. Two config shapes accepted; ${ctx.X} placeholders are recursively resolved against the run's execution context.

// Object literal
{
  "output": {
    "code": "BAG",
    "pieces": "${ctx.tierUplift}",
    "weightKg": 23
  }
}

// outputSchema (template-style — also accepted)
{
  "outputSchema": [
    { "key": "code",    "value": "BAG" },
    { "key": "weightKg","value": 23 }
  ]
}

Output: the resolved object. Emit-time placeholders surface unresolved (literal string) when the path doesn't resolve — useful as an authoring hint.

ruleRef & subRuleCall

Purpose: invoke another rule, optionally fanning out per element of a source array.

Schema: sub-rule-call.schema.json

subRuleCall can ride on any node's data — but the dedicated ruleRef category is the conventional home. The full reference page on this is Sub-Rules; the schema is the contract.

{
  "ruleId":        "rule-pax-tax",
  "pinnedVersion": 1,
  "forEach":       "$.pax",         // optional — fan-out mode when set
  "as":            "pax",             // required when forEach is set
  "inputMapping":  { "pax": "$pax", "orig": "$.orig" },
  "outputMapping": { "paxId": "$pax.id", "taxes": "result.taxes" },
  "onError":       "default",           // skip | fail | default
  "defaultValue":  { "taxes": [] }
}

Errors: missing-source, missing-rule, plus whatever the sub-rule itself emits when onError: "fail".


Worked end-to-end example

The shipped tax fixture rule-pnr-taxes.v1.json uses six of these node categories together. Walk it as a reference for how the contracts compose:

input
 → iterator("$.pax", as: "pax")         // fan out per pax
 → constant({ "code": "GB1", ... })       // seed a shell
 → mutator(set "paxId" from $pax.id)    // stamp the iteration variable
 → mutator(lookup "amount" from ref)    // pull rate from price-table
 → merge(mode: "collect")               // flatten back to one array
 → output                                // envelope.result = the array

For a tax UI: the JSON Schema files above are the input forms; the trace categories above are the error states; this fixture is the canonical end-to-end shape.