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)
entity_baseline_service.pyEntity-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.pyFingerprints 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.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 unimplemented stubs that fail closed — they never report a benign no-change (ADR-0067 extension):

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; an errored lookup is a fail-closed indeterminate WARNING (complete: false), never a clean result
vop_reverificationLiveVoPService.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_statusStub (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_statusStub (fail-closed)EU AEO database query not yet wired — returns the same indeterminate WARNING, so a lost AEO authorisation cannot be ruled out
document_expiryLiveIdentity-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_status and aeo_status are unimplemented stubs that fail closed — rather than a benign INFO "no change", they return an indeterminate WARNING with current_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:

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).

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/2critical / high_tier_3high 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_state payload 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/eppocriminal; sanction/embargosanctions; freeze/frozenfreeze; regulatory/regulatory_actionregulatory_action; enforcement/debarmentenforcement; adverseadverse_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) — what upsert_baseline persists to established_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 the reinject_established_findings Temporal activity, called from compliance_case.py right before risk-reassessment checkpoint 1 (post-OSINT), on every investigation run that has a company_registration_number. It loads the entity's persisted established_findings (load_established_findings) and re-injects any established material finding absent from this run's findings, tagging each with carried_from_baseline=True and not_reconfirmed_this_run=True (an honesty flag the ADR-0067 not-assessed contract renders). The re-injected findings flow back into investigation_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 a material_findings_reinjected audit 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_state with material_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:

EndpointEffect
POST /api/monitoring/baselines/{country}/{registration_number}/downgrade/requestMaker 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/approveChecker 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:

  1. Identity-document freshness. identity_documents (persisted by document_validity_service.persist_identity_documents from the Belgian-eID/passport-MRZ extraction already running in validate_documents) is swept per case for documents past expiry_date. A lapsed document with no accept_with_rationale document_expiry_decisions record is CRITICAL and writes a monitoring_alerts row (trigger_type="document_expired"). A document approaching expiry within the tier's warning horizon is WARNING, informational only:

    TierWarning horizon
    EDD90 days before expiry
    CDD30 days before expiry
    SDD30 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".

  2. Verification staleness. The latest screening_results.screened_at per list_type, checked against source_ttl.py's per-category TTL (sanctions 24h, PEP 24h, adverse media 7d, …) — source_ttl.py had zero callers before this check; check_document_expiry is its first. A stale category writes a monitoring_alerts row (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_due driven by monitoring_cadence_months. Since ADR-0089, latest_risk_score/latest_risk_tier are the ratchet-reconciled effective value (never silently lowered by a re-screen); last_run_risk_score/last_run_risk_tier, established_findings, divergence_state, and material_check_incomplete carry 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 by upsert_baseline on a held downgrade, always priority=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):

PrioritySeverityDefault SLA
1critical24 hours
2warning7 days (168h)
3info30 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+)

EndpointEffect
POST /api/monitoring/alerts/{id}/assignAssign to a tenant officer (PR #153 UserPicker); not a status transition
POST /api/monitoring/alerts/{id}/triagenew → triaged; sets priority (1–3) and derives due_at
POST /api/monitoring/alerts/{id}/escalatetriaged → 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/alertsExtended filters: status, assigned_to, overdue
GET /api/monitoring/alerts/miDisposition-effectiveness MI (below)

Closure reasons (AlertClosureReason): resolved, false_positive, escalated_sar, review_opened, duplicate.

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 alerts
  • backlog — open total, overdue count, aging buckets (lt_1d, d1_7, d7_30, gte_30d)
  • closure_reason_mix — count per AlertClosureReason

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:

  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.

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 MonitoringCheckType member with an honest per-check maturity: production (e.g. ubo_screening, eori_validity, vop_reverification, material_change), partial (document_expiry, profile_deviation), or stub (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 generic partial label, so a newly-added MonitoringCheckType member is never silently missing.
  • Monitoring-form applicabilitycountry_capability.py gains a MonitoringForm enum (pre_onboarding / real_time / post_event) and MONITORING_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_TIER per tier, alongside the AMLR Art. 26(2) ceiling (AMLR_MAX_CADENCE) and the non-disableable mandatory event triggers.
  • Documented limitations + mitigationscountry_capability_gap() gained an optional mitigation parameter (recorded in details["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|pdfformat=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 the passedlabel — 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.

  • 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 ScreeningResult evidence 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