Skip to content

Rule reference ​

Rules evaluate against a run's data and produce pass, fail, error or skipped. Each rule has a severity (info, warn, block), an optional weight, and a type-specific config. Rules are versioned: edits create a draft version; publishing makes it the one workflows use. Worked examples of rules, rule sets and the workflows around them are the example policies, importable into a tenant in one command.

Data paths ​

Every rule config addresses data with dot paths into the run context:

PathMeaning
subjectIdThe subject's id
subject.<field>The subject's data payload, e.g. subject.country
<outputKey>.<field>A workflow step's output under its outputKey
submission.<field>The collection-flow submission (a wait_for_collection step)
sanctions.<field>An app step's output when its outputKey is sanctions
rules.outcomeAn earlier evaluate_rules step's aggregate

The same paths work in workflow branch conditions and call_app input mappings.

Rule sets and aggregation ​

A workflow's evaluate_rules step names either an ad-hoc list of rules (ruleKeys) or a published rule set (ruleSetKey). A rule set is a versioned, ordered group of rules with one aggregation policy; each entry can be disabled, given a weight override, or marked shadow (evaluated and recorded, but ignored by aggregation, so a new rule can be watched on live traffic first).

json
{
  "rules": [
    { "ruleKey": "country_allowed" },
    { "ruleKey": "sanctions_hit", "weightOverride": 60 },
    { "ruleKey": "new_rule_under_test", "shadow": true }
  ],
  "policy": {
    "mode": "sum_weights",
    "clamp": { "min": 0, "max": 100 },
    "bands": [
      { "min": 0, "max": 40, "outcome": "approve" },
      { "min": 40, "max": 90, "outcome": "manual_review" },
      { "min": 90, "max": 100, "outcome": "reject" }
    ]
  }
}
Policy modeOutcome
max_severityA failed block rule rejects; else a failed warn rule goes to manual review; else approve. Used for ad-hoc ruleKeys.
sum_weightsScore = weights of failed rules plus reported scores, clamped to the range (0 to 100 by default), mapped through contiguous bands.
first_matchRules in set order; the first failed rule decides (block rejects, warn reviews, info continues); otherwise the policy's default.

In every mode a rule in error forces manual review: a rule the engine could not run must never silently approve. The decision records the set key and version, the risk score and every rule result, so the admin console can show why.

Testing a rule before it decides anything ​

  • Dry-run (POST /rule-definitions/:key/dry-run, "Test against a subject" on the admin console's rule page) evaluates up to five versions of a rule (versions, default the latest, drafts included) against one stored subject or inline data, each result with an explanation: whether the version hits, its severity and score, and the reasons its handler gave. It needs definitions:write or backtests:run; result details are redacted like a run's for callers without runs:context, and only an audit event per version (rule_definition.dry_run) is recorded. Dry runs do not call apps: app rules come back skipped.
  • Validation (POST /rule-definitions/validate, POST /rule-sets/validate) checks a draft without saving it, with warnings a save does not raise, such as a list the rule reads that has not changed in over 30 days or a set entry whose rule has no published version.
  • Shadow rules: a rule set entry marked shadow is evaluated on live traffic and recorded in every snapshot and decision, but never affects the outcome.
  • Backtests (POST /backtests, the rule page's "Backtest" section) replay a draft rule over recorded evaluation snapshots: the newest evaluation per subject in the last N days, up to a limit. Each snapshot holds the exact data the rules saw, so expressions, list lookups (current list contents) and velocity windows (as of the original evaluation time) are reproduced; app rules come back skipped, since replaying them would call the vendor again. With compareWithRuleSetKey, the stored results are re-aggregated with the draft substituted under that set's policy, and the summary reports how many outcomes would change (approve->manual_review and so on), per-rule overlap (both the draft and an existing rule failing), outcome counts and sample failures. daily splits the sample by day in the tenant's time zone (console.timeZone, else UTC): per day, how many evaluations the recorded result of the rule failed against how many the draft fails, which GET /stats/rules/:key/hits serves beside the hits every evaluation recorded. Backtests run as a Temporal job on a separate task queue and report progress.
  • Workflow backtests (POST /backtests with workflowKey, Run a backtest on an approval request) replay a workflow version's rule steps: the newest evaluation per subject and step among the workflow's runs, each through the evaluate_rules step of the same id in that version, with its rule set or rules as published now and the set's policy. The summary counts the replayed outcomes (decisions: approve, reject, manual_review, and skipped for an evaluation whose step the version no longer has), the outcomes that differ from the recorded ones (wouldChange), every replayed rule's results (outcomes) and the first 50 changed evaluations with the reasons of the rules that failed.

Rule types ​

comparison ​

Compares the value at a path with a constant.

json
{ "path": "subject.country", "op": "in", "value": ["US", "GB", "DE"], "passWhen": true }
FieldNotes
pathDot path
opeq, neq, gt, gte, lt, lte, in, exists
valueConstant; an array for in; omitted for exists
passWhentrue (default): pass when the condition holds; false: pass when it does not

A missing path fails unless op is exists.

list_lookup ​

Checks a value against a tenant list (see Lists below).

json
{ "listKey": "blocked_countries", "path": "submission.country", "mode": "block" }

block fails when the value is on the list; allow fails when it is not. A missing path fails; an unknown list is an error.

score_threshold ​

Fails when a number is outside a range; useful for vendor scores.

json
{ "path": "sanctions.score", "max": 80, "contributeScore": true }
FieldNotes
min, maxAt least one required; inclusive bounds
contributeScoreReport the value as the rule's score so it adds to the risk score

Numeric strings are accepted; other non-numbers are an error.

app (installed apps) ​

Calls a synchronous action of an app the tenant installed and compares one field of its output.

json
{
  "app": "mock-sanctions",
  "action": "screen",
  "inputMapping": { "name": "submission.legalName", "country": "submission.country" },
  "resultPath": "hit",
  "failWhen": true
}
FieldNotes
appThe install's name
actionA synchronous action of the installed version; one that answers through a webhook is refused
inputLiteral input values (default {})
inputMappingInput field -> dot path into the rule data; overrides a literal of the same name
resultPathDot path into the action's output
failWhenThe rule fails when the value at resultPath strictly equals this (default true)

The call goes through the app runner like a call_app step's: the action's timeout and retries apply, it is recorded in the app's invocations, and it carries an idempotency key derived from the run, the step and the rule, so retries of the same workflow step reuse one key. A call that fails (an app that is not installed or disabled, a vendor error, an open circuit, a deployment without apps) makes the rule error rather than letting it pass unchecked. The action's whole output is the result's details.

input holds literal input values that inputMapping cannot express; mapped fields override a literal of the same name. The seeded pep_hit rule uses it to ask OpenSanctions for politically exposed persons only:

json
{
  "app": "opensanctions",
  "action": "screen",
  "input": { "type": "Person", "topics": ["role.pep"] },
  "inputMapping": { "name": "submission.legalName", "country": "submission.country" },
  "resultPath": "hit",
  "failWhen": true
}

The workflow call_app step takes the same input and inputMapping fields (Apps: reference).

expression ​

A CEL expression over the rule data. CEL is not Turing-complete, is type-checked when the rule is saved, and runs under a 50 ms budget.

json
{
  "language": "cel",
  "expression": "data.submission.uboOwnershipPct >= 75.0 && data.submission.uboCountry != data.submission.country",
  "failWhen": true
}
FieldNotes
expressionMust return a boolean; at most 2 000 characters
failWhentrue (default): fail when the expression is true; false: fail when false
scoreExpressionOptional; must return a number, added to the risk score as the rule's score

Variables: data (the full rule data, e.g. data.submission.country), subject (same as data.subject), subjectId, now.

Functions beyond CEL's built-ins (size, startsWith, contains, matches, timestamp, has(...), list and map macros such as exists and all):

FunctionMeaning
inList(listKey, value)True when value is on the tenant list
getPath("a.b.c")Value at a dot path in the data, null when any segment is missing
hasPath("a.b.c")True when every segment exists and the value is not null
daysSince(timestamp)Days between a timestamp (or ISO string) and now
lower(s), upper(s)Case conversion
toNumber(value)Numeric string or number to a double, 0.0 when not numeric

Pitfalls: JSON numbers are doubles, so use decimal literals in arithmetic (x * 2.0, not x * 2; comparisons such as x >= 75 are fine). Reading a missing key is an error, and CEL's has(data.submission.website) only guards the last segment (it still errors when submission itself is absent, e.g. in a workflow without a collection step). Prefer hasPath("submission.website") and getPath(...), which are null-safe over the whole path. Expression errors and timeouts make the rule error, which routes to manual review.

velocity ​

Aggregates a subject's (or a key's) recent events over a trailing window, as of the evaluation time, and compares the result with a threshold. Needs events ingested through POST /events.

json
{
  "aggregate": "sum",
  "window": { "amount": 24, "unit": "hours" },
  "filter": { "types": ["payment"] },
  "op": "gt",
  "threshold": 10000
}
FieldNotes
aggregatecount, sum, avg, max (over field, default amount), distinct_count (of field)
window{ amount, unit } with unit minutes, hours or days; at most 90 days
groupBysubject (default) or a promoted key: ip, device_id, card_fingerprint, email
groupValuePathWhere to read the key value from the rule data when not grouping by subject, e.g. submission.ip
filter{ "types": [...], "attributes": { "status": "declined" } }; both optional
op, thresholdThe rule fails when aggregate op threshold holds

The window ends at the evaluation time, so backtests and replays see the same values the live evaluation saw. The result's details carries the computed value, the key and the window.

Events ​

Events are append-only facts about a subject (payments, logins, signups). Ingest them in batches of up to 1 000 with POST /events; each event carries a caller-chosen idempotencyKey, and re-sending a key is a no-op. Promote the keys velocity rules group by (ip, deviceId, cardFingerprint, email) to their own fields; everything else goes into attributes, which velocity filters can match by equality.

Lists ​

Lists are tenant-managed blocklists and allowlists with a kind that drives validation and matching:

KindMatching
stringTrimmed; case-insensitive unless the list says otherwise
emailLower-cased, must look like an address
countryISO 3166-1 alpha-2, upper-cased
numberNumeric equality (042.5 matches 42.5)
ip_cidrIPv4/IPv6 addresses or ranges; an entry 10.0.0.0/8 matches 10.1.2.3

Entries may carry a reason and an expiry; expired entries never match. Lists are live data, not versioned, and every change is audited. Import entries from CSV (POST /lists/:key/import): one value per line, with an optional value,reason,expires_at header.

Released under the Apache-2.0 License.