AeroToys

Evaluators

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

  • datetime — full instant in milliseconds since epoch.
  • date — collapse to YYYY*10000 + MM*100 + DD in 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 in compare.timezone if 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_next resolve now via an injectable clock, defaulting to DateTimeOffset.UtcNow. The engine exposes RuleRunner.Options.Clock for tests to pin it.

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

  • Arithmetic: + - * / % **
  • Comparison: = != < <= > >=
  • Boolean: and or not
  • 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:

  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.