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
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]>
233 lines
8.4 KiB
Rust
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());
|
|
}
|
|
}
|