Skip to main content

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

ModulePurpose
monitoring_service.pyDatabase 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.pyPer-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.pyManages 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.pyThe 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.pyPersists timestamped ScreeningResult rows to the append-only screening_results evidence trail (ADR-0063, issue #45)
continuous_monitoring.pyThe ContinuousMonitoringWorkflow Temporal workflow — fetches the population, runs each case's checks sequentially, updates last_run
activities.pyfetch_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:

CheckStatusSource
ubo_screeningLiveRisk-paced sanctions/PEP re-screen of directors + UBOs via screen_entities; persists ScreeningResult evidence. See below
eori_validityLiveEORIService.validate — flags CRITICAL when an EORI becomes invalid, WARNING on trader name/address change
vop_reverificationLiveVoPService.verify_batch — flags CRITICAL on full_match → no_match, WARNING on full_match → partial_match
company_statusMockKBO/Crossroads Bank legal-status query deferred
aeo_statusMockEU 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_status and aeo_status return 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:

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

  1. Merges directors + UBOs into the canonical natural-person set via merge_screening_persons(directors_detailed, ubos), then builds queries with build_director_queries (Sanctions Screening, issue #33).
  2. Opens a tenant-scoped session and calls the live screen_entities pipeline (live OpenSanctions/PEP + the ADR-0045 three-tier suppression engine).
  3. Maps post-suppression hits to ScreeningResult rows and persists them via ScreeningResultService to the append-only screening_results evidence trail (ADR-0063, issue #45) — recording "screened, clean" rows too, as ongoing-monitoring proof.
  4. Returns a MonitoringEvent whose 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_opensanctions raises ScreeningUnavailableError when the backend errors or the OpenSanctions data is not loaded; the corresponding SanctionsScreeningResult is tagged with .error.
  • A person with an .error result 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_due driven by monitoring_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:

  1. Population selectfetch_active_monitoring_cases selects 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 retired fetch_active_customs_cases activity, which filtered on template_id LIKE '%customs%', is no longer referenced.)
  2. Per-case checks (sequential)run_monitoring_checks resolves the authoritative cadence tier from the latest MCCClassification.risk_tier, computes the last-screened timestamp from screening_results, and runs each enabled check.
  3. Last-run updateupdate_monitoring_last_run records 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.

  • ADR-0066 — Live risk-paced UBO re-screening + general-population monitoring
  • ADR-0063 — Typed persisted ScreeningResult evidence trail (issue #45)
  • ADR-0045 — Sanctions false-positive suppression (reused by the re-screen pipeline)