openpay: a PEP match is recorded as a PEP match, not an OFAC sanctions hit #269

Merged
dcharlot merged 1 commit from fix/openpay-pep-not-ofac into main 2026-10-15 21:19:06 -04:00
7 changed files with 159 additions and 38 deletions

View file

@ -239,7 +239,7 @@ pub async fn annotate_pep_flags(
#[must_use]
pub fn build_pep_index_from_names(names: &[&str]) -> SanctionsIndex {
use chrono::Utc;
use op_screening::{EntityType, SanctionedEntity, SanctionsList};
use op_screening::{EntityType, SanctionedEntity, WatchList};
let entities = names
.iter()
@ -258,10 +258,10 @@ pub fn build_pep_index_from_names(names: &[&str]) -> SanctionsIndex {
// "PEP" so callers can distinguish from sanctions programs.
programs: vec!["PEP".to_string()],
last_updated: Utc::now(),
// PEP lists aren't issued by treasury authorities, so we
// reuse a sanctions-list discriminant slot for index purposes
// only; the operator-facing surface should not surface this.
source_list: SanctionsList::OfacConsolidated,
// A PEP entry names its own list. It once borrowed
// `OfacConsolidated`, and every PEP match was then recorded in
// the hash-chained audit log as an OFAC sanctions match.
source_list: WatchList::Pep,
})
.collect();
SanctionsIndex::build(entities)
@ -312,6 +312,37 @@ mod tests {
}
}
/// A PEP match is recorded as a PEP match. The PEP index once tagged its
/// entries `OfacConsolidated`, and the screener's signed, hash-chained
/// audit row then stated an OFAC sanctions match for a politically
/// exposed person: a designation where there was a due-diligence flag.
#[tokio::test]
async fn a_pep_match_is_audited_as_a_pep_match_not_a_sanctions_match() {
let pep_screener = Screener::new(
build_pep_index_from_names(&["Vladimir Putin"]),
ScreenerConfig::default(),
AuditLog::new(),
);
let req = ScreenRequest {
name: "Vladimir Putin".into(),
dob: None,
address: None,
additional_ids: vec![],
};
let res = pep_screener.screen(&req).await.expect("screen");
assert!(!res.hits.is_empty(), "the fixture matches its own name");
for h in &res.hits {
assert_eq!(h.entity.source_list, op_screening::WatchList::Pep);
assert!(!h.entity.source_list.is_sanctions());
}
pep_screener.with_audit_log(|log| {
log.verify().expect("the chain verifies");
let row = log.entries().last().expect("the screen was audited");
let (_, list) = row.top_hit.as_ref().expect("a row with hits names its top hit");
assert_eq!(list, "PEP list", "{row:?}");
});
}
#[tokio::test]
async fn pep_flagging_fires_on_fixture_list() {
let pep_idx = build_pep_index_from_names(&["Hugo Chavez", "Vladimir Putin"]);

View file

@ -101,8 +101,10 @@ pub use bsa::{CtrHelper, SarHelper, Transaction, Trigger, TriggerKind};
pub use error::{Error, Result};
pub use lists::{
Address, CountryCode, EntityType, Identification, IdentificationKind, SanctionedEntity,
SanctionsList,
WatchList,
};
#[allow(deprecated)]
pub use lists::SanctionsList;
pub use matching::{MatchMethod, MatchScore, MatchedField, screen};
pub use normalize::{NormalizedName, normalize};
pub use screener::{

View file

@ -1,17 +1,30 @@
//! Sanctions list domain model.
//! Watch-list domain model: sanctions lists, and the PEP and adverse-media
//! datasets screened beside them.
//!
//! Each authority publishes a slightly different schema, but they all
//! agree on the same skeleton: an entity (person, organisation, vessel,
//! or aircraft) carries a primary name, aliases, identifying numbers,
//! addresses, and one-or-more sanctions programs it is named under.
//! [`SanctionedEntity`] is the union we ingest into.
//! [`SanctionedEntity`] is the union we ingest into. A PEP or adverse-media
//! entry takes the same shape, and its [`WatchList`] says it is not a
//! sanctions designation.
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
/// Which authority published the entry.
/// The list an entry came from: a sanctions authority's list, or a
/// politically-exposed-person or adverse-media dataset.
///
/// These are different findings, and a match records which it was. A
/// sanctions match is a designation (block, report); a PEP match calls for
/// enhanced due diligence and is not a designation; an adverse-media match is
/// a reason to review. A match is never recorded under a list it did not come
/// from: the label of an entry's list is what the hash-chained audit row
/// ([`crate::AuditLog`]) states, so a PEP entry tagged with a sanctions
/// authority would state a sanctions match that never happened.
/// [`WatchList::is_sanctions`] tells the two apart.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SanctionsList {
pub enum WatchList {
/// US Treasury — Specially Designated Nationals (SDN).
OfacSdn,
/// US Treasury — Non-SDN Consolidated Sanctions list.
@ -28,9 +41,14 @@ pub enum SanctionsList {
CanadaSema,
/// Japan Ministry of Finance.
JapanMof,
/// A politically-exposed-persons dataset (FATF Recommendation 12),
/// commercial or operator-maintained. Not a sanctions list.
Pep,
/// An adverse-media dataset (negative news). Not a sanctions list.
AdverseMedia,
}
impl SanctionsList {
impl WatchList {
/// Human-readable short name.
#[must_use]
pub const fn label(self) -> &'static str {
@ -43,10 +61,25 @@ impl SanctionsList {
Self::AustraliaDfat => "Australia DFAT",
Self::CanadaSema => "Canada SEMA",
Self::JapanMof => "Japan MOF",
Self::Pep => "PEP list",
Self::AdverseMedia => "Adverse media",
}
}
/// Whether the list is a sanctions authority's: a match on it is a
/// designation. False for [`WatchList::Pep`] and
/// [`WatchList::AdverseMedia`].
#[must_use]
pub const fn is_sanctions(self) -> bool {
!matches!(self, Self::Pep | Self::AdverseMedia)
}
}
/// The former name of [`WatchList`], from when it held only sanctions
/// authorities.
#[deprecated(note = "renamed `WatchList`: it also names PEP and adverse-media lists")]
pub type SanctionsList = WatchList;
/// What kind of thing the entry names.
///
/// Sanctions programs apply to natural persons, legal entities, and to
@ -140,6 +173,61 @@ pub struct SanctionedEntity {
pub programs: Vec<String>,
/// When the upstream list last updated this entry.
pub last_updated: DateTime<Utc>,
/// Which source list this entry came from.
pub source_list: SanctionsList,
/// Which list this entry came from. A PEP or adverse-media entry names
/// its own list here, never a sanctions authority's.
pub source_list: WatchList,
}
#[cfg(test)]
mod tests {
use super::WatchList;
/// Every list, in an order the match below keeps complete: a new list
/// does not compile until it is here, and so in both tests.
const ALL: [WatchList; 10] = [
WatchList::OfacSdn,
WatchList::OfacConsolidated,
WatchList::EuConsolidated,
WatchList::UnConsolidated,
WatchList::HmtUk,
WatchList::AustraliaDfat,
WatchList::CanadaSema,
WatchList::JapanMof,
WatchList::Pep,
WatchList::AdverseMedia,
];
#[allow(dead_code)]
fn all_is_complete(l: WatchList) -> usize {
match l {
WatchList::OfacSdn => 0,
WatchList::OfacConsolidated => 1,
WatchList::EuConsolidated => 2,
WatchList::UnConsolidated => 3,
WatchList::HmtUk => 4,
WatchList::AustraliaDfat => 5,
WatchList::CanadaSema => 6,
WatchList::JapanMof => 7,
WatchList::Pep => 8,
WatchList::AdverseMedia => 9,
}
}
/// The eight authorities are sanctions lists; the PEP and adverse-media
/// datasets are not.
#[test]
fn only_the_authorities_are_sanctions_lists() {
for (i, l) in ALL.into_iter().enumerate() {
assert_eq!(all_is_complete(l), i);
assert_eq!(l.is_sanctions(), !matches!(l, WatchList::Pep | WatchList::AdverseMedia), "{l:?}");
}
}
/// Each list states itself: no two share a label, which is what an
/// audit row records.
#[test]
fn every_list_has_its_own_label() {
let labels: std::collections::HashSet<_> = ALL.iter().map(|l| l.label()).collect();
assert_eq!(labels.len(), ALL.len());
}
}

View file

@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize};
use crate::audit::AuditLog;
use crate::error::Result;
use crate::lists::{Address, Identification, SanctionsList};
use crate::lists::{Address, Identification, WatchList};
use crate::matching::{MatchScore, screen};
use crate::storage::SanctionsIndex;
@ -35,7 +35,7 @@ pub struct ScreenerConfig {
/// If true, address country mismatch demotes the result similarly.
pub screen_address: bool,
/// If non-empty, only consider hits whose `source_list` is in the set.
pub watchlist_filter: HashSet<SanctionsList>,
pub watchlist_filter: HashSet<WatchList>,
/// Above-`threshold` but below `hit_threshold` lands in
/// `AmbiguousNeedsReview`. Defaults to `0.95`.
pub hit_threshold: f32,
@ -231,7 +231,7 @@ mod tests {
identifications: vec![],
programs: vec![],
last_updated: Utc::now(),
source_list: SanctionsList::OfacSdn,
source_list: WatchList::OfacSdn,
}
}

View file

@ -183,7 +183,7 @@ impl SanctionsIndex {
#[cfg(test)]
mod tests {
use super::*;
use crate::lists::{EntityType, SanctionsList};
use crate::lists::{EntityType, WatchList};
use chrono::Utc;
fn fake(id: &str, name: &str) -> SanctionedEntity {
@ -199,7 +199,7 @@ mod tests {
identifications: vec![],
programs: vec![],
last_updated: Utc::now(),
source_list: SanctionsList::OfacSdn,
source_list: WatchList::OfacSdn,
}
}

View file

@ -19,7 +19,7 @@ use serde::Deserialize;
use crate::error::{Error, Result};
use crate::lists::{
CountryCode, EntityType, Identification, IdentificationKind, SanctionedEntity, SanctionsList,
CountryCode, EntityType, Identification, IdentificationKind, SanctionedEntity, WatchList,
};
/// Boxed future the trait yields. Async fn in traits is stable in
@ -30,7 +30,7 @@ type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// HTTP client config; refresh cadence is owned by the caller.
pub trait ListUpdater: Send + Sync {
/// Which list this updater pulls.
fn list(&self) -> SanctionsList;
fn list(&self) -> WatchList;
/// Pull the list as it stands right now.
fn fetch<'a>(&'a self) -> BoxFuture<'a, Result<Vec<SanctionedEntity>>>;
@ -216,14 +216,14 @@ impl OfacEntryBuilder {
identifications: vec![],
programs: self.programs,
last_updated: Utc::now(),
source_list: SanctionsList::OfacSdn,
source_list: WatchList::OfacSdn,
})
}
}
impl ListUpdater for OfacUpdater {
fn list(&self) -> SanctionsList {
SanctionsList::OfacSdn
fn list(&self) -> WatchList {
WatchList::OfacSdn
}
fn fetch<'a>(&'a self) -> BoxFuture<'a, Result<Vec<SanctionedEntity>>> {
@ -396,14 +396,14 @@ impl EuEntryBuilder {
identifications: vec![],
programs: vec![],
last_updated: Utc::now(),
source_list: SanctionsList::EuConsolidated,
source_list: WatchList::EuConsolidated,
})
}
}
impl ListUpdater for EuUpdater {
fn list(&self) -> SanctionsList {
SanctionsList::EuConsolidated
fn list(&self) -> WatchList {
WatchList::EuConsolidated
}
fn fetch<'a>(&'a self) -> BoxFuture<'a, Result<Vec<SanctionedEntity>>> {
@ -590,14 +590,14 @@ impl UnEntryBuilder {
identifications: vec![],
programs: self.programs,
last_updated: Utc::now(),
source_list: SanctionsList::UnConsolidated,
source_list: WatchList::UnConsolidated,
})
}
}
impl ListUpdater for UnUpdater {
fn list(&self) -> SanctionsList {
SanctionsList::UnConsolidated
fn list(&self) -> WatchList {
WatchList::UnConsolidated
}
fn fetch<'a>(&'a self) -> BoxFuture<'a, Result<Vec<SanctionedEntity>>> {
@ -699,7 +699,7 @@ impl HmtUpdater {
identifications: idents,
programs: r.regime.into_iter().collect(),
last_updated: Utc::now(),
source_list: SanctionsList::HmtUk,
source_list: WatchList::HmtUk,
});
}
Ok(out)
@ -746,8 +746,8 @@ struct HmtRecord {
}
impl ListUpdater for HmtUpdater {
fn list(&self) -> SanctionsList {
SanctionsList::HmtUk
fn list(&self) -> WatchList {
WatchList::HmtUk
}
fn fetch<'a>(&'a self) -> BoxFuture<'a, Result<Vec<SanctionedEntity>>> {
@ -797,7 +797,7 @@ mod tests {
assert_eq!(parsed[0].entity_type, EntityType::Individual);
assert_eq!(parsed[0].programs, vec!["SDGT".to_string()]);
assert_eq!(parsed[1].entity_type, EntityType::Entity);
assert_eq!(parsed[1].source_list, SanctionsList::OfacSdn);
assert_eq!(parsed[1].source_list, WatchList::OfacSdn);
}
#[test]
@ -816,7 +816,7 @@ mod tests {
let parsed = UnUpdater::parse_un_xml(xml).expect("parse");
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].name, "Foo Bar");
assert_eq!(parsed[0].source_list, SanctionsList::UnConsolidated);
assert_eq!(parsed[0].source_list, WatchList::UnConsolidated);
}
#[test]
@ -857,7 +857,7 @@ mod tests {
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].name, "Ivan Ivanov");
assert_eq!(parsed[0].entity_type, EntityType::Individual);
assert_eq!(parsed[0].source_list, SanctionsList::HmtUk);
assert_eq!(parsed[0].source_list, WatchList::HmtUk);
assert_eq!(
parsed[0].dob,
Some(NaiveDate::from_ymd_opt(1970, 1, 1).expect("date"))

View file

@ -7,7 +7,7 @@
use chrono::Utc;
use op_screening::{
AuditLog, EntityType, OfacUpdater, SanctionedEntity, SanctionsIndex, SanctionsList,
AuditLog, EntityType, OfacUpdater, SanctionedEntity, SanctionsIndex, WatchList,
ScreenDecision, ScreenRequest, Screener, ScreenerConfig,
};
@ -26,7 +26,7 @@ fn synthetic_entities(n: usize) -> Vec<SanctionedEntity> {
identifications: vec![],
programs: vec!["TEST".to_string()],
last_updated: Utc::now(),
source_list: SanctionsList::OfacSdn,
source_list: WatchList::OfacSdn,
});
}
// Splice in a single distinguished record we can hit.
@ -42,7 +42,7 @@ fn synthetic_entities(n: usize) -> Vec<SanctionedEntity> {
identifications: vec![],
programs: vec!["RUSSIA-EO14024".to_string()],
last_updated: Utc::now(),
source_list: SanctionsList::OfacSdn,
source_list: WatchList::OfacSdn,
});
out
}