Scoring & decisioning — how a screen becomes a decision
This is the one authoritative page for how PROOViD AML turns a name you submit into a
match classification, a 0–100 risk score and band, and a final
Pass / Review / Fail decision. Nothing here is a black box: every number and every branch
is drawn straight from the scoring engine, and §5 is a worked example you can reproduce yourself with two
curl calls. 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
Name strength is judged by the fuzzy name scorer. A name whose confidence reaches the
near-exact bar (NearExactNameSimilarity = 0.97) is treated as an exact /
near-identical name; anything below it is a weaker, partial overlap. That name signal is then combined with
the date-of-birth signal — a corroborated full-DoB agreement, a full DoB that mismatches beyond
tolerance, or no usable DoB — to place a real match into one of four evidence tiers. The
tier is the row dimension of the decision matrix in §4.
| Evidence tier | Name | Date of birth |
|---|---|---|
ExactNameExactDob | Exact / near-exact (≥ 0.97) | Full DoB agrees — the confirmed tier. |
ExactNameNoDob | Exact / near-exact (≥ 0.97) | No corroborating full DoB. |
WeakOrPartialName | Partial / weaker (< 0.97) | No corroborating full DoB. |
ExactNameDobMismatch | Exact / near-exact (≥ 0.97) | Full DoB present on both sides but differs beyond tolerance (DM-3). |
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 (🟡 PROVISIONAL).
When an exact name carries a mismatching DoB, which hits surface is a per-tenant choice:
StrictExactName(default) — only a token-exact name surfaces a DoB-mismatch; a strong-but-partial name with a differing DoB is discarded. Byte-for-byte the historical behaviour.HighScore— also surface a partial-name hit when the 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" / "HighScore".4 · The decision matrix
Each real, enabled match gets a base decision from an explicit matrix keyed by category × evidence-tier × multiplicity (single vs 2+ real matches). The authoritative outcome is the strongest base decision across all matches — precedence is always Fail > Review > Pass — after which the band policy and floors apply. Two structural invariants keep the matrix regulatorily safe: a watchlist cell (sanctions / criminal) can never resolve to Pass, and a Multiple cell is never softer than its Single sibling (2+ matches can only hold or escalate).
The shipped default grid (identical for single and multiple), where P is the
tenant's unconfirmed-hit posture SanctionsUnconfirmedDecision — Review by default, Fail for a
strict tenant:
| Category | ExactName + DoB | ExactName, no DoB | ExactName, DoB mismatch | Weak / partial |
|---|---|---|---|---|
| sanctions | Fail | Fail | P | P |
| criminal / terror | Fail | Fail | P | P |
| pep (all tiers) | Review | Review | Review | Review |
| adverse_media | Fail | Review | Review | Review |
The rules the default cells encode, and the floors applied on top of the aggregate:
| Rule | Effect |
|---|---|
Confirmed sanctions hit (exact full-DoB agreement, or a TruePositive) | Fail regardless of score. An exact name to a sanctioned entity is also Fail with no DoB (DM-1) — a sanctions entry that carries no DoB, common on OFAC, can never reach the ExactDob tier, so anything softer would clear the most common sanctions shape. |
| Unconfirmed sanctions hit (weak, no-DoB, not near-exact) | The tenant's SanctionsUnconfirmedDecision posture — Review by default, Fail for a strict tenant. Never auto-clears to Pass. |
Confirmed PEP match, with PepMatchFloorsToReview on (the default) | Floors the decision to at least Review — a sitting head of state can never quietly Pass, even in a low band. |
| Criminal / terror with a near-exact name | Fail even with no DoB (terrorism is high-stakes) — the sanctions confirmation path extended with a name-only trigger. |
| No real match | Pass. |
Review-policy modifiers
When the aggregate is Review (nothing forced a Fail), two tenant flags can move it — but an
unconfirmed watchlist hit is always floored back to Review, never quietly Passed:
| Condition | Result |
|---|---|
EscalateHighToFail on and band = High | Fail |
AllowLowBandPass on and band ∈ {Minimal, Low} | Pass — unless an unconfirmed sanctions / criminal-terror factor is driving the Review, in which case floored back to Review. |
| Otherwise | Review |
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 docs/decision-priority.md.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://aml-screening.dloizides.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://aml-screening.dloizides.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. Name score 1.0 ≥ 0.97 and no DoB ⇒ 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 → matrix cell Fail (DM-1: an exact name to a sanctioned entity is confirmed even without a DoB). Aggregate precedence (Fail > Review > Pass) ⇒ decision: Fail. The PEP head-of-state hit alone would already floor to Review, but the sanctions Fail dominates. |
StrictExactName that partial-name
DoB-mismatch is discarded as a FalsePositive and the screen would Pass; under
HighScore, 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
Every number and branch above is per-tenant configuration (RiskProfileConfig +
WarningTypeConfig + the matching profile), not a fixed constant:
| 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. |
WarningTypes | all enabled, no overrides | Per-warning-type kill switch (a PEP tier, an adverse-media category) and a tier/category → decision override. |
SanctionsUnconfirmedDecision | Review | Posture P for an unconfirmed sanctions / criminal hit — Review or Fail. Never permits auto-Pass. |
PepMatchFloorsToReview | on | Floors a confirmed PEP match to at least Review, regardless of band. |
DecisionPriorityOrder | sanctions > criminal > PEP1–4 > adverse media | Which match is named PRIMARY; never changes the outcome. |
DecisionMatrixOverrides | none | Sparse per-cell overrides of the §4 grid (category × tier × multiplicity). Normalized so a watchlist→Pass is raised to Review and a softened Multiple is raised to its Single sibling. |
DobYearTolerance | 1 | How many years two DoBs may differ and still count as agreeing (below this ⇒ corroborated; beyond ⇒ mismatch, feeding the tier logic in §3). |
DobMismatchSurfacing 🟡 | StrictExactName | #368 — whether an exact-name-with-wrong-DoB is surfaced strictly (token-exact only) or also for a high-scoring partial name (HighScore, gated by DobMismatchHighScoreThreshold, default 0.97). PROVISIONAL. |
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.