Skip to content

Fraud review: block a card or send it to a reviewer

Your risk model has flagged a card authorisation as suspicious, and the authorisation service must either block it or let it through. Blocking a real fraudster is cheap; blocking a customer buying a laptop is a support call, a churn risk and sometimes a complaint. This page shows how the library lets the block go ahead automatically only when the accounts linked to the flagged one are numerous and consistent enough to lean on, and sends everything else to a fraud reviewer. It is written for a fraud lead or fraud-ops manager; the maths is in collapsed boxes and linked pages.

At a glance

Decision Block the card (block) or let the authorisation through (pass).
Owner Fraud operations; the authorisation service carries it out.
Today's rule If the model says suspicious, block. If it says clean, pass.
What this adds Before blocking or passing automatically, check that the flagged account's device neighbourhood is big and consistent enough to support an automatic call, and that most of it points the same way as the call. Otherwise hold for a reviewer.
Demo outcome 3 authorisations: 1 acted on, 2 held (the block that rested on a thin laptop chain, and the pass of a card inside a fraud ring).
Example id fraud_review (uv run grounded-decision examples fraud_review)

The decision today

A card issuer's risk model scores each authorisation and labels it suspicious or clean. Today's rule blocks on suspicious and passes on clean, whatever else is known. Both kinds of mistake cost money:

Mistake What it costs
Block a legitimate customer Declined at the till, support call, churn risk, possible complaint.
Pass a fraudster The charge, the chargeback handling, and more charges on the same card.
Hold for a reviewer Reviewer time, and the customer waits while the authorisation is decided by your fallback (see What happens next).

Where today's rule goes wrong

Take two $840 electronics purchases, both flagged suspicious by the model.

  • Card A belongs to an account in a device ring: sixteen accounts that log in from the same eight phones in rotation. Each account shares a phone with about five others, and thirteen of the sixteen have been confirmed as fraud in past reviews. Blocking this card is the job.
  • Card B belongs to an account linked to three others only through a chain of shared laptops: account 900 shares laptop A with 901, 901 shares laptop B with 902, 902 shares laptop C with 903. Two of those four were confirmed fraud in the past; the links barely predict each other. This is what a family laptop, an office PC, a VPN exit or an internet café looks like.

Today's rule blocks both, because it never looks at the neighbourhood. The customer behind card B is declined at the till, and fraud ops hears about it from the complaint.

flowchart LR
    subgraph A["Card A: dense device ring (16 accounts, 40 links)"]
      a1((acct)) --- a2((acct)) --- a3((acct)) --- a1
      a2 --- a4((acct)) --- a3
      a4 --- a5((…))
    end
    subgraph B["Card B: thin laptop chain (4 accounts, 3 links)"]
      b0((900)) -- laptop A --- b1((901)) -- laptop B --- b2((902)) -- laptop C --- b3((903))
    end

What the system checks, in your terms

The library sees the same suspicious flag, plus a summary of the flagged account's related cases: the accounts that shared a device with it in the last 30 days, which of them are linked to which, and each account's last review outcome. It runs six checks; all six must pass for the proposed action to be carried out. Two of them check the policy itself and pass in every row here (see the six checks). The four that read your data are:

Check Question, in fraud terms Reads Fails when What a hold means
Neighbour agreement Are there enough linked accounts, agreeing with each other often enough, that their review history says something about this account? Shared-device links and each account's last review outcome Few links per account, or linked accounts' outcomes are close to a coin flip "The neighbourhood is too thin or too mixed to lean on."
Knife-edge Is the neighbourhood's link density far enough from your "this is a ring" cut-off that a few missing or extra logins would not change the reading? Number of accounts, number of links, your ring-density cut-off Density is inside the uncertainty window around the cut-off. Small neighbourhoods have wide windows. "Too few accounts to tell a ring from coincidence."
Unusual cases Does this authorisation's model score look like the scores of recent cases we got right? Model score for this auth; scores of the last 40 confirmed cases (the calibration set) The score is more extreme than almost all of the calibration set "The model has not been right on cases like this recently."
Support Do most of the linked accounts carry the outcome the action implies (confirmed fraud for block, cleared for pass)? Each account's last review outcome Fewer than half the accounts carry that outcome "The model and the linked accounts disagree."

Strong evidence can point the other way

Neighbour agreement measures whether linked accounts agree with each other, not whether they agree with the proposed action. A dense ring of confirmed fraudsters is "strong evidence" for any action, including pass. The support check is what catches that: the demo's third row is a clean $12 grocery purchase on a card inside the same fraud ring. Neighbour agreement, the knife-edge and the unusual-case checks all pass; only 3 of the 16 accounts were cleared, so support fails (0.19, below 0.50) and the pass is held for a reviewer.

This works because the example passes the outcome each action implies (SUPPORTING_LABEL: block needs confirmed fraud, pass needs cleared) when it builds the evidence. Without it, the support check passes with support not measured, and the same clean event would act pass. Do not read an act pass as "the neighbourhood vouched for this customer" unless support is measured.

The knife-edge check also needs a word of honesty: in the demo, its cut-off (0.08 link density) is a policy number, and with only four accounts its uncertainty window is so wide that it fails almost regardless of density. That is useful here (four accounts really is too few to judge), but it means small neighbourhoods are held by this check alone even when they look decisive.

Data you provide

Input Source system Refresh Owner Minimum / quality bar
Device logins: (account_id, device_id) rows for the flagged account's neighbourhood, last 30 days Device-fingerprint or login warehouse Per authorisation (or a cache refreshed every few minutes) Fraud data engineering Exclude devices everyone shares (public Wi-Fi exits, app-store test devices) or they will link unrelated accounts.
Last review outcome per account (CONFIRMED_FRAUD = 1, CLEARED = 0) Case-management / review-outcomes table As reviews close Fraud ops Accounts never reviewed count as cleared in the demo; decide if that is right for you. Never include the current authorisation's own outcome.
Model score for this authorisation (candidate_model_score) Risk model Per authorisation Fraud data science Same scale as the calibration set.
Calibration set: model scores on recent confirmed cases (RECENT_CONFIRMED_CASE_SCORES, 40 in the demo) Review-outcomes table joined to model scores Daily Fraud data science At least 9 scores at the default 10 % level, or the unusual-cases check can never pass; 40+ is better.
Ring-density cut-off (RING_DENSITY_THRESHOLD, 0.08) Your history Quarterly Fraud lead + data science The link density at which past rings and past innocent clusters were hardest to tell apart. See Choose thresholds and calibration.

device_graph_evidence in fraud_review.py turns the first two inputs into the evidence summary: it links every pair of accounts that used the same device and labels each account with its last review outcome.

Worked example

These rows come from uv run grounded-decision examples fraud_review.

Row Event Neighbourhood Agreement check Knife-edge check Unusual-cases check Today's rule This system
1 suspicious, $840 electronics Dense ring: 16 accounts, 40 shared-device links Pass: many links, outcomes consistent Pass: density well away from the cut-off Pass block act block
2 suspicious, $840 electronics Laptop chain: 4 accounts, 3 links Fail: 1.5 links per account, linked outcomes barely agree Fail: 4 accounts is too few to judge density Pass block hold; proposed block kept on the record
3 clean, $12 grocery Same dense ring as row 1 Pass Pass Pass pass hold: support 0.19 (3 of 16 accounts cleared); proposed pass kept on the record

The support check passes in rows 1 and 2 (13 of 16 accounts confirmed fraud, 0.8125; 2 of 4, 0.5) and fails in row 3. Row 2 is held because the neighbourhood is too thin: the model's score looked fine, which is exactly why a score cut-off alone would have frozen the card. Row 3 is held because the model and the neighbourhood disagree: the model says clean, the linked accounts say fraud. Over the three rows, today's rule acts three times; this system acts once and holds twice (blocked_overconfident_acts: 2 in the --json summary).

The numbers behind each check

Neighbour agreement uses \(d\), the average number of links per account, and \(\lambda\), how much more often linked accounts share an outcome than chance (0 = chance, 1 = always). Evidence strength is \(d\lambda^2\); the check passes when it is above \(1 + 0.05 = 1.05\).

Dense ring (rows 1, 3) Laptop chain (row 2)
accounts \(n\) 16 4
links per account \(d\) 5.0 1.5
agreement \(\lambda\) 0.55 0.333 (two links agree, one disagrees)
evidence strength \(d\lambda^2\) 1.513 (above 1.05: pass) 0.167 (below 1.05: fail)
link density \(\hat p\) 0.333 0.5
ring cut-off 0.08 0.08
knife-edge half-width at this \(n\) 0.209 0.5 (the cap)
distance from cut-off 0.253 (outside: pass) 0.42 (inside: fail)
unusualness p-value 1.0 (above 0.10: pass) 1.0 (pass)
support share 13/16 = 0.8125 for block (pass); 3/16 = 0.1875 for pass (fail) 2/4 = 0.5 for block (pass: the rule is "at least 0.5")

The half-width is \(\ln(1/(2\varepsilon))/(\ln n)^2\) with \(\varepsilon = 0.1\), capped at 0.5. The unusualness p-value is \((1 + \#\{\text{calibration scores} \ge \text{this score}\}) / (1 + 40)\). The demo sends a fixed model score of 0.10, below every calibration score (0.40 to 1.18), so this check cannot fail in this demo. With your own scores it can.

The record's reason for row 2 reads: d λ² = 0.1667 vs 1+0.050 (at or below KS; abstain); evidence density inside operational window (…). "KS" is the Kesten–Stigum line at 1; "operational window" is the knife-edge window. Full derivations: neighbour agreement, knife-edge, unusual cases.

What happens next

flowchart TD
    A[Authorisation flagged] --> D{All six checks pass?}
    D -- yes --> X[Carry out block or pass]
    D -- no --> H[Hold: record with proposed action and reason]
    H --> F[Authorisation fallback now: step-up or soft decline]
    H --> Q[Review queue]
    Q --> R[Reviewer decides]
    R --> L[Outcome written to review table]
    L --> N[Next neighbourhoods and calibration set]
Verdict Who is told What they see Time limit If nobody acts in time Feedback
Act block Cardholder (decline), fraud ops dashboard action=block, the full decision record Immediate n/a Confirmed or reversed after the customer contacts you; write the outcome to the review table.
Act pass Nobody (normal authorisation) action=pass Immediate n/a Chargebacks later become CONFIRMED_FRAUD labels.
Hold Fraud review queue proposed_action (block or pass), the failed checks and the reason text The authorisation itself needs an answer in well under a second, so a hold cannot wait for a person. Choose a fallback for the authorisation (step-up authentication such as 3-D Secure or a one-time passcode, or a soft decline with a "call us" message) and a review deadline for the account (for example, before the next authorisation on the card or within your queue's SLA). Apply your fallback; do not silently convert the hold into a block. The reviewer's ruling becomes the account's review outcome and enters the next neighbourhoods and, once confirmed, the calibration set.

The module never blocks or declines on a hold; it hands back the proposed action and the reason. What the authorisation does in the meantime is your policy. A common choice is step-up authentication: the genuine customer on the family laptop gets through, and a fraudster usually does not.

A hold is also cheap to retry: if the reviewer finds one more independent device link and the neighbourhood becomes dense and consistent, sending the same event again with the rebuilt evidence can act block.

If the authorisation path combines fraud review with another check (for example, a sanctions screen) using composition, the combined decision acts only when every part acts; a hold in either part holds the whole.

Tuning and expected workload

Setting Policy or data? How to choose Too tight Too loose
Ring-density cut-off (0.08) Data Density at which past rings and innocent clusters overlap most More neighbourhoods land near it; more holds Rings and households are treated alike
Agreement margin (0.05 above the line at 1) Policy Keep the default unless reviewers find holds that were obviously decidable More holds on borderline rings Thin rings act automatically
Unusualness level α (0.10) Policy Fraction of "right" cases you accept treating as unusual More holds Fewer holds on odd scores
Support minimum (0.5) Policy Learn it from history: the share of linked accounts that must carry the action's outcome More holds where the model and the neighbourhood half-agree Passes and blocks against a mixed neighbourhood
Calibration set size (40) and refresh (daily) Data Enough recent confirmed cases to cover the score range — Stale scores make new cases look normal

Expected holds. The demo has three hand-built rows, so it cannot tell you your hold rate. The knife-edge window is the main driver for small neighbourhoods: its half-width is 0.5 (the cap) up to about 6 accounts, about 0.30 at 10, 0.21 at 16, 0.12 at 40 and 0.08 at 80. If many of your flagged accounts have fewer than about ten device neighbours, most of them will be held. Estimate the rate on last month's flagged authorisations before launch: Estimate impact before launch.

Limits, risks and compliance

  • Direction is checked only through support (see the warning above). The model's suspicious/clean call sets the direction; the support check holds it when most linked accounts carry the opposite outcome, and only when the evidence carries the support share. Never-reviewed accounts count as cleared in the demo, which inflates support for pass.
  • The demo's unusual-cases check cannot fail, because it sends a fixed score. Do not conclude from the demo that the model score is being scrutinised; with your real scores it is.
  • Synthetic data. The ring, the chain and the scores are constructed to show one pass and one hold. They are not drawn from real fraud data.
  • Latency. Building a 30-day device neighbourhood per authorisation is a warehouse query. Precompute neighbourhoods per account and refresh them on a schedule if you cannot afford it inline. The decide call itself has a 50 ms target (checked by a test on the library, not a timer); see Limits and performance.
  • Shared devices. Hotel PCs, carrier-grade NAT and corporate laptops can create large, dense, consistent neighbourhoods of unrelated customers. Filter them out when you build the graph.
  • Fair treatment. Device sharing correlates with household size and income. Review held and blocked rates by customer segment.

Wiring it in

The pieces of src/grounded_decision/examples/fraud_review.py map onto an authorisation path:

  1. At service start, build the policy once: fraud_module = build(). It is a two-path policy: clean proposes pass, suspicious proposes block.
  2. Daily, refresh RECENT_CONFIRMED_CASE_SCORES from the review outcomes table.
  3. Per authorisation, fetch the flagged account's device logins and each linked account's last review outcome, and build the evidence:
from grounded_decision.examples.fraud_review import (
    SUPPORTING_LABEL,
    device_graph_evidence,
)

# After restoring the card's saved state:
proposed_action = fraud_module.peek_proposed_action(event.name)

evidence = device_graph_evidence(
    device_logins,  # [("acct_000", "phone_0"), ...]
    last_review_outcome,  # {"acct_000": CONFIRMED_FRAUD, ...}
    candidate_model_score=model_score_for_this_auth,
    supporting_label=SUPPORTING_LABEL[proposed_action],  # block: fraud, pass: cleared
)
  1. Decide through the stateless entry point, passing the card's saved state (or None for a new card):
response = serve_one_authorisation(fraud_module, saved_state_for_card, event, evidence)
save_card_state(card_id, response["extras"]["state"])
append_to_audit_log(response)  # the full decision record
if response["verdict"] == "act":
    execute(response["action"])  # "block" or "pass"
else:
    apply_authorisation_fallback(card_id)  # step-up or soft decline
    enqueue_for_review(card_id, response["proposed_action"], response["reason"])

save_card_state, append_to_audit_log, execute, apply_authorisation_fallback and enqueue_for_review are your own functions.

  1. Concurrency. A policy module holds one current state and is not thread-safe. Restore the card's state at the start of every request under a lock, or give each worker its own module. The reference service shows the pattern: Build a decision service.

Run it yourself

uv run grounded-decision examples fraud_review
uv run grounded-decision examples fraud_review --json

The text report prints one block per row ("tick"). How to read it: Example library: reading a report. HOLD and hold_for_human in the output are this page's "hold" (the record's verdict field says abstain).

Further reading

Results this case relies on
Check Result Math catalog number Trust level of the check
Neighbour agreement Kesten–Stigum reconstruction 229 proven (lean) that labels are recoverable above the line; holding at or below it is policy (paper on a failing row)
Knife-edge Friedgut–Kalai sharp thresholds 186 practical (operational): the proven width is far wider than 1 at every realistic size
Unusual cases Split conformal prediction — published
Support A counting rule with an explicit minimum — practical (operational)
Policy shape and progress Star height; parity and mean-payoff games 134, 104 proven (lean)

See Trust levels and honest limits.