op-connect: an unrun PEP screen is not a clearance #286

Merged
dcharlot merged 1 commit from fix/openpay-screening-unrun-is-not-clear into main 2026-10-17 09:23:44 -04:00
2 changed files with 161 additions and 15 deletions

View file

@ -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::{

View file

@ -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());
} }
} }