Rules
Author tenant-scoped fraud rules with the ABAC-shape DSL and roll them out via the draft → shadow → published flow.
Tenant-defined rules drive the verdict for every /v1/fraud/decide call. Rules are authored as a small JSON DSL, validated with shadow-decide and shadow reports, and only then promoted to production.
Lifecycle
draft → shadow → published → archived (terminal)
\ \
\ → draft (revert)
→ archived (cancel)
| Status | Effect on production verdict | Effect on shadow-decide |
|---|---|---|
draft | None — rule is parked. | None. |
shadow | None. Outcome recorded in ruleVersionsEvaluated for the shadow report. | Counts toward the would-be verdict. |
published | Triggered weight contributes to score; verdict bands apply; verdict overrides apply. | Counts. |
archived | Ignored. | Ignored. |
Rules transition through POST /api/v1/fraud/rules/:id/transition.
Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/fraud/rules | List rules (filter by ?status=) |
| POST | /api/v1/fraud/rules | Create a rule (always status=draft) |
| GET | /api/v1/fraud/rules/:id | Fetch one rule |
| PATCH | /api/v1/fraud/rules/:id | Update a draft or shadow rule |
| DELETE | /api/v1/fraud/rules/:id | Soft-archive (transition to archived) |
| POST | /api/v1/fraud/rules/:id/transition | State-machine transition |
| GET | /api/v1/fraud/rules/:id/shadow-report | Trigger-rate metrics |
Scope: fraud:manage (CRUD + transitions + report)
Plan gate: Detection Pack addon
Rate limit: 500 requests / hour per API key
PATCH on a published or archived rule returns 409 CONFLICT. To change a published rule, archive it and create a new draft.
Authoring a rule
Rules are JSON. The condition AST mirrors the Authorization Engine's ABAC operators (13 operators + all / any / not), with two fraud-specific extensions:
- Dot-path lookups for nested fields:
subject.kycTier,amount.value,context.deviceFingerprint. $count.<windowName>references that resolve from the velocity counter snapshot computed before the rule fires.
Minimal example
{
"name": "high-value-transfer",
"weight": 30,
"appliesTo": { "actions": ["transfer"], "resourceKinds": ["transaction"] },
"condition": {
"amount.value": { "gt": 100000 }
}
}
Velocity rule with windows
{
"name": "velocity-failed-logins",
"weight": 50,
"appliesTo": { "actions": ["login"] },
"windows": [
{
"name": "failedLogins24h",
"aggregation": "count",
"duration": "P1D",
"bucketBy": "subject.id"
}
],
"condition": {
"$count.failedLogins24h": { "gt": 5 }
}
}
Hard verdict override
A rule can bypass score-band logic with verdictOverride. The most-severe override across triggered rules wins (severity: block > step_up > review > allow).
{
"name": "sanctions-hit",
"weight": 100,
"verdictOverride": "block",
"appliesTo": { "actions": ["*"] },
"condition": {
"context.sanctionsListId": { "exists": true }
}
}
Combinators (all / any / not)
{
"name": "high-risk-transfer",
"weight": 60,
"appliesTo": { "actions": ["transfer"] },
"condition": {
"all": [
{ "context.geoHint": { "in": ["NG", "GH", "KE"] } },
{
"any": [
{ "$count.failedLogins24h": { "gt": 5 } },
{ "$count.distinctRecipients24h": { "gt": 10 } }
]
},
{ "not": { "subject.attributes.kycTier": { "gte": 3 } } }
]
}
}
Operators
| Operator | Notes |
|---|---|
equals / notEquals | Strict equality. |
gt / gte / lt / lte | Both operands must be numeric; otherwise the predicate is false. |
in / notIn | expected must be an array. |
contains / startsWith / endsWith | String-only; non-string operand → false. |
exists | expected: true matches if path is non-null/undefined; false is the inverse. |
matches | Regex. Invalid regex never matches and never throws — it's safe to author. |
Windows (velocity counters)
Each window adds one Redis sorted-set behind the scenes, keyed by (orgId, ruleId, windowName, bucketValue).
| Field | Description |
|---|---|
name | Identifier referenced from the AST as $count.<name>. Unique within the rule. |
aggregation | count, sum, or distinctCount. |
field | Required for sum / distinctCount. Dot-path within the request context — e.g. context.amount.value for sum, context.recipient for distinctCount. |
duration | ISO-8601 duration. Supported: P1D, PT5M, PT1H, P7D, P1DT12H, etc. |
bucketBy | Dot-path resolved against the request context to produce the bucket key (e.g. subject.id). |
Rolling windows are pruned at read time via ZREMRANGEBYSCORE. Each event is added to the sorted set with score = now (ms).
Verdict bands
| Score | Verdict |
|---|---|
| 0 – 24 | allow |
| 25 – 49 | review |
| 50 – 74 | step_up |
| 75 – 100 | block |
Score is the sum of triggered-rule weights, capped at 100. Hard verdictOverride values from triggered rules can promote the verdict (never demote it).
Shadow report
GET /api/v1/fraud/rules/:id/shadow-report?from=...&to=...
Aggregates fraud_decisions to surface trigger-rate metrics for a single rule across an arbitrary window. Default window is the last 7 days.
{
"success": true,
"data": {
"ruleId": "frl_2j7yhq8wcs9",
"ruleName": "velocity-failed-logins",
"ruleVersion": 1,
"status": "shadow",
"windowFrom": "2026-04-26T09:14:00Z",
"windowTo": "2026-05-03T09:14:00Z",
"totalDecisions": 12_403,
"triggeredCount": 187,
"triggerRate": 0.01508
}
}
Use the report to decide when to promote a rule from shadow to published:
- Trigger rate too high → tighten the condition before publishing.
- Trigger rate near zero → either the rule is solid or the condition never matches; cross-check with shadow-decide.
- Steady-state on a published rule → drift signal; investigate if the rate suddenly jumps.
Rule-rollout playbook
- Author as
draft. Iterate freely, validate with/shadow-decidecalls. - Promote to
shadow. The rule now runs ride-along on every production decide call but never affects verdicts. - Watch the shadow report. Aim for a stable trigger rate that matches your design intent.
- Promote to
published. The rule's weight starts contributing to verdicts. - Monitor via
/decisionsaudit trail and the shadow report (still works post-publish for drift detection). - Archive when retired. There is no hard-delete path — every historical decision can be reproduced from
ruleVersionsEvaluated.
Errors
| HTTP | Code | Cause |
|---|---|---|
| 400 | BAD_REQUEST | Validation failure (missing field, bad shape, invalid duration). |
| 401 | UNAUTHORIZED | Missing or invalid API key. |
| 402 | DETECTION_PACK_REQUIRED | Detection Pack addon not enabled. |
| 403 | FORBIDDEN | API key has no organization context, or wrong scope. |
| 404 | NOT_FOUND | Rule unknown or belongs to a different org. |
| 409 | CONFLICT | Name collision, immutable (published/archived) rule, or invalid state transition. |
| 429 | RATE_LIMITED | 500/hr ceiling exceeded. |
| 500 | INTERNAL_ERROR | Unexpected service failure. |