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
Owner

The defect

ConnectScreener takes its PEP and adverse-media screeners as Option<Screener>, documented so that "operators who only have a sanctions feed can still use this crate (PEP / AM are then no-ops returning empty hit-vectors)". Both screens are gated on if let Some(..), so with pep: None neither pep_hits nor any_review is ever touched, and the fall-through returned Decision::Clear — whose own doc comment read "No hits anywhere; safe to proceed."

The module doc states that at each BeneficialOwners submission the platform must "screen every person against a PEP watchlist ... per FATF Recommendation 12 and EU AMLD5 Art. 20". So the crate emitted safe to proceed for a screen it is required to run and did not run.

ScreeningResult had no field recording which screens ran, so the persisted row was indistinguishable from one where a real PEP feed was queried and returned nothing. A beneficial owner who is a sitting head of state, onboarded under a sanctions-only configuration, produced the same record as a genuine three-screen clearance — the required enhanced due diligence never triggers and the gap is invisible on review.

The crate's own test clear_when_lists_empty built exactly that configuration and asserted Clear, pinning the behaviour.

This is the "unknown conflated with a value" family, and here the value it was conflated with is the best possible one.

The fix

Unknown becomes a distinct value rather than a clearance:

  • ScreenCoverage { pep, adverse_media } records which optional screens ran and travels with the hits. Sanctions is deliberately absent from it: it cannot be absent from the run, because ConnectScreener::new takes it by value, so a bool could never be false. #[serde(default)] makes a stored row that does not state its coverage default to neither ran — a record that does not say cannot assert it.
  • Decision::Incomplete reports a mandated screen that did not run. Precedence is Block > Review > Incomplete > Clear: a sanctions hit outranks everything, and a hit needing adjudication outranks a missing screen because the gap is reported independently in coverage.
  • Decision::may_proceed() is true only for Clear, so a caller cannot read Incomplete as proceedable by forgetting a match arm.

The mandated/optional line is taken from the crate's own doc rather than invented: items 1–3 are "must", item 4 is "Optionally". A missing adverse-media screen is therefore recorded but still clears; a missing PEP screen does not.

Tests

clear_when_lists_empty is replaced by a discriminating pair with identical empty hit-vectors and opposite decisions — which is the distinction the old single test could not express:

test config pep_hits decision
an_unrun_pep_screen_is_not_a_clearance new(sanctions, None, None) empty Incomplete
a_pep_screen_that_ran_and_found_nothing_is_a_clearance new(sanctions, Some(empty), Some(empty)) empty Clear

Plus a_missing_optional_screen_still_clears_but_is_recorded (adverse media absent → recorded, still clears) and a_pep_hit_routes_to_review (a hit still reviews rather than blocks, with full coverage).

Falsification

Each test was shown to fail against a mutated implementation, so none is decoration:

mutation result
M1 restore the pre-fix fall-through (else { Clear }) only an_unrun_pep_screen_is_not_a_clearance FAILED
M2 mandated_screens_ran() → true only an_unrun_pep_screen_is_not_a_clearance FAILED
M3 coverage.pep → false the three coverage-positive tests FAILED

M3 is the check that the positive tests have teeth; M1 is the regression test for the defect itself.

Verification

Run on the build host (aarch64), matching what standards-build.yml runs for openpay:

  • cargo check --workspace --all-targets — clean
  • cargo test --workspace — exit status 0, 0 failed suites, 117 suites with passing tests
  • cargo clippy -p op-connect --all-targets --no-deps -- -D warnings — clean (not a CI gate for openpay; run anyway)

Scope

No wire or vector impact: op-connect ships no conformance vectors (only op-rails-a2a, op-iso20022 and op-emv do), and grep finds no serialized ScreeningResult anywhere in the tree. ConnectScreener / ScreeningResult / Decision have no consumer outside the crate beyond the lib.rs re-export, and nothing in the repo matches on Decision, so the new variant breaks no match.

Noted, not fixed here

Two adjacent findings in the same subsystem, left for separate changes because they are a different kind of change:

  1. Error::ScreeningBlocked is defined and never constructed. OnboardingProvider::submit_step's doc promises it ("if screening fired on a step") and this module's doc claims the ScreeningResult "gates the StepResult", but NativeProvider's StepPayload::BeneficialOwners arm validates FinCEN CDD and merges the roster without ever screening. tests/end_to_end.rs walks a full onboarding to Active with no screen performed. Wiring it is not a small change: submit_step is async and holds std::sync::MutexGuards, which cannot be held across the .await of screen() under async_trait's Send bound, so it needs a two-phase lock (read, drop, screen, re-lock) plus a decision on requirement semantics for Review/Incomplete and on not persisting a partial mutation when Block returns Err. That belongs in its own PR.
  2. annotate_pep_flags clobbers is_pep to false when its index returns no hits, discarding a flag that may have been set from another source. Arguably its documented job ("re-running the PEP screen and toggling"), so recorded rather than changed.

🤖 Generated with Claude Code

## The defect `ConnectScreener` takes its PEP and adverse-media screeners as `Option<Screener>`, documented so that "operators who only have a sanctions feed can still use this crate (PEP / AM are then no-ops returning empty hit-vectors)". Both screens are gated on `if let Some(..)`, so with `pep: None` neither `pep_hits` nor `any_review` is ever touched, and the fall-through returned `Decision::Clear` — whose own doc comment read **"No hits anywhere; safe to proceed."** The module doc states that at each `BeneficialOwners` submission the platform **must** "screen every person against a **PEP** watchlist ... per **FATF Recommendation 12** and EU AMLD5 Art. 20". So the crate emitted *safe to proceed* for a screen it is required to run and did not run. `ScreeningResult` had no field recording which screens ran, so the persisted row was indistinguishable from one where a real PEP feed was queried and returned nothing. A beneficial owner who is a sitting head of state, onboarded under a sanctions-only configuration, produced the same record as a genuine three-screen clearance — the required enhanced due diligence never triggers and the gap is invisible on review. The crate's own test `clear_when_lists_empty` built exactly that configuration and asserted `Clear`, pinning the behaviour. This is the "unknown conflated with a value" family, and here the value it was conflated with is the *best possible* one. ## The fix Unknown becomes a distinct value rather than a clearance: - **`ScreenCoverage { pep, adverse_media }`** records which optional screens ran and travels with the hits. Sanctions is deliberately absent from it: it cannot be absent from the run, because `ConnectScreener::new` takes it by value, so a bool could never be `false`. `#[serde(default)]` makes a stored row that does not state its coverage default to *neither ran* — a record that does not say cannot assert it. - **`Decision::Incomplete`** reports a mandated screen that did not run. Precedence is `Block` > `Review` > `Incomplete` > `Clear`: a sanctions hit outranks everything, and a hit needing adjudication outranks a missing screen because the gap is reported independently in `coverage`. - **`Decision::may_proceed()`** is true only for `Clear`, so a caller cannot read `Incomplete` as proceedable by forgetting a match arm. The mandated/optional line is taken from the crate's own doc rather than invented: items 1–3 are "must", item 4 is "Optionally". A missing adverse-media screen is therefore recorded but still clears; a missing PEP screen does not. ## Tests `clear_when_lists_empty` is replaced by a **discriminating pair** with identical empty hit-vectors and opposite decisions — which is the distinction the old single test could not express: | test | config | `pep_hits` | decision | |---|---|---|---| | `an_unrun_pep_screen_is_not_a_clearance` | `new(sanctions, None, None)` | empty | `Incomplete` | | `a_pep_screen_that_ran_and_found_nothing_is_a_clearance` | `new(sanctions, Some(empty), Some(empty))` | empty | `Clear` | Plus `a_missing_optional_screen_still_clears_but_is_recorded` (adverse media absent → recorded, still clears) and `a_pep_hit_routes_to_review` (a hit still reviews rather than blocks, with full coverage). ### Falsification Each test was shown to fail against a mutated implementation, so none is decoration: | mutation | result | |---|---| | **M1** restore the pre-fix fall-through (`else { Clear }`) | only `an_unrun_pep_screen_is_not_a_clearance` FAILED | | **M2** `mandated_screens_ran()` → `true` | only `an_unrun_pep_screen_is_not_a_clearance` FAILED | | **M3** `coverage.pep` → `false` | the three coverage-positive tests FAILED | M3 is the check that the positive tests have teeth; M1 is the regression test for the defect itself. ## Verification Run on the build host (aarch64), matching what `standards-build.yml` runs for `openpay`: - `cargo check --workspace --all-targets` — clean - `cargo test --workspace` — exit status **0**, **0** failed suites, 117 suites with passing tests - `cargo clippy -p op-connect --all-targets --no-deps -- -D warnings` — clean (not a CI gate for openpay; run anyway) ## Scope No wire or vector impact: `op-connect` ships no conformance vectors (only `op-rails-a2a`, `op-iso20022` and `op-emv` do), and `grep` finds no serialized `ScreeningResult` anywhere in the tree. `ConnectScreener` / `ScreeningResult` / `Decision` have no consumer outside the crate beyond the `lib.rs` re-export, and nothing in the repo matches on `Decision`, so the new variant breaks no match. ## Noted, not fixed here Two adjacent findings in the same subsystem, left for separate changes because they are a different kind of change: 1. **`Error::ScreeningBlocked` is defined and never constructed.** `OnboardingProvider::submit_step`'s doc promises it ("if screening fired on a step") and this module's doc claims the `ScreeningResult` "gates the `StepResult`", but `NativeProvider`'s `StepPayload::BeneficialOwners` arm validates FinCEN CDD and merges the roster without ever screening. `tests/end_to_end.rs` walks a full onboarding to `Active` with no screen performed. Wiring it is not a small change: `submit_step` is `async` and holds `std::sync::MutexGuard`s, which cannot be held across the `.await` of `screen()` under `async_trait`'s `Send` bound, so it needs a two-phase lock (read, drop, screen, re-lock) plus a decision on requirement semantics for `Review`/`Incomplete` and on not persisting a partial mutation when `Block` returns `Err`. That belongs in its own PR. 2. **`annotate_pep_flags` clobbers `is_pep` to `false`** when its index returns no hits, discarding a flag that may have been set from another source. Arguably its documented job ("re-running the PEP screen and toggling"), so recorded rather than changed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
op-connect: an unrun PEP screen is not a clearance
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 1m21s
standards workspaces build + test / openpay (openpay, openpay, linux-arm64) (pull_request) Successful in 6m0s
fc5081ae3f
`ConnectScreener` takes its PEP and adverse-media screeners as
`Option<Screener>`, documented so that "operators who only have a
sanctions feed can still use this crate (PEP / AM are then no-ops
returning empty hit-vectors)". Both screens are gated on
`if let Some(..)`, so with `pep: None` neither `pep_hits` nor
`any_review` is ever touched, and the fall-through returned
`Decision::Clear` — whose own doc comment read "No hits anywhere; safe
to proceed."

The module doc states that at each BeneficialOwners submission the
platform *must* "screen every person against a PEP watchlist ... per
FATF Recommendation 12 and EU AMLD5 Art. 20". So the crate emitted
"safe to proceed" for a screen it is required to run and did not run.
`ScreeningResult` recorded no field saying which screens had run, so
the persisted row was indistinguishable from one where a real PEP feed
was queried and returned nothing: a sitting head of state onboarded
under a sanctions-only configuration produced the same record as a
genuine three-screen clearance, and the gap was invisible on review.
The crate's own test `clear_when_lists_empty` built exactly that
configuration and asserted `Clear`, pinning the behaviour.

Unknown is now a distinct value rather than the best possible one:

- `ScreenCoverage { pep, adverse_media }` records which optional
  screens ran, and travels with the hits. Sanctions is absent from it
  because it cannot be absent from the run — `new` takes it by value,
  so a bool could never be false. `#[serde(default)]` makes a stored
  row that does not state its coverage default to "neither ran": a
  record that does not say cannot assert it.
- `Decision::Incomplete` reports a mandated screen that did not run.
  Precedence is `Block` > `Review` > `Incomplete` > `Clear`; a hit
  needing adjudication outranks a missing screen because the gap is
  reported independently in `coverage`.
- `Decision::may_proceed()` is true only for `Clear`, so a caller
  cannot read `Incomplete` as proceedable by forgetting a match arm.

The mandated/optional line is drawn from the crate's own doc: items
1-3 are "must", item 4 is "Optionally", so a missing adverse-media
screen is recorded but still clears, and a missing PEP screen does not.

`clear_when_lists_empty` is replaced by a discriminating pair with
identical empty hit-vectors and opposite decisions — unconfigured PEP
yields `Incomplete`, a configured-but-empty PEP index yields `Clear` —
which is the distinction the old single test could not express.

Co-Authored-By: Claude Opus 5 <[email protected]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!286
No description provided.