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 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 tax_reporting::{
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@
|
|||
//! news mentions); same shape as the PEP index.
|
||||
//!
|
||||
//! 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;
|
||||
|
||||
|
|
@ -31,14 +38,71 @@ use crate::error::Result;
|
|||
use crate::kyb::{Address, BeneficialOwner, BusinessProfile};
|
||||
|
||||
/// 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)]
|
||||
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,
|
||||
/// One or more sanctions hits; block the account.
|
||||
Block,
|
||||
/// PEP or adverse-media hits, or sanctions ambiguity; route to manual 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.
|
||||
|
|
@ -50,6 +114,12 @@ pub struct ScreeningResult {
|
|||
pub pep_hits: Vec<MatchScore>,
|
||||
/// Adverse-media matches across all persons + business.
|
||||
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.
|
||||
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 {
|
||||
Decision::Block
|
||||
} else if any_review {
|
||||
Decision::Review
|
||||
} else {
|
||||
} else if coverage.mandated_screens_ran() {
|
||||
Decision::Clear
|
||||
} else {
|
||||
Decision::Incomplete
|
||||
};
|
||||
|
||||
Ok(ScreeningResult {
|
||||
sanctions_hits,
|
||||
pep_hits,
|
||||
adverse_media_hits,
|
||||
coverage,
|
||||
decision,
|
||||
})
|
||||
}
|
||||
|
|
@ -361,19 +439,86 @@ mod tests {
|
|||
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]
|
||||
async fn clear_when_lists_empty() {
|
||||
let sanctions = Screener::new(empty_index(), ScreenerConfig::default(), AuditLog::new());
|
||||
let cs = ConnectScreener::new(sanctions, None, None);
|
||||
let res = cs
|
||||
.screen(&sample_business(), &[BeneficialOwner {
|
||||
person: person("Jane Doe"),
|
||||
ownership_pct: 100.0,
|
||||
control: true,
|
||||
is_pep: false,
|
||||
}])
|
||||
.await
|
||||
.expect("ok");
|
||||
async fn an_unrun_pep_screen_is_not_a_clearance() {
|
||||
let cs = ConnectScreener::new(empty_screener(), None, None);
|
||||
let res = cs.screen(&sample_business(), &roster()).await.expect("ok");
|
||||
|
||||
assert!(res.pep_hits.is_empty(), "nothing screened, so nothing hit");
|
||||
assert!(!res.coverage.pep, "the PEP screen did not run");
|
||||
assert_eq!(res.decision, Decision::Incomplete);
|
||||
assert!(
|
||||
!res.decision.may_proceed(),
|
||||
"an unrun mandated screen must not gate onboarding open"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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!(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