String filter
Operates on text. Coerces JSON strings, numbers and booleans into strings; objects and arrays are treated as missing.
Operators
| Operator | Reads | Notes |
|---|---|---|
equals / not_equals | compare.value | Honors caseInsensitive + trim on both sides. |
starts_with / ends_with / contains / not_contains | compare.value | Same normalization. |
in / not_in | compare.values: [] | Arrays only. Each candidate is normalized before comparison. |
regex | compare.value | Pattern lives in value, not a separate pattern field. Invalid patterns return fail, never throw. |
is_null | — | Short-circuits before normalization. Matches null and missing. |
is_empty | — | Like is_null plus the empty string. |
Number filter
{
"source": { "kind": "request", "path": "$.bagPieces" },
"compare": {
"operator": "between",
"min": 1, "max": 4,
"minInclusive": true,
"maxInclusive": true,
"round": "floor" // floor | ceil | round (optional)
},
"arraySelector": "first",
"onMissing": "pass"
}
Operators: equals · not_equals · gt · gte · lt · lte · between · not_between · in · not_in · is_null.
Coercion. JSON numbers stay numbers. Strings parse via double.TryParse with InvariantCulture. Booleans become 0/1. Anything else (objects, arrays, NaN, Infinity) is treated as missing.
Date filter
{
"source": { "kind": "request", "path": "$.depDate" },
"compare": {
"operator": "within_next",
"amount": 14, "unit": "days",
"granularity": "datetime", // datetime | date | time
"timezone": "Asia/Dubai" // optional, IANA
},
"arraySelector": "first",
"onMissing": "fail"
}
Operators: equals · not_equals · before · after · between · not_between · within_last · within_next · is_null. Units: minutes · hours · days · weeks · months.
Granularity
- datetime — full instant in milliseconds since epoch.
- date — collapse to
YYYY*10000 + MM*100 + DDin the configured timezone. - time — collapse to seconds since midnight in the configured timezone.
Timezone handling
- ISO-8601 with explicit offset (
2026-04-27T14:00:00Z) is trusted as-is. - Naive strings (
2026-04-27T14:00:00) are interpreted incompare.timezoneif set, otherwise UTC. - Date-only (
2026-04-27) → midnight in the configured timezone. - Time-only (
14:00:00) → today in the configured timezone. within_last/within_nextresolvenowvia an injectable clock, defaulting toDateTimeOffset.UtcNow. The engine exposesRuleRunner.Options.Clockfor tests to pin it.
Array selectors + onMissing
Every filter shares the same array-reduction and missing-handling semantics.
| Selector | Verdict when |
|---|---|
any | ≥1 resolved value matches |
all | every resolved value matches |
none | no resolved value matches |
first | only index [0] is checked |
only | exactly one resolved value matches |
onMissing kicks in only when the resolved-value list is empty. JSON nulls in the resolved list are kept (and fail every operator except is_null/is_empty).
Logic ops
Logic nodes aggregate the verdicts of their upstream nodes. Multiple edges from the same source are deduped — a logic node sees one verdict per upstream node.
| Op | Pass when | Special |
|---|---|---|
and | every input is pass | Any error input → error. |
or | ≥1 input is pass | Any error input → error. |
xor | exactly one input is pass | Any error input → error. |
not | single input is not pass | Requires exactly one input. Throws otherwise. skip input → pass. |
Product nodes
Emit a structured object as the node's output. Two config shapes:
// 1. Direct object literal { "output": { "code": "BAG", "pieces": "${ctx.tierUplift}", // resolves from execution context "weightKg": 23 } } // 2. outputSchema (template-style, also accepted) { "outputSchema": [ { "key": "code", "value": "BAG" }, { "key": "weightKg","value": 23 } ] }
${ctx.X} placeholders inside any string field get recursively resolved against the run's execution context. Unresolved placeholders leak through as the literal string — useful as a debugging hint.
Mutator nodes
A mutator reads exactly one upstream object, modifies one field, and emits the modified object. Two flavors share one config record.
Set-property
{
"target": "pieces",
"from": "$.bagPieces" // or "value": 3 for a literal
}
Lookup-and-replace
{
"target": "fee",
"lookup": {
"referenceId": "ref-price-matrix",
"valueColumn": "fee",
"matchOn": {
"route": "$.route",
"cabin": "$.cabin",
"pieces": "$.bagPieces"
}
},
"onMissing": "leave" // leave | clear | error
}
The engine fetches the reference set once per run (cached indefinitely — versions are immutable), scans rows linearly until every matchOn column equals the resolved value, then writes the row's valueColumn onto target.
Calc nodes
{
"target": "fee",
"expression": "fee * (1 + markup)"
}
Backed by NCalc. Variables resolve from a stacked namespace, highest-wins:
- Upstream object's top-level fields
- Execution context entries
- Request top-level fields
So fee picks up the upstream bag product's fee field; markup falls through to the request. With target set, the result replaces that field on a copy of the upstream object. Without it, the bare scalar is the node's output.
Supported expression features
- Arithmetic:
+-*/%** - Comparison:
=!=<<=>>= - Boolean:
andornot - Conditionals:
if(cond, a, b) - String concat:
'fare-' + cabin - Math functions:
Min,Max,Abs,Round,Floor,Ceiling,Sqrt, …
Output node assembly
The output node has its own resolution order. The engine picks the first rule that matches:
- Legacy literal —
output.config.resultis set → use it as-is, with${ctx.X}placeholder resolution. - Single upstream — exactly one upstream node has produced an output → its output is the result, with placeholder resolution.
- Multiple upstream — shallow merge of every upstream object output (later edge wins on key conflict), with placeholder resolution.
- Nothing — output node never activated → envelope
decision: skip.
Execution context
Each rule run owns a flat dictionary of context entries — JSON values keyed by short names. Sub-rule calls populate it via outputMapping: { "ctx.X": "result.Y" }. Filters and product/mutator/calc nodes can read it via $ctx.X JSONPath or ${ctx.X} placeholders.
Every node trace in --debug mode includes a ctxRead snapshot of context-keys-present-before, plus a ctxWritten diff if the node mutated context. Sub-rule traces also link via subRuleRunId.