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": "high_ownership_foreign_ubo", "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", "CA", "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 (through an install)
Calls a synchronous action through one of the tenant's installs and compares one field of its output. app names the install, not the catalogue app: an app installed twice, say once per vendor account, is two installs with names of their own.
json
{
"app": "vendor-a",
"action": "screen",
"inputMapping": { "name": "submission.legalName", "country": "submission.country" },
"resultPath": "hit",
"failWhen": true
}| Field | Notes |
|---|---|
app | The install's name, or a former name it keeps after a rename |
action | A synchronous action of the version the install runs; 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) |
fallback | Another install that answers when this one cannot (below) |
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 install's invocations and counts toward the tenant's quotas, 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 install that does not exist or is disabled, a vendor error, an open circuit, a quota used up, a deployment without apps, an inputMapping field the action takes no run data in) makes the rule error rather than letting it pass unchecked, with the error's details as its details. An answered call's whole output is the result's details. A rule that names a former name of an install, which the install keeps as an alias after a rename, still calls it; validation warns (install_renamed, naming the current name), and the call is recorded under the name the rule used (Apps: reference).
input holds literal input values that inputMapping cannot express; mapped fields override a literal of the same name. For instance, a rule that asks the same screening install for politically exposed persons only:
json
{
"app": "vendor-a",
"action": "screen",
"input": { "type": "Person", "lists": ["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).
fallback names another of the tenant's installs that answers when the rule's own cannot: its circuit refused the call, or the vendor's outage outlasted the call's retries. It takes its own app, action (synchronous too), input and inputMapping, built from the same rule data, and reads its output at its own resultPath and failWhen, the rule's when it gives none; the reason of a failing rule names the install that answered. A value the fallback never answers passes the rule, so validation warns (unknown_result_path) when its result path reads a field its action's output does not declare. It is never called after a refusal (a blocked module, invalid input, a quota), after an error the app calls final, or after a timeout or a runner failure of an action that is not idempotent, whose request may have reached the vendor. Its calls are recorded under its own install as standing in for the rule's, under the same idempotency key; when it fails too, the rule is error with the rule's install's error, the fallback's in details.fallback (Apps: reference):
json
{
"app": "vendor-a",
"action": "screen",
"inputMapping": { "name": "submission.legalName" },
"resultPath": "hit",
"fallback": {
"app": "vendor-b",
"action": "search",
"inputMapping": { "query.name": "submission.legalName" },
"resultPath": "matches.found"
}
}Through an install of the platform's http-api, the action is request, the input a partial request and the output { status, ok, headers, body }, so resultPath reads the vendor's answer under body, or its status and ok (App catalogue). A fraud vendor answers 200 when it lists an email and 404 when it does not; this rule fails for a listed applicant:
json
{
"app": "fraud-vendor",
"action": "request",
"input": { "endpoint": "/v1/blocklist/emails/<<email>>", "acceptStatuses": [404] },
"inputMapping": { "variables.email": "submission.email" },
"resultPath": "ok",
"failWhen": true
}inputMapping puts the applicant's email in the request's email variable, encoded as one path segment (run data fills a request's variables only), and acceptStatuses makes the 404 an answer (ok: false) rather than a failure, which would make the rule error. A rule calls once: a vendor error, retryable or not, makes it error.
Through an install of one of the model apps, a rule compares one value of a model's answer: decide answers each question under answers.<id>, so resultPath: answers.fraud.value with failWhen: true fails the rule when the answer to the question fraud is yes (AI models).
In the console the rule form has the call_app step's pickers: the install, the actions of the version it runs, and an input editor for the chosen action, which for an http-api install shows the install's endpoint and takes a request pasted from Hoppscotch, and for a model app's install asks for the prompt or the questions (Admin console: Define). The result path, the fallback's too, offers the paths the chosen action answers, the answers to the questions it asks among them (answers.fraud.value), and takes any other.
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.