PROOViD AML Docs
Transparency & defensibility

Scoring & decisioning — how a screen becomes a decision

This is the authoritative page for how PROOViD AML turns a name you submit into a match classification and a 0–100 risk score and band, and §5 is a worked example you can reproduce yourself with two curl calls. The companion page Decision rules is the single source of truth for the rules that resolve those matches to Pass / Review / Fail — the full decision matrix, the named rules, the floors, Enhanced Due Diligence and the decisioning knobs. Nothing here is a black box: every number and every branch is drawn straight from the scoring engine. For how the underlying watchlist data is built see How the data is built; for the response shape see API reference. Deeper source-of-truth notes live in the engineering docs docs/decision-criteria.md, docs/decision-matrix.md and docs/decision-priority.md, which this page consolidates.

Two different numbers, two different questions. A result carries a name-match score (0–1: is this the same person?) and a riskScore (0–100: how risky is this hit?). They are not the same scale — a perfect name match does not imply a high risk score. A head of state and a city mayor can both match at name-score 1.0 and land in different bands and different decisions, because risk is driven by who they are and what category they're in, not by how well the name matched.

1 · The pipeline, end to end

Every screen runs the same five ordered stages. Each stage's output is the next stage's input, and the response returns the artefacts of all five.

  1. Retrieval. Candidate entities are pulled from the watchlist corpus by a Postgres trigram (pg_trgm) name search, optionally widened by phonetic and nickname expansion. This stage is about recall — cast a wide net; precision is imposed downstream.
  2. Classification. Each candidate is judged on the name × date-of-birth axis into TruePositive (full name + exact full-DoB agreement), PotentialMatch (a real hit a human should confirm), or FalsePositive (ruled out — a different person). Only TruePositive and PotentialMatch are real and carry forward.
  3. Scoring. The real matches produce a single riskScore (0–100) and a riskBand (Minimal / Low / Medium / High) — §2.
  4. Decision. Each real match is resolved through the tenant's decision matrix to a base Pass / Review / Fail, then aggregated (worst wins) and adjusted by the band policy and floors — §4.
  5. Return. The response carries isMatch, score, classification, riskScore, riskBand, decision, the reasonCodes, and the contributing factors[] — the full audit trail of the four stages above.
flowchart LR
  R["1 Retrieval
pg_trgm (+ phonetic / nickname)"] --> C["2 Classification
name x DoB"] C --> CL{"TruePositive /
PotentialMatch /
FalsePositive"} CL -- "real only" --> S["3 Scoring
0-100 + band"] S --> D["4 Decision
matrix -> Pass / Review / Fail"] D --> OUT["5 Return
score, riskScore, riskBand,
decision, reasonCodes, factors[]"]

2 · The 0–100 risk score

Only real matches feed the score — TruePositive or PotentialMatch. A warning type the tenant has switched off is dropped before scoring, as if the hit never happened. Each surviving match contributes:

flowchart LR
  M["Real matches only
(TruePositive / PotentialMatch,
enabled warning types)"] --> C["contribution =
categoryWeight x countryWeight x matchConfidence"] C --> MAX["aggregate = MAX single contribution
(the worst hit governs, not the sum)"] MAX --> N["normalize: a full-confidence,
weight-1.0 match = the tenant's High threshold"] N --> S["clamp 0-100 -> riskScore"]
  • matchConfidence — the provider name-match score, clamped to 0–1.
  • categoryWeight — the tenant's weight for the match's canonical category (sanctions / pep / adverse_media); criminal-category hits use the dedicated CriminalWeight.
  • countryWeight — the tenant's weight for the matched entity's nationality.
The score is a MAXIMUM, not a sum. AML risk is governed by the single worst hit, not by how many weak signals pile up — five weak PEP overlaps never outscore one strong sanctions hit. The dominant contribution is normalized so that a full-confidence (1.0), full-weight (1.0) match lands exactly on the tenant's High threshold — which guarantees a single strong sanctions hit always reaches the High band — then the result is clamped to 0–100.

Banding

The score is bucketed against three tenant-configured thresholds (defaults shown):

ConditionBand
score ≥ HighThreshold (default 85)High
score ≥ MediumThreshold (default 60)Medium
score ≥ LowThreshold (default 30)Low
otherwiseMinimal

3 · Classification & evidence tiers

The decision rules live on one page — and it is not this one. How a match becomes Pass / Review / Fail — the evidence tiers, the full decision matrix, the named rules, the floors, Enhanced Due Diligence and every decisioning knob — is maintained in Decision rules, which is the single source of truth. This page owns the score. What follows is a signpost, not a second rulebook: if the two ever disagree, Decision rules is correct.

Two name bars, two different questions

Name strength is judged by the fuzzy name scorer against two separate bars, and they do not answer the same question. Conflating them is the difference between surfacing a hit for a human and rejecting a customer automatically:

BarValueThe question it answers
ExactNameSimilarity1.00 (per-tenant)May this match auto-block? A Fail requires a name that is 100% identical to the watchlist name after normalisation. Anything below the bar, however close, is an algorithmic variation: it still surfaces, but its Fail is clamped to Review with the reason code NAME_NOT_EXACT.
NearExactNameSimilarity0.97 (engine)Should this match surface for a human? The recall bar. It governs whether a DoB mismatch surfaces (DobMismatchHighScoreThreshold), the rarity prior, and default retrieval options. It does not authorise a block.
Why "score ≥ 0.97" was never the same as an exact name. The provider score is a combined figure — name similarity plus date-of-birth and nationality corroboration, clamped to [0,1]. A name similarity of 0.96 lifted to 1.0 by a nationality agreement used to read as "exact" and auto-Fail on a name that was never identical. The exact bar is now measured on the name alone. A tenant that wants the historical behaviour sets ExactNameSimilarity to 0.97 — no code change.

The five evidence tiers

The name signal is combined with the date-of-birth signal — a corroborated full-DoB agreement, two full DoBs that differ but stay inside tolerance, a full DoB that mismatches beyond tolerance, or no usable DoB — to place a real match into one of five evidence tiers. The tier is the row dimension of the decision matrix, which is tabulated in full in Decision rules §3. Note which tiers require the name bar to be cleared and which do not:

Evidence tierName required?Date of birth
ExactNameExactDobNo — date-drivenFull DoB agrees within DobYearTolerance — the confirmed tier.
ExactNameNoDobYes — 100% identicalNo corroborating full DoB on one or both sides.
ExactNameDobNearMatchYes — 100% identicalFull DoB on both sides, differing but within tolerance — close, not identical.
ExactNameDobMismatchNo — date-drivenFull DoB present on both sides but differs beyond tolerance (DM-3).
WeakOrPartialName— (below the bar)No corroborating full DoB.
Tiering and blocking are two different questions. Two of the five tiers — ExactNameExactDob and ExactNameDobMismatch — are reached on the date of birth alone, whatever the name scored, so that a hit whose DoB contradicts the subject still surfaces even on a name that is not character-identical. The ExactName… prefix on those two tier names is historical: it describes the date half. The 100%-identical bar is applied after tiering, to the resolved decision. Nothing stops surfacing; only automatic blocking is narrowed.
DM-3 — an exact name with a wrong DoB is surfaced, not silently cleared. Such a candidate used to be classified FalsePositive and dropped (it Passed by absence). It is now surfaced as a PotentialMatch in the ExactNameDobMismatch tier, so the tenant matrix decides it — Review by default — rather than the pipeline clearing it. A merely partial name with a differing DoB is still ruled out as a different person.
#368 — per-tenant DobMismatchSurfacing. When an exact name carries a mismatching DoB, which hits surface is a per-tenant choice:
  • HighScore (the default) — surface a hit for a token-exact name and for a partial name whose provider score clears the near-exact bar (DobMismatchHighScoreThreshold, default 0.97). This stops a watchlist entry that carries an extra middle name yet still scores ~1.0 from being silently dropped on a DoB difference — a human confirms instead.
  • StrictExactName — only a token-exact name surfaces a DoB mismatch; a strong-but-partial name with a differing DoB is discarded. The pre-#368 behaviour, retained as an opt-in.
On the wire it is a readable string — "HighScore" / "StrictExactName".

4 · From score to decision

Each real, enabled match gets a base decision from an explicit matrix keyed by category × evidence tier × multiplicity (single vs 2+ real matches) — four categories × the five tiers of §3 × two multiplicities, 40 cells in all. The authoritative outcome is the strongest base decision across all matches — precedence is always Fail > Review > Pass — after which the floors, the band policy and the post-screening Enhanced Due Diligence check apply.

The 40-cell grid is not reproduced here, on purpose. It lives once, in Decision rules §3, together with the named rules (§4), the floors and band policy (§5), the graduated EDD ladder (§6) and every decisioning knob (§7). A normative table kept in two places drifts; this one did, and a client reading the old copy here would have concluded that a 0.98 name auto-blocks. It does not — see §3.

Three structural invariants hold whatever a tenant configures — a misconfiguration cannot breach them:

  1. A watchlist hit never auto-clears. A sanctions / criminal cell can never resolve to Pass; a Pass override on one is raised to Review.
  2. Multiplicity never lowers. A Multiple cell is never softer than its Single sibling — 2+ matches can only hold or escalate.
  3. A Fail requires an exact name. If the matched name does not clear the tenant's ExactNameSimilarity bar (§3), a resolved Fail is clamped to Review, with NAME_NOT_EXACT recorded so the downgrade is never silent.
The primary (decisive) factor named on the result is chosen by the tenant's DecisionPriorityOrder ladder (default sanctions > criminal > PEP1 > PEP2 > PEP3 > PEP4 > adverse media). The ladder only decides which match is the headline "why" — it never changes the Pass/Review/Fail outcome. See Decision rules §2.

5 · A worked example you can reproduce

To keep this honest and reproducible we screen a well-known sanctioned public figure (Bashar al-Assad — on the actual OFAC / EU / UK lists, a public watchlisted subject, not a private individual) against the live sandbox. Two curl calls; run them and you get the same shape.

# 1 - register a throwaway sandbox tenant; copy the aml_test_ key from the response into AML_KEY
curl -s -X POST https://wl.proovid.com/v1/register \
  -H "Content-Type: application/json" \
  -d '{"companyName":"Docs Example","email":"you@example.com"}'
export AML_KEY="aml_test_the-key-from-step-1"

# 2 - screen a subject that hits a sanctions entry
curl -s -X POST https://wl.proovid.com/v1/screenings/check \
  -H "X-Api-Key: $AML_KEY" \
  -H "Content-Type: application/json" \
  -d '{"fullName":"Bashar al-Assad"}'

The real response (captured 2026-08-09, abridged to the fields that matter):

{
  "isMatch": true,
  "score": 1,
  "classification": "PotentialMatch",
  "riskScore": 85,
  "riskBand": "High",
  "decision": "Fail",
  "matchedEntities": [
    { "sourceList": "UK",       "matchedName": "Bashar AL ASSAD",  "score": 1, "classification": "PotentialMatch", "rejectionTag": "sanctions" },
    { "sourceList": "EU",       "matchedName": "Bashar AL-ASSAD",  "score": 1, "classification": "PotentialMatch", "rejectionTag": "sanctions" },
    { "sourceList": "OFAC",     "matchedName": "Bashar AL-ASSAD",  "score": 1, "classification": "PotentialMatch", "rejectionTag": "sanctions" },
    { "sourceList": "WIKIDATA", "matchedName": "Bashar al-Assad",  "score": 1, "classification": "PotentialMatch", "rejectionTag": "pep" }
  ],
  "factors": [
    { "category": "sanctions", "sourceList": "UK",       "country": "Syria", "contribution": 1 },
    { "category": "sanctions", "sourceList": "EU",       "country": null,    "contribution": 1 },
    { "category": "sanctions", "sourceList": "OFAC",     "country": "Syria", "contribution": 1 },
    { "category": "pep",       "sourceList": "WIKIDATA", "country": "Syria", "contribution": 1 }
  ]
}

Stepping the real numbers through §2–§4:

StageWhat happens on this response
ClassifyThe submitted name is token-exact to Bashar AL-ASSAD, so it is a Full name match. No DoB was supplied, so it cannot reach full-DoB agreement → classification: PotentialMatch, not TruePositive. The submitted name is 100% identical to the list name after normalisation, so it clears the ExactNameSimilarity bar (§3); with no DoB on either side ⇒ evidence tier ExactNameNoDob.
ScoreEvery top factor's contribution = categoryWeight(1.0) × countryWeight(1.0) × matchConfidence(1.0) = 1.0. The aggregate is the maximum = 1.0. Normalized: 1.0 × HighThreshold(85) = 85 ⇒ riskScore: 85.
Band85 ≥ 85 ⇒ riskBand: High.
DecideThe sanctions factors sit in sanctions × ExactNameNoDob → the matrix cell is the tenant's unconfirmed posture SanctionsUnconfirmedDecision, Review by default and Fail for a strict tenant. The PEP head-of-state hit also floors to Review. Aggregate precedence (Fail > Review > Pass) ⇒ decision: Review on a default tenant, Fail on a strict one. The unconfirmed-watchlist floor then stops the band policy downgrading that Review to Pass.
This capture predates the 2026-08-23 decisioning change. The response above was taken on 2026-08-09, when a sanctions hit with an exact name and no date of birth Failed outright (rule DM-1). That cell now follows the tenant's unconfirmed posture — Review by default — so the same two curl calls today return "decision": "Review" on a fresh sandbox tenant. Every other number is unchanged: score 1, riskScore 85, riskBand High, the same four factors. A tenant that wants the old outcome sets SanctionsUnconfirmedDecision = Fail. The change and its rationale are in Decision rules §4.
The DoB-mismatch contrast (#368), in the abstract. Screen the same person with an extra middle name — a partial name — and a DoB that disagrees with the entity's (the well-known "exact name, wrong DoB" case). Under StrictExactName that partial-name DoB-mismatch is discarded as a FalsePositive and the screen would Pass; under HighScore (the default), because the provider score clears 0.97, the same hit is surfaced as a PotentialMatch and the screen goes to Review. Same data, one tenant knob — this is exactly the gap #368 closes.
Sandbox registration is rate-limited; if step 1 returns 429, retry shortly. The numbers above are the real captured values — if the lists have moved since, your riskScore and top factors[] will still step through the formula identically.

6 · What's tenant-configurable in the score

Every number in §2 is per-tenant configuration (RiskProfileConfig + the matching profile), not a fixed constant. These are the knobs that move the score and the band:

KnobDefaultWhat it changes
CategoryWeights[category]1.0Multiplier per canonical category (sanctions, pep, adverse_media) in the score contribution.
CountryWeights[isoCode]1.0Multiplier per matched entity's nationality.
CriminalWeight1.0Multiplier specifically for criminal-category hits (terrorism / bring-your-own criminal-records).
LowThreshold / MediumThreshold / HighThreshold30 / 60 / 85Band cut-offs (§2); High also anchors the score normalization.
DobYearTolerance1How many years two DoBs may differ and still count as agreeing — it sets the evidence tier (§3): inside the tolerance and non-identical is the near-match tier, beyond it is the DM-3 mismatch tier.
DobMismatchSurfacing / DobMismatchHighScoreThresholdHighScore / 0.97#368 — whether an exact-name-with-wrong-DoB surfaces for a token-exact name only (StrictExactName) or also for a near-exact partial name (HighScore, the default). §3.
ExactNameSimilarity1.00The bar at which a name counts as exact — and therefore the bar a Fail must clear (§3). Set 0.97 to restore the pre-2026-08-23 near-exact blocking behaviour.
The decisioning knobs are documented once, elsewhere. SanctionsUnconfirmedDecision, PepMatchFloorsToReview, EscalateHighToFail, AllowLowBandPass, DecisionMatrixOverrides, the WarningTypes overrides, DecisionPriorityOrder, EddEscalationMaxClass, EnhancedDueDiligenceDefault, DisabledPostScreeningChecks and PepRecencyYears — with their defaults, their validation rules and the GET/PUT routes that set them — are in Decision rules §7.
Honest nuance. The raw name-match confidence itself is engine-level — the ExactNameSimilarity bar, the DobYearTolerance and the DobMismatchSurfacing mode aside, making name-strictness fully per-tenant is a documented pending item (DM-5). Everything else on this page is live per-tenant configuration today, set via the console Settings panel or the tenant provisioning / matching-profile APIs — the same risk profile referenced throughout Coverage & sources.
© PROOViD AML · Developer Docs Decision rules · How the data is built · Coverage & sources · API reference