diff --git a/openpay/crates/op-connect/src/screening.rs b/openpay/crates/op-connect/src/screening.rs index bf8b0a9a0..ad2ee448e 100644 --- a/openpay/crates/op-connect/src/screening.rs +++ b/openpay/crates/op-connect/src/screening.rs @@ -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"]); diff --git a/openpay/crates/op-screening/src/lib.rs b/openpay/crates/op-screening/src/lib.rs index a3862c469..f56ebe4a0 100644 --- a/openpay/crates/op-screening/src/lib.rs +++ b/openpay/crates/op-screening/src/lib.rs @@ -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::{ diff --git a/openpay/crates/op-screening/src/lists.rs b/openpay/crates/op-screening/src/lists.rs index d65363105..b74d75981 100644 --- a/openpay/crates/op-screening/src/lists.rs +++ b/openpay/crates/op-screening/src/lists.rs @@ -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, /// When the upstream list last updated this entry. pub last_updated: DateTime, - /// 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()); + } } diff --git a/openpay/crates/op-screening/src/screener.rs b/openpay/crates/op-screening/src/screener.rs index dd8aa668c..a73cc79f8 100644 --- a/openpay/crates/op-screening/src/screener.rs +++ b/openpay/crates/op-screening/src/screener.rs @@ -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, + pub watchlist_filter: HashSet, /// 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, } } diff --git a/openpay/crates/op-screening/src/storage.rs b/openpay/crates/op-screening/src/storage.rs index 45ba8c5dd..66e2ab3da 100644 --- a/openpay/crates/op-screening/src/storage.rs +++ b/openpay/crates/op-screening/src/storage.rs @@ -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, } } diff --git a/openpay/crates/op-screening/src/updater.rs b/openpay/crates/op-screening/src/updater.rs index f5ba3293c..9e7adb06c 100644 --- a/openpay/crates/op-screening/src/updater.rs +++ b/openpay/crates/op-screening/src/updater.rs @@ -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 + 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>>; @@ -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>> { @@ -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>> { @@ -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>> { @@ -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>> { @@ -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")) diff --git a/openpay/crates/op-screening/tests/integration.rs b/openpay/crates/op-screening/tests/integration.rs index 1db6518eb..ec78482c4 100644 --- a/openpay/crates/op-screening/tests/integration.rs +++ b/openpay/crates/op-screening/tests/integration.rs @@ -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 { 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 { identifications: vec![], programs: vec!["RUSSIA-EO14024".to_string()], last_updated: Utc::now(), - source_list: SanctionsList::OfacSdn, + source_list: WatchList::OfacSdn, }); out }