Chapter 03

Evaluators

Focused evaluators for filters, logic, products, mutators and calc. Each one is a 1:1 port of a TypeScript reference implementation, so the engine and the editor always agree.

String filter

Operates on text. Coerces JSON strings, numbers and booleans into strings; objects and arrays are treated as missing.

Operators

OperatorReadsNotes
equals / not_equalscompare.valueHonors caseInsensitive + trim on both sides.
starts_with / ends_with / contains / not_containscompare.valueSame normalization.
in / not_incompare.values: []Arrays only. Each candidate is normalized before comparison.
regexcompare.valuePattern lives in value, not a separate pattern field. Invalid patterns return fail, never throw.
is_nullShort-circuits before normalization. Matches null and missing.
is_emptyLike 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

Timezone handling

Array selectors + onMissing

Every filter shares the same array-reduction and missing-handling semantics.

SelectorVerdict when
any≥1 resolved value matches
allevery resolved value matches
noneno resolved value matches
firstonly index [0] is checked
onlyexactly 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.

OpPass whenSpecial
andevery input is passAny error input → error.
or≥1 input is passAny error input → error.
xorexactly one input is passAny error input → error.
notsingle input is not passRequires 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:

  1. Upstream object's top-level fields
  2. Execution context entries
  3. 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

Output node assembly

The output node has its own resolution order. The engine picks the first rule that matches:

  1. Legacy literaloutput.config.result is set → use it as-is, with ${ctx.X} placeholder resolution.
  2. Single upstream — exactly one upstream node has produced an output → its output is the result, with placeholder resolution.
  3. Multiple upstream — shallow merge of every upstream object output (later edge wins on key conflict), with placeholder resolution.
  4. 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.