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.
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.
- 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. - 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), orFalsePositive(ruled out — a different person). OnlyTruePositiveandPotentialMatchare real and carry forward. - Scoring. The real matches produce a single
riskScore(0–100) and ariskBand(Minimal / Low / Medium / High) — §2. - 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.
- Return. The response carries
isMatch,score,classification,riskScore,riskBand,decision, thereasonCodes, and the contributingfactors[]— 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 dedicatedCriminalWeight.countryWeight— the tenant's weight for the matched entity's nationality.
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):
| Condition | Band |
|---|---|
score ≥ HighThreshold (default 85) | High |
score ≥ MediumThreshold (default 60) | Medium |
score ≥ LowThreshold (default 30) | Low |
| otherwise | Minimal |
3 · Classification & evidence tiers
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:
| Bar | Value | The question it answers |
|---|---|---|
ExactNameSimilarity | 1.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. |
NearExactNameSimilarity | 0.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. |
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 tier | Name required? | Date of birth |
|---|---|---|
ExactNameExactDob | No — date-driven | Full DoB agrees within DobYearTolerance — the confirmed tier. |
ExactNameNoDob | Yes — 100% identical | No corroborating full DoB on one or both sides. |
ExactNameDobNearMatch | Yes — 100% identical | Full DoB on both sides, differing but within tolerance — close, not identical. |
ExactNameDobMismatch | No — date-driven | Full DoB present on both sides but differs beyond tolerance (DM-3). |
WeakOrPartialName | — (below the bar) | No corroborating full DoB. |
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.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.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, default0.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.
"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.
0.98 name
auto-blocks. It does not — see §3.Three structural invariants hold whatever a tenant configures — a misconfiguration cannot breach them:
- A watchlist hit never auto-clears. A
sanctions/criminalcell can never resolve to Pass; a Pass override on one is raised to Review. - Multiplicity never lowers. A
Multiplecell is never softer than itsSinglesibling — 2+ matches can only hold or escalate. - A Fail requires an exact name. If the matched name does not clear the tenant's
ExactNameSimilaritybar (§3), a resolved Fail is clamped to Review, withNAME_NOT_EXACTrecorded so the downgrade is never silent.
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:
| Stage | What happens on this response |
|---|---|
| Classify | The 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. |
| Score | Every 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. |
| Band | 85 ≥ 85 ⇒ riskBand: High. |
| Decide | The 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. |
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.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.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:
| Knob | Default | What it changes |
|---|---|---|
CategoryWeights[category] | 1.0 | Multiplier per canonical category (sanctions, pep, adverse_media) in the score contribution. |
CountryWeights[isoCode] | 1.0 | Multiplier per matched entity's nationality. |
CriminalWeight | 1.0 | Multiplier specifically for criminal-category hits (terrorism / bring-your-own criminal-records). |
LowThreshold / MediumThreshold / HighThreshold | 30 / 60 / 85 | Band cut-offs (§2); High also anchors the score normalization. |
DobYearTolerance | 1 | How 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 / DobMismatchHighScoreThreshold | HighScore / 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. |
ExactNameSimilarity | 1.00 | The 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. |
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.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.