op-connect: an unrun PEP screen is not a clearance #286
2 changed files with 161 additions and 15 deletions
|
|
@ -121,7 +121,8 @@ pub use onboarding::{
|
||||||
};
|
};
|
||||||
pub use payout::{PayoutInstruction, PayoutMode, PayoutSchedule};
|
pub use payout::{PayoutInstruction, PayoutMode, PayoutSchedule};
|
||||||
pub use screening::{
|
pub use screening::{
|
||||||
ConnectScreener, Decision, ScreeningResult, annotate_pep_flags, build_pep_index_from_names,
|
ConnectScreener, Decision, ScreenCoverage, ScreeningResult, annotate_pep_flags,
|
||||||
|
build_pep_index_from_names,
|
||||||
};
|
};
|
||||||
pub use split::{PaymentId, PaymentSplit, SplitLeg};
|
pub use split::{PaymentId, PaymentSplit, SplitLeg};
|
||||||
pub use tax_reporting::{
|
pub use tax_reporting::{
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@
|
||||||
//! news mentions); same shape as the PEP index.
|
//! news mentions); same shape as the PEP index.
|
||||||
//!
|
//!
|
||||||
//! The combined [`ScreeningResult`] gates the [`crate::onboarding::StepResult`].
|
//! The combined [`ScreeningResult`] gates the [`crate::onboarding::StepResult`].
|
||||||
|
//!
|
||||||
|
//! Screens 3 and 4 are optional *to configure*, which makes "we did not
|
||||||
|
//! look" a reachable state. It is not the same state as "we looked and
|
||||||
|
//! found nothing", so the two are different [`Decision`]s:
|
||||||
|
//! [`ScreeningResult::coverage`] records which screens ran, and a
|
||||||
|
//! submission missing the mandated PEP screen reports
|
||||||
|
//! [`Decision::Incomplete`] rather than [`Decision::Clear`].
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
|
@ -31,14 +38,71 @@ use crate::error::Result;
|
||||||
use crate::kyb::{Address, BeneficialOwner, BusinessProfile};
|
use crate::kyb::{Address, BeneficialOwner, BusinessProfile};
|
||||||
|
|
||||||
/// Top-level screening decision for a connected account.
|
/// Top-level screening decision for a connected account.
|
||||||
|
///
|
||||||
|
/// Precedence when more than one condition holds: `Block` > `Review` >
|
||||||
|
/// `Incomplete` > `Clear`. A sanctions hit outranks everything, and a
|
||||||
|
/// hit that needs adjudication outranks a missing screen because the
|
||||||
|
/// gap is reported independently in [`ScreeningResult::coverage`].
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum Decision {
|
pub enum Decision {
|
||||||
/// No hits anywhere; safe to proceed.
|
/// Every mandated screen ran and none of them hit; safe to proceed.
|
||||||
|
///
|
||||||
|
/// This is a statement about screens that *ran*. A configuration
|
||||||
|
/// missing a mandated screen yields [`Decision::Incomplete`], never
|
||||||
|
/// this.
|
||||||
Clear,
|
Clear,
|
||||||
/// One or more sanctions hits; block the account.
|
/// One or more sanctions hits; block the account.
|
||||||
Block,
|
Block,
|
||||||
/// PEP or adverse-media hits, or sanctions ambiguity; route to manual review.
|
/// PEP or adverse-media hits, or sanctions ambiguity; route to manual review.
|
||||||
Review,
|
Review,
|
||||||
|
/// A screen the platform is required to run did not run, so no
|
||||||
|
/// determination was made.
|
||||||
|
///
|
||||||
|
/// This is **not** a clearance. An unrun screen produces no hits,
|
||||||
|
/// which is indistinguishable from a screen that ran and found
|
||||||
|
/// nothing unless the two are different decisions.
|
||||||
|
/// [`ScreeningResult::coverage`] names which screen is missing.
|
||||||
|
Incomplete,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Decision {
|
||||||
|
/// Onboarding may continue without human intervention.
|
||||||
|
///
|
||||||
|
/// Only [`Decision::Clear`] qualifies. [`Decision::Incomplete`]
|
||||||
|
/// deliberately does not: the absence of a hit from a screen that
|
||||||
|
/// never ran is not a negative result.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn may_proceed(self) -> bool {
|
||||||
|
matches!(self, Self::Clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of the optional screens actually ran for one submission.
|
||||||
|
///
|
||||||
|
/// Recorded alongside the hit vectors so a persisted [`ScreeningResult`]
|
||||||
|
/// distinguishes "screened, found nothing" from "never screened".
|
||||||
|
/// Without it an operator holding only a sanctions feed produces a row
|
||||||
|
/// indistinguishable from a full clearance, and the gap is invisible on
|
||||||
|
/// review.
|
||||||
|
///
|
||||||
|
/// The sanctions screen is absent from this record because it cannot be
|
||||||
|
/// absent from the run: [`ConnectScreener::new`] takes it by value.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ScreenCoverage {
|
||||||
|
/// The PEP screen ran. Mandated by **FATF Recommendation 12** and
|
||||||
|
/// EU AMLD5 Art. 20, so `false` withholds clearance.
|
||||||
|
pub pep: bool,
|
||||||
|
/// The adverse-media screen ran. Documented as optional, so `false`
|
||||||
|
/// does not by itself withhold clearance.
|
||||||
|
pub adverse_media: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScreenCoverage {
|
||||||
|
/// Every screen the platform is *required* to run actually ran.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn mandated_screens_ran(self) -> bool {
|
||||||
|
self.pep
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Combined screening outcome for one owner-roster submission.
|
/// Combined screening outcome for one owner-roster submission.
|
||||||
|
|
@ -50,6 +114,12 @@ pub struct ScreeningResult {
|
||||||
pub pep_hits: Vec<MatchScore>,
|
pub pep_hits: Vec<MatchScore>,
|
||||||
/// Adverse-media matches across all persons + business.
|
/// Adverse-media matches across all persons + business.
|
||||||
pub adverse_media_hits: Vec<MatchScore>,
|
pub adverse_media_hits: Vec<MatchScore>,
|
||||||
|
/// Which optional screens ran.
|
||||||
|
///
|
||||||
|
/// Defaults to "neither ran" when absent from a stored record: a row
|
||||||
|
/// that does not state its coverage cannot assert it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub coverage: ScreenCoverage,
|
||||||
/// Top-line decision.
|
/// Top-line decision.
|
||||||
pub decision: Decision,
|
pub decision: Decision,
|
||||||
}
|
}
|
||||||
|
|
@ -196,18 +266,26 @@ impl ConnectScreener {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let coverage = ScreenCoverage {
|
||||||
|
pep: self.pep.is_some(),
|
||||||
|
adverse_media: self.adverse_media.is_some(),
|
||||||
|
};
|
||||||
|
|
||||||
let decision = if any_block {
|
let decision = if any_block {
|
||||||
Decision::Block
|
Decision::Block
|
||||||
} else if any_review {
|
} else if any_review {
|
||||||
Decision::Review
|
Decision::Review
|
||||||
} else {
|
} else if coverage.mandated_screens_ran() {
|
||||||
Decision::Clear
|
Decision::Clear
|
||||||
|
} else {
|
||||||
|
Decision::Incomplete
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(ScreeningResult {
|
Ok(ScreeningResult {
|
||||||
sanctions_hits,
|
sanctions_hits,
|
||||||
pep_hits,
|
pep_hits,
|
||||||
adverse_media_hits,
|
adverse_media_hits,
|
||||||
|
coverage,
|
||||||
decision,
|
decision,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -361,19 +439,86 @@ mod tests {
|
||||||
assert!(owners[0].is_pep, "PEP flag should have fired");
|
assert!(owners[0].is_pep, "PEP flag should have fired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn empty_screener() -> Screener {
|
||||||
|
Screener::new(empty_index(), ScreenerConfig::default(), AuditLog::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn roster() -> Vec<BeneficialOwner> {
|
||||||
|
vec![BeneficialOwner {
|
||||||
|
person: person("Jane Doe"),
|
||||||
|
ownership_pct: 100.0,
|
||||||
|
control: true,
|
||||||
|
is_pep: false,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The discriminating pair, part one: no PEP feed is configured, so
|
||||||
|
/// the screen mandated by FATF Rec. 12 never runs. `pep_hits` is
|
||||||
|
/// empty because nothing looked, and that is not a clearance.
|
||||||
|
///
|
||||||
|
/// This replaces `clear_when_lists_empty`, which built exactly this
|
||||||
|
/// configuration and asserted `Clear`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn clear_when_lists_empty() {
|
async fn an_unrun_pep_screen_is_not_a_clearance() {
|
||||||
let sanctions = Screener::new(empty_index(), ScreenerConfig::default(), AuditLog::new());
|
let cs = ConnectScreener::new(empty_screener(), None, None);
|
||||||
let cs = ConnectScreener::new(sanctions, None, None);
|
let res = cs.screen(&sample_business(), &roster()).await.expect("ok");
|
||||||
let res = cs
|
|
||||||
.screen(&sample_business(), &[BeneficialOwner {
|
assert!(res.pep_hits.is_empty(), "nothing screened, so nothing hit");
|
||||||
person: person("Jane Doe"),
|
assert!(!res.coverage.pep, "the PEP screen did not run");
|
||||||
ownership_pct: 100.0,
|
assert_eq!(res.decision, Decision::Incomplete);
|
||||||
control: true,
|
assert!(
|
||||||
is_pep: false,
|
!res.decision.may_proceed(),
|
||||||
}])
|
"an unrun mandated screen must not gate onboarding open"
|
||||||
.await
|
);
|
||||||
.expect("ok");
|
}
|
||||||
|
|
||||||
|
/// The discriminating pair, part two: a real PEP index is configured
|
||||||
|
/// and returns nothing. The hit vectors are identical to the test
|
||||||
|
/// above and the decision is the opposite, which is the whole reason
|
||||||
|
/// coverage is recorded.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_pep_screen_that_ran_and_found_nothing_is_a_clearance() {
|
||||||
|
let cs = ConnectScreener::new(
|
||||||
|
empty_screener(),
|
||||||
|
Some(empty_screener()),
|
||||||
|
Some(empty_screener()),
|
||||||
|
);
|
||||||
|
let res = cs.screen(&sample_business(), &roster()).await.expect("ok");
|
||||||
|
|
||||||
|
assert!(res.pep_hits.is_empty(), "the index is empty");
|
||||||
|
assert!(res.coverage.pep);
|
||||||
|
assert!(res.coverage.adverse_media);
|
||||||
assert_eq!(res.decision, Decision::Clear);
|
assert_eq!(res.decision, Decision::Clear);
|
||||||
|
assert!(res.decision.may_proceed());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adverse media is documented as optional, so its absence is
|
||||||
|
/// recorded but does not withhold clearance. Only the mandated
|
||||||
|
/// screen does that.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_missing_optional_screen_still_clears_but_is_recorded() {
|
||||||
|
let cs = ConnectScreener::new(empty_screener(), Some(empty_screener()), None);
|
||||||
|
let res = cs.screen(&sample_business(), &roster()).await.expect("ok");
|
||||||
|
|
||||||
|
assert!(!res.coverage.adverse_media, "it did not run");
|
||||||
|
assert_eq!(res.decision, Decision::Clear, "and it is not mandated");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A PEP hit still routes to review rather than blocking, and still
|
||||||
|
/// reports full coverage.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_pep_hit_routes_to_review() {
|
||||||
|
let pep = Screener::new(
|
||||||
|
build_pep_index_from_names(&["Jane Doe"]),
|
||||||
|
ScreenerConfig::default(),
|
||||||
|
AuditLog::new(),
|
||||||
|
);
|
||||||
|
let cs = ConnectScreener::new(empty_screener(), Some(pep), None);
|
||||||
|
let res = cs.screen(&sample_business(), &roster()).await.expect("ok");
|
||||||
|
|
||||||
|
assert!(!res.pep_hits.is_empty(), "the fixture names this owner");
|
||||||
|
assert!(res.coverage.pep);
|
||||||
|
assert_eq!(res.decision, Decision::Review);
|
||||||
|
assert!(!res.decision.may_proceed());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue