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

View file

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