Appearance
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:
| Path | Meaning |
|---|---|
subjectId | The 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.outcome | An 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 mode | Outcome |
|---|---|
max_severity | A failed block rule rejects; else a failed warn rule goes to manual review; else approve. Used for ad-hoc ruleKeys. |
sum_weights | Score = weights of failed rules plus reported scores, clamped to the range (0 to 100 by default), mapped through contiguous bands. |
first_match | Rules 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 anexplanation: whether the version hits, its severity and score, and the reasons its handler gave. It needsdefinitions:writeorbacktests:run; result details are redacted like a run's for callers withoutruns:context, and only an audit event per version (rule_definition.dry_run) is recorded. Dry runs do not call apps:apprules come backskipped. - 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
shadowis 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;apprules come backskipped, since replaying them would call the vendor again. WithcompareWithRuleSetKey, 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_reviewand so on), per-rule overlap (both the draft and an existing rule failing), outcome counts and sample failures.dailysplits 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, whichGET /stats/rules/:key/hitsserves beside the hits every evaluation recorded. Backtests run as a Temporal job on a separate task queue and report progress. - Workflow backtests (
POST /backtestswithworkflowKey, 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 theevaluate_rulesstep 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, andskippedfor 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 }| Field | Notes |
|---|---|
path | Dot path |
op | eq, neq, gt, gte, lt, lte, in, exists |
value | Constant; an array for in; omitted for exists |
passWhen | true (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 }| Field | Notes |
|---|---|
min, max | At least one required; inclusive bounds |
contributeScore | Report 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
}| Field | Notes |
|---|---|
app | The install's name |
action | A synchronous action of the installed version; one that answers through a webhook is refused |
input | Literal input values (default {}) |
inputMapping | Input field -> dot path into the rule data; overrides a literal of the same name |
resultPath | Dot path into the action's output |
failWhen | The 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
}| Field | Notes |
|---|---|
expression | Must return a boolean; at most 2 000 characters |
failWhen | true (default): fail when the expression is true; false: fail when false |
scoreExpression | Optional; 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):
| Function | Meaning |
|---|---|
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
}| Field | Notes |
|---|---|
aggregate | count, sum, avg, max (over field, default amount), distinct_count (of field) |
window | { amount, unit } with unit minutes, hours or days; at most 90 days |
groupBy | subject (default) or a promoted key: ip, device_id, card_fingerprint, email |
groupValuePath | Where 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, threshold | The 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:
| Kind | Matching |
|---|---|
string | Trimmed; case-insensitive unless the list says otherwise |
email | Lower-cased, must look like an address |
country | ISO 3166-1 alpha-2, upper-cased |
number | Numeric equality (042.5 matches 42.5) |
ip_cidr | IPv4/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.