Rules & decision policy
The rules engine is the heart of AML monitoring. Rules are declarative conditions that, when satisfied by a screening or transaction, contribute a score and (optionally) take an action.
Anatomy of a rule
{
"id": "rul_01HXY...",
"code": "CASH_STRUCT_D7",
"name": "Cash deposit structuring (7-day window)",
"description": "More than 3 cash credits in 7 days, each ≥ 80% of cash threshold but < 100%.",
"lane": "transaction",
"category": "structuring",
"type": "velocity",
"severity": "high",
"action": "alert",
"scoreContribution": 45,
"conditions": {
"channel": "in:['cash', 'atm_deposit']",
"direction": "==:credit",
"amountIdr": "between:[400000000, 499999999]",
"occurrenceWindow": "rolling:P7D",
"occurrenceThreshold": ">=:3"
},
"status": "active",
"tags": ["structuring", "fatf-r-10"],
"version": 3,
"createdAt": "...",
"updatedAt": "...",
"approvedBy": "usr_compliance_lead_...",
"approvedAt": "..."
}Field meanings
| Field | Notes |
|---|---|
code | Unique per org. Use for cross-referencing in your compliance docs. |
lane | onboarding · transaction · ongoing. |
category | Free-text bucket — structuring, pep, geography, velocity, kyc, custom. |
type | threshold · velocity · pattern · lookup. |
severity | low · medium · high · critical. |
action | alert (score adds to risk) · escalate (force escalate) · block (force escalate + halt). |
scoreContribution | Integer added to alert's riskScore when this rule fires. |
conditions | DSL describing match — see below. |
status | draft · pending_approval · active · paused. |
Condition DSL
A condition object maps fields to predicates. Operators:
==:value·!=:value>:n·>=:n·<:n·<=:n·between:[a,b]in:[a,b,c]·not_in:[a,b,c]match:regex(anchored)rolling:ISO-8601-duration(for velocity windows, paired withoccurrenceThreshold)
Conditions are AND-joined within a single rule. For OR logic, define multiple rules with the same category.
List + create rules
/api/rulesFilters: lane, status, category. Paginated (default 50, max 200).
/api/rulesBody is the rule object above (omit ID, timestamps, version, approvedBy, approvedAt). New rules start in draft status and require approval before they go active.
Coverage (before activation)
Rules reference canonical fields like transaction.amountIdr and transaction.isCrossBorder. For a rule to fire correctly in production, every field it references must either be engine-computed (e.g. transaction.isRoundNumber, velocity counters — derived automatically) or covered by at least one active channel in the rule's lane (see Channels).
The coverage endpoint tells you which fields are covered and which aren't:
/api/rules/{id}/coverage{
"ok": true,
"data": {
"ruleId": "rul_01HXY...",
"code": "TX-030",
"name": "PER-11 cash threshold",
"lane": "transaction",
"fields": [
{
"path": "transaction.amountIdr",
"channelAware": true,
"canonical": "transaction.amount_idr",
"covered": true,
"coveredByChannels": [
{ "id": "ch_01HXY...", "name": "Core banking — transfers" }
]
},
{
"path": "transaction.isCrossBorder",
"channelAware": true,
"canonical": "transaction.is_cross_border",
"covered": false,
"coveredByChannels": [],
"reason": "no active channel in lane=transaction maps this canonical field"
},
{
"path": "transaction.isRoundNumber",
"channelAware": false,
"covered": true,
"reason": "engine-computed — always covered"
}
],
"allCovered": false,
"unmappedRequired": ["transaction.isCrossBorder"]
}
}The dashboard's inline rule editor (Rules → ⋯ → Edit) renders the same coverage live as a green "all mapped" pill or amber "X fields unmapped" list with deep-links to the channels that need editing.
Activate without coverage = silent underfire
A rule can be activated even when allCovered is false — the engine doesn't refuse to run. But a missing-mapped field evaluates to false for every event, so the condition never trips. CI tip: gate rule activation on allCovered === true for every rule you flip from pending_approval to active.
Approval workflow
A second outcome from pending_approval is rejected — the rule returns to draft with the rejector's note attached.
/api/rules/{id}/approveThe approver must differ from the drafter (configurable four-eyes; AML defaults to required for rules in block action class).
{ "decision": "approve", "notes": "Aligns with PPATK Reg. PER-11 cash threshold." }Update + status
/api/rules/{id}Edits put the rule back into draft and bump the version. Active version keeps running until approval reactivates the new draft. No silent edits.
/api/rules/{id}/status{ "status": "paused" }Use paused for emergency rule-off without deletion.
Temporary bypass
/api/rules/{id}/bypass{
"until": "2026-05-25T23:59:59Z",
"reason": "Investigating false-positive surge on counterparty acquisitions during M&A close."
}Bypasses fire webhook.rule.bypassed events. Max bypass duration is 30 days; auto-expires.
Decision policy
Per-org thresholds that turn raw scores into actions.
/api/decision-policy/api/decision-policy{
"ok": true,
"data": {
"onboardingConfirmedHitScore": 80,
"onboardingReviewScore": 50,
"txReviewScore": 40,
"txEscalateScore": 70,
"txSarScore": 90,
"cashThresholdIdr": "500000000",
"structuringTolerancePct": 80,
"crossBorderPremiumOnly": true,
"fourEyesOnRuleApprove": true,
"fourEyesOnSarSubmit": true,
"fourEyesOnCaseClose": false
}
}Tuning thresholds is regulatory-sensitive
Indonesian regulators expect your AML program to document the rationale for any threshold change. Quantum Elixir audit-logs every PATCH /api/decision-policy call with actor + before/after values; export the policy-changes report quarterly from the dashboard for examiner review.
Backtesting
Before promoting a rule from draft to pending_approval, test it against historical data:
POST /api/rules/{id}/backtest
{
"lookbackDays": 90,
"scope": "all | sample",
"sampleSize": 10000
}Returns:
{
"ok": true,
"data": {
"totalEvaluated": 387412,
"wouldHaveFired": 1872,
"byLane": { "onboarding": 134, "transaction": 1738 },
"estimatedFalsePositiveRate": 0.94,
"topMatchingCustomers": [...]
}
}Use the false-positive estimate to right-size scoreContribution before activating the rule in production traffic.
Audit & versions
Every rule keeps a version history queryable via GET /api/rules/{id}/versions. Use this to demonstrate to examiners exactly what was active during any past time window.