Explanations
In short
Section titled “In short”Every rule has an explanation: one sentence, with the real numbers filled in, telling the analyst why it fired. It is stored with each hit, and the latest one becomes the summary on the alert. A good explanation saves the analyst from rebuilding the rule’s arithmetic in their head.
Template: {{features.cash_in_count_48h}} cash-ins totalling {{txn.base_currency}} {{features.cash_in_sum_48h}} in 48h; this one ({{txn.base_currency}} {{txn.amount}}) is just below the {{txn.base_currency}} {{params.threshold}} threshold.Analyst: 4 cash-ins totalling GHS 37,600 in 48h; this one (GHS 9,400) is just below the GHS 10,000 threshold.Placeholders
Section titled “Placeholders”Write {{ … }} around one of seven kinds of value, or {{= … }} around an expression
(computed placeholders, below):
| Placeholder | Fills in | Example |
|---|---|---|
{{txn.<field>}} |
A field of the transaction, from the CEL reference | {{txn.amount}}, {{txn.channel}}, {{txn.counterparty_country}}, {{txn.hour}} |
{{features.<name>}} |
A feature this rule declares | {{features.senders_7d}} |
{{params.<name>}} |
A parameter of this version | {{params.threshold}} |
{{customer.<field>}} |
A field of the customer, from the CEL reference | {{customer.risk_rating}}, {{customer.segment}}, {{customer.designations}} |
{{account.<field>}} |
A field of the account, from the CEL reference | {{account.type}}, {{account.age_days}} |
{{counterparty.<field>}} |
A field of the other party, from the CEL reference | {{counterparty.country}}, {{counterparty.provider}} |
{{device.<field>}} |
A field of the device, from the CEL reference | {{device.customer_age_hours}} |
- Label amounts with
{{txn.base_currency}}, not a fixed currency: it gives your workspace’s base currency, the currency every amount is in, so{{txn.base_currency}} {{txn.amount}}reads GHS 9,400 in a Ghanaian workspace and NGN 9,400 in a Nigerian one. Every shipped pack does this fromcore1.1.0,fraud1.1.0 andgh-psp1.2.0. - Spaces inside the braces are allowed:
{{ txn.amount }}works. - One value from a map: add its key after a dot.
{{customer.hours_since.sim_swap}}gives the hours since the customer’s latest SIM swap, as the shipped rule G3 does. The key must be written in lowercase letters, digits and underscores. A map on its own, such as{{customer.hours_since}}, prints the raw map, which reads badly. - Any other variable is not replaced: it appears in the alert exactly as typed. To work something out instead, use a computed placeholder.
- A field, name or key that doesn’t exist shows as
—, for example{{customer.hours_since.sim_swap}}for a customer with no SIM swap on record. - The template can be up to 500 characters.
Computed placeholders
Section titled “Computed placeholders”Sometimes the value you want isn’t a single field but something worked out from several: which of the configured events happened, or how many hours ago. Put an equals sign after the opening braces and write a CEL expression, the same language as the rule itself:
{{= <CEL expression> }}The expression sees exactly what the rule’s expression sees: txn, customer, account,
counterparty, device, features, params and lists. Its result is formatted like any other
value (see How values are formatted), so a list comes out separated by
commas.
Example: H5 names the changes that fired it
Section titled “Example: H5 names the changes that fired it”H5 fires on a payout soon after a profile or credential change. Its
explanation, in the fraud pack 1.1.0, is:
Payout of {{txn.base_currency}} {{txn.amount}} after {{= params.change_events.filter(e, e in customer.hours_since && customer.hours_since[e] < double(params.window_hours)).map(e, e.replace('_', ' ') + ' ' + (customer.hours_since[e] < 1.0 ? string(int(customer.hours_since[e] * 60.0)) + 'm ago' : string(int(customer.hours_since[e])) + 'h ago')).join(', ') }} (rule window: {{params.window_hours}} hours).Read the computed part from left to right:
params.change_events.filter(…)keeps only the changes this rule is set to watch (by default phone, email, password reset, password change and device added) that happened within the rule’s window..map(…)turns each one into words:phone_changebecomes phone change 20h ago. A change less than an hour ago is given in whole minutes, email change 35m ago; an older one in whole hours..join(', ')puts them in one line.
A customer who changed their phone number 20 hours ago and their email 35 minutes ago, and had a SIM swap an hour ago, sends GHS 2,400. The analyst reads:
Payout of GHS 2,400 after phone change 20h ago, email change 35m ago (rule window: 24 hours).The SIM swap isn’t listed, because it isn’t one of the changes H5 watches. It is G3’s business.
Checked when you save
Section titled “Checked when you save”When a version is saved, validated or tested, each computed placeholder is compiled like the rule’s expression. An unknown name or a syntax error is refused, and the problem names the placeholder, for example:
explanation {{= no_such_var }}: ERROR: <input>:1:1: undeclared reference to 'no_such_var' (in container '')Some mistakes only show up with real data. txn, customer and the other variables hold values
of any type, so an expression such as {{= txn.amount + 'x' }} is accepted on save and fails when
it runs. A placeholder that fails, or that exceeds the same cost limit rules run under, shows as
—, and the rest of the explanation is still filled in. The hit itself still counts: only the text
is affected. Before you submit a version, select Test in the
rule builder with sample data, and read the explanation
it returns.
Each failure is also counted, by rule key, in the metric
speculapro_rule_explanation_errors_total, so whoever runs SpeculaPro can see a broken placeholder
without waiting for an analyst to notice dashes. See
Monitoring.
- The expression can’t contain
}}, because that ends the placeholder. - The 500-character limit covers the whole template, expressions included.
Explanations of scheduled rules
Section titled “Explanations of scheduled rules”A scheduled rule doesn’t fire on one transaction, so its explanation has a different set of values:
| Placeholder | Fills in | Example |
|---|---|---|
{{features.<name>}} |
The figures the run found for this subject. Each scheduled rule lists them in Pack contents | {{features.senders}}, {{features.in_amount}} |
{{params.<name>}} |
A parameter of this version | {{params.window_hours}} |
{{subject.<name>}} |
Text about the subject the run found, such as an agent’s peer group or the branches used | {{subject.peer_group}}, {{subject.branch_list}} |
{{txn.base_currency}} |
Your workspace’s base currency | GHS |
Other txn, customer, account and counterparty fields aren’t available and show as —.
Computed placeholders are refused: scheduled rule explanations use {{features.x}}, {{params.x}},
{{subject.x}} and {{txn.base_currency}}, not computed {{= }} placeholders.
How values are formatted
Section titled “How values are formatted”| Value | Shown as |
|---|---|
| A whole number | With thousands separators: 50000 → 50,000 |
| A number with a fraction | Two decimals, no separators: 4950.5 → 4950.50 |
| Text | As is: momo, NG |
| A list | Items separated by commas: high, unacceptable. An empty list shows as nothing at all |
Yes/no (for example customer.is_pep) |
yes or no |
| Nothing (missing) | — |
Two things to watch:
- “Unknown” numbers show as
-1.customer.kyc_level,customer.age_days,account.age_daysandcustomer.hours_since_last_changeare-1when not known, and the explanation prints-1. Avoid them in explanations unless the rule only fires when the value is known. - An empty list leaves a gap. “Designations: {{customer.designations}}” reads “Designations: “ for a customer with none. Use list placeholders in rules that only fire when the list has something in it.
So most amounts read naturally (GHS 49,500), but an amount with pesewas reads
GHS 12345.67. Keep that in mind when you word the sentence. Putting the currency in front
({{txn.base_currency}} {{txn.amount}}) makes it clear either way.
Hours from customer.hours_since are rarely whole, so they read like 3.42 (hours, not 3 hours
42 minutes). Word the sentence so a decimal makes sense: “SIM swapped 3.42 hours before…”.
Good and bad explanations
Section titled “Good and bad explanations”| ❌ Weak | ✅ Strong | Why |
|---|---|---|
Rule B1 matched. |
{{features.cash_in_count_48h}} cash-ins totalling {{txn.base_currency}} {{features.cash_in_sum_48h}} in 48h; this one is just below {{txn.base_currency}} {{params.threshold}}. |
The analyst sees the pattern and the numbers at once |
Suspicious activity detected. |
{{features.senders_7d}} different senders paid this account in 7 days. |
Says what is suspicious |
Amount {{txn.amount}} > {{params.threshold}} |
Transaction of {{txn.base_currency}} {{txn.amount}} is at or above the {{txn.base_currency}} {{params.threshold}} threshold. |
Plain words, with the currency |
Customer may be laundering money via agents. |
Cash-ins via {{features.agents_24h}} different agents totalling {{txn.base_currency}} {{features.cash_in_24h}} in 24h. |
States facts, not conclusions. The analyst decides |
Checklist
Section titled “Checklist”- Does it make sense to someone who has never seen the rule’s expression?
- Does it include the number that crossed the line, and the line itself?
- Does it name the time window (“in 24h”, “in 7 days”)?
- Would it still read well if a value were missing (
—)? - If it has a computed placeholder, did you Test it with sample data?
Related
Section titled “Related”- Writing a custom rule
- Pack contents, which shows every shipped rule’s explanation
- Alerts and cases