Continuous Monitoring
Post-onboarding continuous monitoring infrastructure for ongoing entity surveillance — the platform's perpetual KYC capability (NBB 2026/02; AMLR Art. 21). A per-tenant Temporal Schedule triggers ContinuousMonitoringWorkflow on a configurable cron; each run iterates the general onboarded population for the tenant, executes the active checks per case, compares current state against the last persisted state field-by-field, and records a MonitoringEvent with a change_detected flag and severity (INFO / WARNING / CRITICAL).
The headline capability (ADR-0066) is live, risk-paced sanctions/PEP re-screening of every onboarded case's subject, directors, and beneficial owners — gated by a per-case cadence so most cases skip on most runs, and persisted to a timestamped, append-only evidence trail.
Components
| Module | Purpose |
|---|---|
monitoring_service.py | Database access layer (MonitoringService): list/acknowledge events, aggregate summary, monitoring config get/update on the tenant record, and reads for entity baselines, alerts, and investigation deltas. Each method opens its own tenant-scoped session |
monitoring_check_service.py | Per-check execution (MonitoringCheckService.run_check): dispatches by MonitoringCheckType, fetches the previous state, runs the check, diffs against the baseline, and persists the event. Hosts the live check_ubo_screening re-screen |
monitoring_schedule_service.py | Manages the per-tenant Temporal Schedule (monitoring-{tenant_id}): create/update/pause/delete, plus validate_monitoring_config against AMLR Article 26 cadence rules. Also exposes the pure cadence gate is_rescreen_due(last_screened_at, cadence_days, now) |
sanctions_screening_pipeline.py | The live screen_entities sanctions/PEP pipeline (with ADR-0045 three-tier suppression) reused by the re-screen, plus merge_screening_persons + build_director_queries. See Sanctions Screening |
screening_result_service.py | Persists timestamped ScreeningResult rows to the append-only screening_results evidence trail (ADR-0063, issue #45) |
continuous_monitoring.py | The ContinuousMonitoringWorkflow Temporal workflow — fetches the population, runs each case's checks sequentially, updates last_run |
activities.py | fetch_active_monitoring_cases (population select), run_monitoring_checks (resolves per-case cadence tier + last-screened timestamp), update_monitoring_last_run |
cadence_days_for_tier and the derived RiskAssessment.review_cadence_days field live in the shared trustrelay_models.risk_matrix package.
Check Types
MonitoringCheckService implements five check types. Three run against live integrations; two are explicitly in mock mode pending live wiring:
| Check | Status | Source |
|---|---|---|
ubo_screening | Live | Risk-paced sanctions/PEP re-screen of directors + UBOs via screen_entities; persists ScreeningResult evidence. See below |
eori_validity | Live | EORIService.validate — flags CRITICAL when an EORI becomes invalid, WARNING on trader name/address change |
vop_reverification | Live | VoPService.verify_batch — flags CRITICAL on full_match → no_match, WARNING on full_match → partial_match |
company_status | Mock | KBO/Crossroads Bank legal-status query deferred |
aeo_status | Mock | EU AEO database query deferred |
The monitoring population is now the general onboarded KYB population, not customs-only (ADR-0066). The EORI + VoP checks remain available for the customs fiscal-representative use case (which is now a subset of the monitored population);
company_statusandaeo_statusreturn INFO mock events pending live wiring.
Live UBO/Director Re-Screening (ADR-0066)
check_ubo_screening was previously a mock no-op. It is now a live, risk-cadence-gated sanctions/PEP re-screen that reuses the production OSINT screening pipeline and writes to the same evidence trail as initial onboarding. It realises perpetual KYC under AMLR Art. 21/26.
Risk-based cadence
Re-screening is gated, per case, by the case's risk tier — higher risk re-screens more often. The pure helpers cadence_days_for_tier and is_rescreen_due decide whether a case is due:
| Tier | Re-screen cadence |
|---|---|
| EDD (enhanced) | 90 days |
| CDD (standard) | 180 days |
| SDD (simplified) | 365 days |
These intervals are deliberately tighter than the AMLR Art. 26(2) full-review ceilings (EDD 12 months, CDD/SDD 60 months) — they are sanctions-re-screen intervals, not full periodic reviews. The tier is sourced per-case from the latest MCCClassification.risk_tier reassessment (the cases table has no risk_tier column); the derived RiskAssessment.review_cadence_days field auto-populates from the tier via a @model_validator. "Last screened" is max(screening_results.screened_at) filtered to complete monitoring screens (see cardinal rule). A never-screened or elapsed case is due:
def is_rescreen_due(last_screened_at, cadence_days, now) -> bool:
if last_screened_at is None:
return True # never screened ⇒ due
return (now - last_screened_at) >= timedelta(days=cadence_days)
When a case is not due, check_ubo_screening returns an INFO no-op — no engine call, no persistence.
Live screen + persistence
When a case is due, the check:
- Merges directors + UBOs into the canonical natural-person set via
merge_screening_persons(directors_detailed, ubos), then builds queries withbuild_director_queries(Sanctions Screening, issue #33). - Opens a tenant-scoped session and calls the live
screen_entitiespipeline (live OpenSanctions/PEP + the ADR-0045 three-tier suppression engine). - Maps post-suppression hits to
ScreeningResultrows and persists them viaScreeningResultServiceto the append-onlyscreening_resultsevidence trail (ADR-0063, issue #45) — recording "screened, clean" rows too, as ongoing-monitoring proof. - Returns a
MonitoringEventwhose severity reflects the hits: CRITICAL for any sanctions/OFAC/UN/WANTED hit, WARNING for a PEP hit, INFO for a complete clean run.
Cardinal rule — never record an indeterminate lookup as clean
The system can ADD scrutiny but must NEVER suppress a risk signal. An indeterminate or unavailable OpenSanctions lookup is therefore never recorded as clean:
_query_opensanctionsraisesScreeningUnavailableErrorwhen the backend errors or the OpenSanctions data is not loaded; the correspondingSanctionsScreeningResultis tagged with.error.- A person with an
.errorresult gets no row at all — neither a clean row (which would be a false "clean" evidence record) nor a hit row. - The run is marked incomplete, surfaces as a WARNING monitoring event, and its rows are written with
screened_by="monitoring-ubo-rescreen-partial". - The cadence query counts only rows marked
screened_by="monitoring-ubo-rescreen"(the complete-run marker), so a partial run does not advance the cadence — the case stays due on the next run (fail-open in time, never a silent skip).
Baselines, Alerts & Deltas
MonitoringService also exposes the portfolio-level read surface consumed by the dashboard:
- Entity baselines — per-entity risk score/tier, last-investigated timestamp, and
next_review_duedriven bymonitoring_cadence_months - Monitoring alerts — risk-delta-triggered alerts (
risk_score_before/after,risk_delta,material_changes_summary) filterable by status - Investigation deltas — field-level change records between investigation runs (dimension, previous/current value, risk impact, source)
Workflow & Population
ContinuousMonitoringWorkflow (continuous_monitoring.py) drives each scheduled run:
- Population select —
fetch_active_monitoring_casesselects every onboarded case for the tenant:status IN (APPROVED, APPROVED_WITH_RESTRICTIONS), no customs filter. This activity uses an admin (RLS-bypass) session in the worker, so it carries an explicit tenant predicate — without it the run would leak every tenant's approved cases into the calling tenant's monitoring run. (The retiredfetch_active_customs_casesactivity, which filtered ontemplate_id LIKE '%customs%', is no longer referenced.) - Per-case checks (sequential) —
run_monitoring_checksresolves the authoritative cadence tier from the latestMCCClassification.risk_tier, computes the last-screened timestamp fromscreening_results, and runs each enabled check. - Last-run update —
update_monitoring_last_runrecords the run timestamp.
Per-case execution is sequential by design (avoids DB contention); a run's wall-time grows only with the number of due cases, since the cadence gate short-circuits the rest.
API
app/api/monitoring.py — events list, summary, manual {case_id}/recheck, event acknowledge, config get/update/validate, Temporal schedule/start + schedule/stop, baselines, alerts, alert count, and per-workflow investigation deltas.
Related ADRs
- ADR-0066 — Live risk-paced UBO re-screening + general-population monitoring
- ADR-0063 — Typed persisted
ScreeningResultevidence trail (issue #45) - ADR-0045 — Sanctions false-positive suppression (reused by the re-screen pipeline)