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) |
entity_baseline_service.py | Entity-scoped risk baseline read/write, keyed by (registration_number, country) — spans every case ever opened for that entity. Hosts the ADR-0089 one-way ratchet (reconcile_baseline_risk, upsert_baseline) and the two-step audited downgrade (request_baseline_downgrade / approve_baseline_downgrade). See below |
material_findings.py | Fingerprints material findings (criminal/enforcement/sanctions/adverse_media/freeze/regulatory_action) and reconciles a persisted floor against an incoming run (fingerprint, merge_established_findings, reconcile_material_findings) — ADR-0089 Component C. See below |
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 unimplemented stubs that fail closed — they never report a benign no-change (ADR-0067 extension):
| 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; an errored lookup is a fail-closed indeterminate WARNING (complete: false), never a clean result |
vop_reverification | Live | VoPService.verify_batch — flags CRITICAL on full_match → no_match, WARNING on full_match → partial_match; an errored re-verification is a fail-closed indeterminate WARNING (complete: false), never a clean result |
company_status | Stub (fail-closed) | KBO/Crossroads Bank legal-status query not yet wired — returns an indeterminate WARNING (current_state {"complete": false, "indeterminate": true}), never a benign no-change, so a status change (e.g. dissolution) cannot be ruled out |
aeo_status | Stub (fail-closed) | EU AEO database query not yet wired — returns the same indeterminate WARNING, so a lost AEO authorisation cannot be ruled out |
document_expiry | Live | Identity-document freshness (lapsed/approaching, tier-based horizon) + verification staleness against screening_results via source_ttl. A lapsed-undecided document or a stale verification category writes a monitoring_alerts row (trigger_type document_expired / verification_stale). See below |
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_statusare unimplemented stubs that fail closed — rather than a benign INFO "no change", they return an indeterminate WARNING withcurrent_state {"complete": false, "indeterminate": true}, so a possible status change (e.g. dissolution) cannot be silently ruled out (ADR-0067 extension). The EORI and VoP checks apply the same fail-closed treatment on their error paths — an errored lookup is recorded as an indeterminate WARNING, never a clean result.
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).
Entity-Risk One-Way Ratchet & Audited Downgrade (ADR-0089)
A live re-run of the same legal entity (OB Holding 1 OÜ, registrikood 14975047) was
assessed CRITICAL/90 on one investigation run and medium/51 on another — same entity,
lower-recall re-screen missed the EPPO criminal finding — and the lower run silently
overwrote the entity's persisted baseline. That is a direct breach of the cardinal
principle: the system may ADD scrutiny but must NEVER suppress a risk signal. ADR-0089 closes
it with one invariant, enforced entirely at the entity_baselines persistence layer (keyed by
(registration_number, country), so it spans every case ever opened for that entity — exactly
the shape of the OB Holding defect, where the two divergent runs were two different case_ids
against the same registrikood):
Per-entity risk is monotonic absent an explicit, audited downgrade. A re-screen may raise or maintain risk; it may never silently lower it. The only path down is a maker-checker officer decision, recorded in immutable
audit_events.
This mirrors, at the persistent baseline layer, the PR #176 reconcile_display_risk ratchet
that already protects a single case's display risk — ADR-0089 is the same defense applied
cross-case, at the entity level.
Where it's wired
upsert_baseline runs from the upsert_entity_baseline Temporal activity, called once per case
investigation from compliance_case.py — immediately after the post-OSINT risk assessment,
before the officer's requirements-review gate. Because the baseline row is keyed by identity
(not by case), every subsequent investigation of the same entity — a follow-up-loop
re-investigation, or an entirely new case opened later for the same company — reconciles
against whatever floor is already established.
Scope note (be precise about what re-screens the ratchet actually covers): the ratchet
fires whenever a full case investigation runs upsert_entity_baseline — onboarding and any
case re-investigation. The lighter-weight check_ubo_screening periodic sanctions/PEP recheck
(above) only persists ScreeningResult rows; it does not currently call upsert_baseline or
recompute an EBA composite score, so a routine perpetual-monitoring sanctions hit does not by
itself pass through this ratchet. The two mechanisms protect different things: check_ubo_screening
guarantees a re-screen is never recorded as clean when it was indeterminate (§ above); the
ADR-0089 ratchet guarantees an entity's persisted risk number and findings are never silently
lowered by a subsequent full investigation.
Component B — reconciling incoming against established
reconcile_baseline_risk(established, incoming) -> BaselineDecision (pure, no I/O) compares
tier rank first (clear < low < medium < high < critical, with the SDD/CDD/EDD due-diligence
vocabulary and the MCC high_tier_1/2 → critical / high_tier_3 → high vocabulary folded
onto the same scale before ranking), then score as a tie-breaker:
- Raise or maintain → the incoming value becomes effective.
- Would-be downgrade (incoming ranks strictly lower on tier, or same tier with a lower
score) → the established value is held as effective; the raw incoming run is recorded
separately; a
divergence_statepayload is stored pending an audited downgrade.
Tier-rank lookup is asymmetrically fail-closed: an unrecognised established tier ranks
99 (highest possible) so it can never be silently superseded by a recognisable-but-lower
incoming tier; an unrecognised incoming tier ranks -1 (lowest) so it can never spuriously
out-rank — or mask a downgrade against — a known established floor. A None score on either
side compares as the lowest possible value, so a not-yet-scored run can never lower a floor but
is freely superseded once a concrete score exists.
upsert_baseline reads the currently-established (latest_risk_score, latest_risk_tier, established_findings) row for the identity inside the same tenant-scoped session, calls
reconcile_baseline_risk, and writes the reconciled decision — never the raw incoming
values — into latest_risk_score / latest_risk_tier (the on_conflict_do_update.set_ uses
decision.effective_score / decision.effective_tier). The raw incoming run is written
alongside, unconditionally, to last_run_risk_score / last_run_risk_tier — so the actual
divergent run is never lost, just never allowed to become the record of truth on its own.
next_review_due is always computed from the effective tier via
compute_next_review_date, so a held-down divergence can never loosen review cadence either.
Migration 080_entity_risk_ratchet adds five nullable/defaulted columns to
entity_baselines: last_run_risk_score (Integer), last_run_risk_tier (String),
established_findings (JSONB, default []), divergence_state (JSONB), and
material_check_incomplete (Boolean, default false). latest_risk_score / latest_risk_tier
keep their existing meaning (the effective value) — no reader migration was needed.
Component C — material-findings persistence & re-injection
A score floor alone is coarse: the actual regulatory miss on OB Holding wasn't just a lower
number, it was a dropped finding (the EPPO criminal article). material_findings.py
fingerprints each finding — fingerprint(finding) is a stable SHA-256 over
(material_type, normalized_subject, normalized_claim), with the type resolved from
category/type/finding_type via ordered substring markers (criminal/prosecut/eppo →
criminal; sanction/embargo → sanctions; freeze/frozen → freeze;
regulatory/regulatory_action → regulatory_action; enforcement/debarment →
enforcement; adverse → adverse_media, checked last so a criminal-adverse finding
classifies as criminal, its stronger floor) — and the claim/subject text is lowercased,
stopword-stripped, and token-sorted so the same finding hashes identically across
wording/temperature variance between runs.
merge_established_findings(prior, incoming)— whatupsert_baselinepersists toestablished_findings: a fingerprint-keyed union, prior wins on collision. Only the six material families (MATERIAL_FINDING_TYPES) are persisted; never subtracted by a later run.reconcile_material_findings(established, incoming)— used on the read side, by thereinject_established_findingsTemporal activity, called fromcompliance_case.pyright before risk-reassessment checkpoint 1 (post-OSINT), on every investigation run that has acompany_registration_number. It loads the entity's persistedestablished_findings(load_established_findings) and re-injects any established material finding absent from this run's findings, tagging each withcarried_from_baseline=Trueandnot_reconfirmed_this_run=True(an honesty flag the ADR-0067 not-assessed contract renders). The re-injected findings flow back intoinvestigation_result["findings"]before the EBA reassessment runs, so a re-screen that misses the EPPO article still re-floors to CRITICAL from the persisted finding, not just from a bare score. The workflow logs amaterial_findings_reinjectedaudit event with the count and categories re-injected.
Component D — fail-closed on a material-check data-gap
A retrieval gap must never be read as evidence of lower risk. adverse_media_agent.py and
osint_agent.py set a structured material_check_incomplete=True flag whenever the
sanctions/PEP/adverse-media screen itself failed or was skipped (no API key configured, the
provider call raised, or the analysis step failed) — distinct from a screen that ran cleanly
and found nothing. confidence_engine.compute_confidence takes a material_check_incomplete
parameter and applies a confidence-cap penalty (cap_reason = "material_check_incomplete")
rather than letting an incomplete screen contribute to a confident score.
upsert_baseline reads the flag (explicit argument, or falls back to
investigation_result.get("material_check_incomplete")) and:
- Persists it to
entity_baselines.material_check_incomplete, surfacing the ADR-0067 "not assessed" banner wherever the baseline is read. - On a first-ever assessment (no established row to hold a floor against) with the flag set, floors the review cadence to EDD / 12 months regardless of the computed tier — a data-gapped first screen is re-reviewed soon rather than parked on a looser SDD/CDD interval.
- Annotates any held
divergence_statewithmaterial_check_incomplete, which Component E's approval step reads and hard-blocks on (below) — a data-gap can never enable a downgrade, only ever delay one.
Component E — RISK_DIVERGENCE alert + audited downgrade
The only way an entity's persisted risk goes down. When reconcile_baseline_risk detects a
downgrade, upsert_baseline calls MonitoringAlertService.create_risk_divergence (same
tenant-scoped session, flushed before the dependent baseline write commits) which raises a
MonitoringAlert with trigger_type=MonitoringTriggerType.risk_divergence, priority 1
(the same 24-hour SLA tier as any other critical alert — see Alert Disposition
Lifecycle below), risk_score_before/_after set to
the effective/raw scores, and a material_changes_summary explicitly stating the floor was held
and that a downgrade needs a second, different approver. The alert creation is non-blocking:
if it fails, the floor still holds and the divergence is still recorded in divergence_state —
raising the alert can never gate the ratchet itself. A risk_divergence_detected audit_events
row is written alongside.
Resolving the divergence is a distinct, two-step maker-checker flow (ADR-0070) — separate from the alert's own new→triaged→escalated→closed lifecycle, which can still track/triage the alert as a task in parallel:
| Endpoint | Effect |
|---|---|
POST /api/monitoring/baselines/{country}/{registration_number}/downgrade/request | Maker step (request_baseline_downgrade): records the requesting officer + a mandatory ≥10-character reason into divergence_state.pending_downgrade. Does not lower risk. Fails closed (BaselineDowngradeError → 409) if there is no open divergence_state to downgrade, or no maker identity. Writes an immutable risk_downgrade_requested audit event |
POST /api/monitoring/baselines/{country}/{registration_number}/downgrade/approve | Checker step (approve_baseline_downgrade): a second, different approver, enforced by maker_checker.assert_distinct_approver (a same-actor self-approval is rejected). Hard-blocked (409) if the held-down run's material_check_incomplete is set — a data-gap's low score is a retrieval miss, not evidence of lower risk; the material check must complete (a fresh, complete re-screen) before the divergence can be resolved. On success: latest_risk_score/latest_risk_tier are lowered to the raw last_run_* values, established_findings is reset to the raw run's material findings (never below the raw run), divergence_state is cleared, next_review_due re-derives from the new (lower) tier, and an immutable risk_downgrade_approved audit event records both the maker and checker identities, the reason, and the from/to score+tier |
Both endpoints are gated on Permission.MONITORING_DISPOSE (officer+, RBAC ADR-0074) and are
tenant-scoped via get_tenant_session, with the tenant_id written explicitly onto every audit
event (never the column default — the PR #177 RLS WITH CHECK lesson).
Consequence for perpetual KYC
The CRITICAL→medium silent flip that motivated ADR-0089 is now structurally impossible: the
worst a lower-recall re-screen can do to an established floor is raise a RISK_DIVERGENCE alert
and wait for two humans to agree it should come down. Combined with Component A
(temperature=0 on every compliance-output agent — synthesis_agent, mcc_classifier,
case_intelligence_agent, memo_justification_agent, finding_debugger, task_generator,
belgian_agent; dashboard_agent stays interactive at 0.7), identical evidence now produces
an identical score, so a divergence that does occur is attributable to a genuine retrieval
difference, not LLM sampling. The inherent limit is honest, not hidden: raw live-web retrieval
recall (BrightData/Tavily/Google) cannot be made deterministic — the design does not pretend
otherwise. It removes the two axes that had no excuse (LLM sampling, blind persistence) and
makes a retrieval gap fail closed, so a recall miss can raise scrutiny (via a divergence alert)
but can never quietly lower it.
Document Validity & Verification Staleness (ADR-0087)
check_document_expiry folds two AMLA Art. 26 concerns into one MonitoringEvent, both read from already-persisted evidence — no new Temporal activity or workflow change was needed:
-
Identity-document freshness.
identity_documents(persisted bydocument_validity_service.persist_identity_documentsfrom the Belgian-eID/passport-MRZ extraction already running invalidate_documents) is swept per case for documents pastexpiry_date. A lapsed document with noaccept_with_rationaledocument_expiry_decisionsrecord is CRITICAL and writes amonitoring_alertsrow (trigger_type="document_expired"). A document approaching expiry within the tier's warning horizon is WARNING, informational only:Tier Warning horizon EDD 90 days before expiry CDD 30 days before expiry SDD 30 days before expiry This horizon table (
EXPIRY_WARNING_HORIZON_DAYS_BY_TIER,document_validity_service.py) is intentionally distinct from the re-screen cadence table above (90/180/365d) — it answers "when should we warn about an upcoming expiry", not "when do we re-screen". -
Verification staleness. The latest
screening_results.screened_atperlist_type, checked againstsource_ttl.py's per-category TTL (sanctions 24h, PEP 24h, adverse media 7d, …) —source_ttl.pyhad zero callers before this check;check_document_expiryis its first. A stale category writes amonitoring_alertsrow (trigger_type="verification_stale").
An expired identity document without a decision record also blocks case approval — see the Decision Gates table (evaluate_document_expiry_block, ADR-0059/0065 pattern). The gate is cleared exclusively via POST /cases/{id}/documents/{docId}/expiry-decision (recollect | accept_with_rationale, factors mandatory for the latter) — there is no inline override.
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. Since ADR-0089,latest_risk_score/latest_risk_tierare the ratchet-reconciled effective value (never silently lowered by a re-screen);last_run_risk_score/last_run_risk_tier,established_findings,divergence_state, andmaterial_check_incompletecarry the raw-run and audit-trail detail — see Entity-Risk One-Way Ratchet below - Monitoring alerts — risk-delta-triggered alerts (
risk_score_before/after,risk_delta,material_changes_summary) filterable by status.trigger_type=risk_divergence(ADR-0089 Component E) is raised automatically byupsert_baselineon a held downgrade, alwayspriority=1 - Investigation deltas — field-level change records between investigation runs (dimension, previous/current value, risk impact, source)
Alert Disposition Lifecycle (W2, ADR-0084)
Every monitoring_alerts row now carries a typed, evidenced disposition lifecycle —
generalising the production-verified ADR-0045 suppression shape (mandatory rationale,
evidence_refs, typed reasons, telemetry) onto the alert queue, so a detection can never
silently die as an un-actioned list row. Service: monitoring_alert_service.py,
class MonitoringAlertService (named to avoid a collision with the pre-existing
alert_service.py, the ADR-0025 cross-case pattern-alert service).
Status machine
new ──▶ triaged ──▶ escalated ──▶ closed
└──────────────────────────────────▶ closed
└───────────────────────────▶ closed
Legal transitions only: new→triaged, new→closed, triaged→escalated, triaged→closed,
escalated→closed. closed is terminal — a wrongly closed alert is never reopened in
place; the underlying condition re-fires a new alert (append-only philosophy, matching
ADR-0045 revoke). An unknown/blank status transitions to nothing — fail-closed, never a
silent auto-close.
SLA / aging
due_at is derived from severity at write time (W1's trigger_router_service, or directly by
MonitoringAlertService.create_risk_divergence for ADR-0089's risk_divergence trigger type,
always at priority=1) or at triage, and is tenant-overridable downward only (an override
may tighten, never loosen — enforced in validate_monitoring_config and by min() in
compute_due_at):
| Priority | Severity | Default SLA |
|---|---|---|
| 1 | critical | 24 hours |
| 2 | warning | 7 days (168h) |
| 3 | info | 30 days (720h) |
overdue is derived at read time (never persisted) — a non-closed alert whose due_at has
passed.
Endpoints (RBAC-gated: Permission.MONITORING_DISPOSE, officer+)
| Endpoint | Effect |
|---|---|
POST /api/monitoring/alerts/{id}/assign | Assign to a tenant officer (PR #153 UserPicker); not a status transition |
POST /api/monitoring/alerts/{id}/triage | new → triaged; sets priority (1–3) and derives due_at |
POST /api/monitoring/alerts/{id}/escalate | triaged → escalated; target=sar pre-populates a draft SAR via SARService.raise_sar (ADR-0071) with an alert_id back-ref and stores the returned sar_id; target=review_case stores an officer-supplied review_case_id (W1) |
POST /api/monitoring/alerts/{id}/close | → closed; requires a typed closure_reason + closure_rationale (≥10 chars, the ADR-0045 floor). false_positive additionally requires ≥1 evidence_refs entry; escalated_sar/review_opened require the corresponding link (sar_id/review_case_id) already set — a closure can never claim an escalation it cannot point at |
GET /api/monitoring/alerts | Extended filters: status, assigned_to, overdue |
GET /api/monitoring/alerts/mi | Disposition-effectiveness MI (below) |
Closure reasons (AlertClosureReason): resolved, false_positive, escalated_sar,
review_opened, duplicate.
SAR link
Escalating with target=sar does not invent a second filing path — it calls the existing
ADR-0071 SARService.raise_sar, which creates a draft SAR that then follows its own
lifecycle (draft → pending_mlro → approved → submitted → acknowledged, MLRO four-eyes,
AMLR Art. 73 tipping-off boundary). The alert stores the returned sar_id; the SAR's
raised_reason carries a [monitoring alert {alert_id}] back-reference, so the chain
detection → alert → SAR is reconstructable end-to-end from either side.
MI counts
GET /api/monitoring/alerts/mi computes, per tenant, in SQL (no new tables):
time_to_close_seconds— p50/p90 + count of closed alertsbacklog— open total, overdue count, aging buckets (lt_1d,d1_7,d7_30,gte_30d)closure_reason_mix— count perAlertClosureReason
These are the AMLA "effectiveness over volume" numbers; W6's Monitoring Framework Record renders them.
Audit trail
Every transition writes one immutable audit_events row (ADR-0064) with an explicit
tenant_id (never the column's server default — the PR #177 RLS WITH CHECK lesson):
alert_assigned, alert_triaged, alert_escalated, alert_closed, each carrying actor id,
from/to status, rationale, evidence refs, and linked ids (sar_id/review_case_id).
UI
The dashboard's Analytics panel renders AlertQueue (replaces the read-only MonitoringAlerts
widget in the same slot) — status tabs, inline assign/triage/escalate/close actions (Sonner
toasts, no dialogs), and the MI strip. An empty tab states plainly that no alert-engine events
exist yet, never a fabricated "all clear."
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.
Monitoring Framework Record & Calibration (ADR-0088, W6)
The system-level, data-driven comply-or-explain artifact a tenant hands a supervisor to
demonstrate its monitoring framework and its risk-calibration discipline — built in the
ai_act_conformity_service mold (app/services/monitoring_framework_service.py): assembled
live from MonitoringCheckType, the canonical cadence tables (§2.2), and
country_capability, so it can never claim a check, cadence, or coverage the deployed system
does not actually have.
What it documents
- Check catalog — every
MonitoringCheckTypemember with an honest per-check maturity:production(e.g.ubo_screening,eori_validity,vop_reverification,material_change),partial(document_expiry,profile_deviation), orstub(company_status— registry-wired for Belgium only;aeo_status— EU AEO database not wired, every check returns an indeterminate WARNING). A check type absent from the curated descriptor dict still renders, with a genericpartiallabel, so a newly-addedMonitoringCheckTypemember is never silently missing. - Monitoring-form applicability —
country_capability.pygains aMonitoringFormenum (pre_onboarding/real_time/post_event) andMONITORING_FORM_BY_CHECK, an authored (not country-gated) mapping of when in the relationship lifecycle each check actually operates. - Cadences & AMLR ceilings — the §2.2 canonical
REVIEW_CADENCE_MONTHS_BY_TIER/RESCREEN_CADENCE_DAYS_BY_TIERper tier, alongside the AMLR Art. 26(2) ceiling (AMLR_MAX_CADENCE) and the non-disableable mandatory event triggers. - Documented limitations + mitigations —
country_capability_gap()gained an optionalmitigationparameter (recorded indetails["mitigation"]and appended to the finding description); the record's limitations register reads it directly for the company-status and AEO-status stubs above. - Calibration status — the tenant's active risk-config version, its activation rationale (mandatory since this wave — see below), and whether a defaults review is on file. An unreviewed factory-default configuration renders as an explicit "No — factory defaults in use" line, never silently blended into a clean report.
GET /api/monitoring/framework-record?format=json|pdf — format=pdf renders via WeasyPrint
(the decision-memorandum PDF pattern, PR #169); format=json (default) returns the structured
record for the admin UI.
Risk-config mandatory rationale + defaults-review artifact
risk_config_service.create_draft() now requires a non-empty rationale; activate_version()
requires ≥20 characters — the activation is the point a configuration starts scoring live
cases, so it carries a binding calibration-decision record persisted on risk_config_audit.rationale
(historical pre-migration rows render as "recorded before rationale capture (2026-07)", never
backfilled with fiction). POST /api/risk-config/defaults-review records the tenant's own
assessment of the EBA-derived factory defaults against its business-wide risk assessment — an
honest-surfacing artifact, not a hard activation block (architecture §5.3: a hard block
would brick new tenants; the framework record surfaces "unreviewed" instead).
Config-layer effectiveness runs
effectiveness_run_service.py replays two packaged, purpose-built synthetic snapshots
(app/data/effectiveness_snapshots/: benign_be_company.yaml, a low-risk baseline; and
subject_criminal_investigation.yaml, which exercises the deterministic CRITICAL/90
entity_criminal_investigation floor) through the pure compute_eba_risk computation against
a tenant's active or draft configuration. This is a config-layer replay — it recomputes
scoring from a frozen investigation snapshot; it does not re-run live OSINT retrieval. Every
run carries the CONFIG_LAYER_LABEL string verbatim so a supervisor or buyer never over-reads a
green run as end-to-end assurance. The criminal-investigation floor is asserted on `actual_score
= 90
, never on thepassedlabel — the label can legitimately flip under adversarial threshold-ordering while the underlying floor still holds (the label misreading apassed=False` run as a security bug is a false alarm the test suite documents explicitly).
Deviation from ADR-0088 §3's literal wording (documented in the ADR and architecture §5.7): the
harness does not reuse the 7 existing tests/golden/*.yaml oracles verbatim — those are
end-to-end, live-retrieval workflow-acceptance specs, not frozen config-layer input dicts.
POST /api/risk-config/effectiveness-runs (replay + persist, effectiveness_runs table) and
GET /api/risk-config/effectiveness-runs (history).
Data-quality register
data_quality_register_service.py aggregates already-emitted country_capability_gap findings
and data_quality_warnings across a tenant's cases into one register with per-source deficiency
ownership (read from tenant config; unassigned sources render as "unassigned", never a silent
default owner). Purely additive read-only aggregation — no new detection logic. GET /api/monitoring/data-quality.
Case-pack ongoing-monitoring appendix
case_pack_service.py gained a _monitoring_appendix() section rendering the post-approval
MonitoringEvent trail, the ScreeningResult evidence trail (ADR-0063), and monitoring_alerts
dispositions (ADR-0084) into the regulator-facing case pack (ADR-0069). The appendix is never
silently omitted: a case that hasn't reached an approved status gets "monitoring has not
started"; an approved case with zero recorded activity gets an explicit "honest absence, not a
clean result" note; a genuine load failure raises CasePackDataUnavailableError rather than
sealing a pack that misrepresents a failure as an honest absence. W2-only monitoring_alerts
columns (closure_reason/closure_rationale/closed_by) are read via getattr(row, col, None)
so a pre-W2 deployment degrades one appendix detail instead of failing the whole export.
EU AI Act conformity — post-market monitoring, satisfied by reference
ai_act_conformity_service.py's KnownGap for "Post-market monitoring (Art. 72)" flips from
partial to status="satisfied", pointing at this Monitoring Framework Record — "satisfied by
reference," not by rewriting the copy: the gap closes only because the referenced mechanism now
genuinely exists.
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, per-workflow investigation deltas, (ADR-0088) GET /framework-record (?format=json|pdf) + GET /data-quality, and (ADR-0089) POST /baselines/{country}/{registration_number}/downgrade/request + POST /baselines/{country}/{registration_number}/downgrade/approve (maker-checker entity-risk downgrade, Permission.MONITORING_DISPOSE). app/api/risk_config.py gains POST /defaults-review and POST/GET /effectiveness-runs. GET /retention (super_admin, ADR-0108/#384) reports the retention-purge posture — flag, Temporal schedule presence (missing = loud), last RETENTION_PURGE audit tally, and the honest per-store coverage map — see PII Classification → Proactive retention purge.
Related ADRs
- ADR-0066 — Live risk-paced UBO re-screening + general-population monitoring
- ADR-0084 — Monitoring-alert disposition lifecycle (assign/triage/escalate/close, SAR link, MI counts)
- ADR-0063 — Typed persisted
ScreeningResultevidence trail (issue #45) - ADR-0045 — Sanctions false-positive suppression (reused by the re-screen pipeline; disposition shape generalised by ADR-0084)
- ADR-0071 — SAR/STR lifecycle (reused by ADR-0084's SAR-escalation link)
- ADR-0089 — Deterministic compliance scoring + entity-risk one-way ratchet + fail-closed re-screen (the entity-baseline ratchet, material-findings persistence/re-injection, and audited downgrade documented above; also pins the 7 compliance-output agents to
temperature=0) - ADR-0088 — Monitoring Framework Record, calibration records & config-layer effectiveness testing