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:
- Purpose — one sentence.
- JSON Schema — link to the typed schema file. UIs can codegen TypeScript types or render forms directly from it.
- Required fields + defaults.
- Inputs and outputs — what the node reads from upstream / request / context / iteration frames; what it emits.
- Errors — high-level categories. The engine emits
decision: "error"with a per-node trace entry on each. - 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.
| Schema | Covers |
|---|---|
rule.schema.json | The whole Rule (top-level — pulls in everything) |
envelope.schema.json | The engine's response shape |
string-filter-config.schema.json | String filter data.config |
number-filter-config.schema.json | Number filter data.config |
date-filter-config.schema.json | Date filter data.config |
mutator-config.schema.json | Mutator (set + lookup-replace) data.config |
calc-config.schema.json | Calc data.config |
iterator-config.schema.json | Iterator data.config |
merge-config.schema.json | Merge data.config |
reference-config.schema.json | Reference (multi-row lookup) data.config |
sub-rule-call.schema.json | data.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.
| Category | When | Treat as |
|---|---|---|
missing-config | A filter / mutator / calc / iterator / merge node has no data.config. | Author error — show "Configure this node". |
legacy-config-shape | Filter 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-error | data.config exists but doesn't match the schema (wrong types, missing required fields). | Author error — link to the schema for the node category. |
missing-source | Sub-rule call has no SubRuleSource wired up; reference node has no ReferenceSetSource. | Deployment error — engine config missing. |
missing-rule | Sub-rule call's ruleId not found in the rule source. | Author or env error — wrong id, or env binding missing. |
missing-reference-set | Mutator / reference node's referenceId doesn't exist. | Author or env error. |
arity-violation | Mutator / calc has zero or multiple upstream outputs (one required); NOT logic node has anything other than one input. | Author error — fix wiring. |
cycle | Rule 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):
- If
output.config.resultis set → that literal, with${ctx.X}placeholders resolved. - If exactly one upstream node activated the output → that node's output, with placeholders resolved.
- If multiple upstream nodes activated → shallow object-merge of their outputs (later edge wins).
- 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.
| Op | Pass when | Notes |
|---|---|---|
and | every input is pass | Any error input → error |
or | ≥1 input is pass | Same error rule |
xor | exactly one input is pass | Same error rule |
not | single input is not pass | Requires 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):
- Upstream object's top-level fields
- Iteration frame variables (
$pax,$paxIndex,$paxCount; nested for outer iterators) - Execution context (
ctx.X) - 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):
$pax— current element$paxIndex— 0-based index$paxCount— total iteration count
(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
}
| Mode | Output | field |
|---|---|---|
collect (default) | Array of upstream outputs in source order | — |
count | Number of iterations whose upstream produced output | — |
sum | Sum of field across iterations | required |
avg | Mean of field (0 on empty) | required |
min / max | Extremum of field | required |
first / last | First / 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.