open-standards/openpay/crates/op-screening/src/lists.rs
Transaction Science 2d7de0396c
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 10s
standards workspaces build + test / macOS energy backends (pull_request) Has been skipped
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m4s
standards workspaces build + test / openpay (openpay, openpay, linux-arm64) (pull_request) Successful in 6m13s
openpay: a PEP match is recorded as a PEP match, not an OFAC sanctions hit
op-connect's PEP index tagged every entry `SanctionsList::OfacConsolidated`,
because the enum had no place for a PEP list. Its comment said the operator
surface "should not surface this". But a PEP index is a `Screener`, and every
screen appends `source_list.label()` to the screener's signed, hash-chained
audit log. So each PEP match was written into the tamper-evident compliance
record as "OFAC Consolidated": a sanctions designation (block, report) where
there was a due-diligence flag. An adverse-media index had the same problem:
it too could only tag its entries with a sanctions authority.

- `SanctionsList` is now `WatchList`, the term `ScreenerConfig::watchlist_filter`
  already used. It gains `Pep` ("PEP list") and `AdverseMedia` ("Adverse
  media"), and `is_sanctions()`, which is false for exactly those two. Its
  docs say why a match must never be recorded under a list it did not come
  from. A deprecated `SanctionsList` alias keeps the old name compiling.
- `build_pep_index_from_names` tags its entries `WatchList::Pep`.
- Serialized forms are unchanged: the enum's variant names stay the same, so
  stored indexes still load. No published vector or doc carries the label.

Tests:
- `a_pep_match_is_audited_as_a_pep_match_not_a_sanctions_match` screens a PEP
  fixture and checks that the audit chain verifies and that its row names
  "PEP list".
- `only_the_authorities_are_sanctions_lists` and `every_list_has_its_own_label`
  pin the kinds and labels. An exhaustive match makes a new list fail to
  compile until it is classified.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:47:42 -04:00

233 lines
8.4 KiB
Rust

//! 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. 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};
/// 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 WatchList {
/// US Treasury — Specially Designated Nationals (SDN).
OfacSdn,
/// US Treasury — Non-SDN Consolidated Sanctions list.
OfacConsolidated,
/// EU Council — consolidated financial sanctions list.
EuConsolidated,
/// UN Security Council — consolidated list.
UnConsolidated,
/// HM Treasury (UK) — consolidated list of financial sanctions targets.
HmtUk,
/// Australia DFAT — consolidated list.
AustraliaDfat,
/// Canada SEMA — Special Economic Measures Act regulations.
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 WatchList {
/// Human-readable short name.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::OfacSdn => "OFAC SDN",
Self::OfacConsolidated => "OFAC Consolidated",
Self::EuConsolidated => "EU Consolidated",
Self::UnConsolidated => "UN Consolidated",
Self::HmtUk => "HMT UK",
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
/// specific transportation assets (vessel IMO numbers, aircraft tail
/// numbers) that have themselves been designated.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EntityType {
/// A natural person.
Individual,
/// A company, organisation, or other legal entity.
Entity,
/// A maritime vessel (named by IMO number and ship name).
Vessel,
/// An aircraft (named by registration / tail number).
Aircraft,
}
/// ISO 3166-1 alpha-2 country code.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CountryCode(pub String);
/// Postal / business address attached to an entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Address {
/// Street address line(s), joined with newlines.
pub street: Option<String>,
/// City or locality.
pub city: Option<String>,
/// State, province, or region.
pub region: Option<String>,
/// Postal code.
pub postal_code: Option<String>,
/// ISO 3166-1 alpha-2 country, if known.
pub country: Option<CountryCode>,
}
/// The kind of identifying document or number attached to an entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum IdentificationKind {
/// National identity card.
NationalId,
/// Passport.
Passport,
/// Driver's licence.
DriversLicence,
/// Tax identification number.
TaxId,
/// Maritime IMO number (vessels).
Imo,
/// Aircraft registration / tail number.
AircraftTail,
/// Anything not in the enum above; carry the raw label.
Other,
}
/// A piece of identifying information published with an entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Identification {
/// What sort of identifier this is.
pub kind: IdentificationKind,
/// Free-text label as published (e.g. `"Cedula No."`, `"DNI"`).
pub label: Option<String>,
/// The identifier value itself.
pub value: String,
/// Issuing country, if known.
pub country: Option<CountryCode>,
}
/// A single sanctioned entity, normalised across all upstream list formats.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SanctionedEntity {
/// Stable per-list identifier (e.g. OFAC's `uid`, EU `logicalId`).
pub id: String,
/// Canonical primary name as published.
pub name: String,
/// AKAs, FKAs, and other published aliases.
pub name_aliases: Vec<String>,
/// Individual / Entity / Vessel / Aircraft.
pub entity_type: EntityType,
/// Date of birth (individuals only).
pub dob: Option<NaiveDate>,
/// Place of birth, free-text (individuals only).
pub place_of_birth: Option<String>,
/// Known addresses.
pub addresses: Vec<Address>,
/// Nationalities / citizenships.
pub nationalities: Vec<CountryCode>,
/// Published identifications.
pub identifications: Vec<Identification>,
/// Sanctions programmes the entry sits under (e.g. `"SDGT"`, `"UKRAINE-EO13662"`).
pub programs: Vec<String>,
/// When the upstream list last updated this entry.
pub last_updated: DateTime<Utc>,
/// 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());
}
}