openpay: a PEP match is recorded as a PEP match, not an OFAC sanctions hit #269
7 changed files with 159 additions and 38 deletions
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue