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": "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 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", "CA", "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 (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
}
FieldNotes
appThe install's name, or a former name it keeps after a rename
actionA synchronous action of the version the install runs; 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)
fallbackAnother 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
}
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.