Skip to main content

ADR-0129: BO register-discrepancy report + Art. 24(2) derogation + 14-day lifecycle

Date: 2026-07-24 Status: Accepted Deciders: Adrian (Soft4U BV), Claude (Opus 4.8) Extends: ADR-0059 (open-UBO-discrepancy approval block — the INWARD gate), ADR-0064/0109 (immutable, hash-chained audit_events), ADR-0023 (PostgreSQL RLS multi-tenant isolation) Issue: #537 (AMLR epic #528)

Context

Today, discrepancy handling is an internal block gate. ADR-0059 (discrepancy_approval_gate) makes an open UBO/identity discrepancy block approval — an INWARD control that protects the obliged entity's own decision. The cross-reference engine (cross_reference_service) already detects the discrepancies (legal-form / address / director / UBO / registration-number / LEI mismatches across registry, VIES, GLEIF, NorthData, documents) and persists them in the cross_reference_result JSONB.

AMLR Art. 24 imposes a separate, OUTWARD duty that the platform did not implement: on resolving beneficial-ownership / identity findings, the obliged entity must compare them against the data held in the central (beneficial-ownership) register, record when the register was consulted (Art. 20(1)(b) / 22(7)), and report a genuine discrepancy to the register keeper within 14 calendar days of detecting it. Art. 24(2) grants a narrow derogation: where the discrepancy is a minor / typing error, or the register is outdated but otherwise reliable, the entity may first invite the customer to correct the register entry within a deadline, escalating to a report only if the correction is not made — and this derogation is hard-disabled for higher-risk (Section 4 / EDD) cases.

The detection existed; the outward reporting lifecycle did not. Reusing ADR-0059's detection without replacing its gate is the whole shape of this decision.

Decision

Add an OUTWARD register-reporting lifecycle ON TOP of the existing ADR-0059 gate (which stays unchanged). Detection is reused; the lifecycle is new.

  1. New RLS table register_discrepancy_report (Alembic 094, down_revision=093). One append-only row per detected register discrepancy: register_source (which central register), consultation_timestamp (when it was consulted), detected_at, discrepancy JSONB (the entity's findings + the register value + reasons), classification, status, report_deadline, derogation_deadline, reported_at, higher_risk, plus officer_id / report_reference / resolution_note and audit columns. FORCE ROW LEVEL SECURITY with both a tenant_isolation policy and an admin_bypass policy (the migration mirrors the 017/023 two-policy pattern, deliberately NOT the 085 single-policy shape that omitted admin_bypass — the #561 gap). No server_default on tenant_id — every writer sets it explicitly (WITH CHECK, PR #177 lesson). Two CHECK constraints pin the classification and status vocabularies at the DB.

  2. Deterministic, fail-closed classification (classify_discrepancy, pure). genuine | minor_typo | outdated_reliable. Cardinal rule: a HIGH/CRITICAL mismatch is ALWAYS genuine — a caller-supplied classification hint can never downgrade a material discrepancy. A LOW/MEDIUM discrepancy honours an explicit hint, else derives from explanation markers (typo/formatting → minor_typo; outdated/superseded → outdated_reliable). Anything unclassifiable defaults to genuine (the report obligation stands, never silently dropped).

  3. Higher-risk hard-disables the derogation (is_higher_risk, derogation_available, pure). Higher-risk = an EDD review tier OR a HIGH/CRITICAL composite risk level. For a higher-risk case the derogation is unavailable for EVERY classification — a minor typo on a higher-risk case is still reported. Two independent never-suppress guards: the classification guard (severity → genuine) and the case-tier guard (higher-risk → no derogation).

  4. The lifecycle (register_discrepancy_service.RegisterDiscrepancyService). create_report classifies, decides the opening status, stamps deadlines, records the consultation timestamp:

    • genuine (or any higher-risk) discrepancy → status detected, report_deadline = detected_at + 14 days, no derogation window.
    • minor_typo / outdated_reliable on a NON-higher-risk case → status derogation_invited, derogation_deadline = detected_at + 14 days (customer-correction window), no report obligation yet. record_report_to_registerreported (valid only from an open report obligation). record_derogation_outcome(corrected=True)resolved; (corrected=False)escalated_to_report with a fresh report_deadline = now + 14 days. check_derogation_deadlines is the fail-closed sweep: a derogation_invited row whose window has lapsed uncorrected escalates to escalated_to_report (never silently expires).
  5. Deadline tracking (list_due_reports). Rows with a live obligation are banded breached / due / upcoming on their report or derogation deadline; terminal reported / resolved rows are excluded. Reuses the banded pattern of the ADR-0098 document-expiry surface.

  6. API (app/api/register_discrepancy.py, append-only, tenant/RLS-scoped, audited). GET /cases/{wf}/register-discrepancies, GET /register-discrepancies/due, POST /cases/{wf}/register-discrepancies/{id}/report, POST /cases/{wf}/register-discrepancies/{id}/derogation-outcome. Officer transitions require CASE_DECIDE; reads require CASE_READ. Wrong-state transitions → 409, not-found → 404.

  7. Wiring (cross_reference_evidence_activity, guard-and-swallow, idempotent per case). After the cross-reference completes, create_reports_from_cross_reference selects only the discrepancies where the central register itself disagrees (select_register_discrepancies — a discrepancy with no registry value has nothing to report against, correctly skipped), reads the best-available risk signal for higher_risk, and creates one report per discrepancy. It never blocks the pipeline (any failure is logged and swallowed) and skips creation when the case already has reports (a Temporal retry never duplicates rows).

  8. Every transition is audited — immutable, hash-chained register_discrepancy_detected / _derogation_invited / _reported / _resolved / _escalated rows in audit_events (ADR-0064/0109), written via the canonical AuditService.log_event (guard-and-swallow: the persisted row is the record of truth, an audit-write failure is logged, never lost silently).

Decision context:

  • Latency / cost: the wiring reuses the already-computed cross-reference result; report creation is a small INSERT per register discrepancy on the pre-investigation path, idempotent and guard-and-swallowed. No new external calls. Deadline sweep is a single indexed query.
  • Dependency surface: zero new packages. One migration (094), one Pydantic model file, one service, one API router, one activity wiring block, plus the ORM model.
  • Reversibility: alembic downgrade -1 drops the table + both policies cleanly (verified on the dev DB: upgrade → downgrade removes it → re-apply restores). The wiring is a guard-and-swallow block that can be removed without touching the cross-reference result.
  • Blast radius: purely additive. The ADR-0059 inward gate is untouched; the outward lifecycle is a new table + surface. A failure in the outward path never affects the cross-reference or the approval decision.
  • Alternative considered: repurpose the ADR-0059 DiscrepancyResolution internal-resolution table for the outward lifecycle (rejected — it models the officer's internal resolution of a blocking discrepancy, a different actor, deadline, and audit semantics than a statutory report to a register keeper; conflating them would blur the inward/outward distinction the regulation draws).

Consequences

Positive

  • The Art. 24 outward duty is implemented as a first-class, auditable lifecycle with the load-bearing 14-day report deadline, distinct from and additive to the ADR-0059 inward gate.
  • Never-suppress holds by construction: a material (HIGH/CRITICAL) mismatch is always genuine; an unclassifiable discrepancy defaults to genuine; the derogation is never available for a higher-risk case; a lapsed derogation escalates to a report rather than expiring.
  • Every state transition is on the immutable, hash-chained audit trail (EU AI Act Art. 12 / AMLR 5-year retention).
  • Tenant-isolated with FORCE RLS and both policies — closes the 085 admin_bypass omission for this table.

Negative

  • A new officer surface: genuine discrepancies now carry a tracked reporting obligation with a deadline that must be actioned (the intended regulatory friction).
  • Classification is deterministic and marker-based; a genuinely-minor discrepancy whose explanation lacks a typo/outdated marker is treated as genuine (fail-closed over-reporting, the correct direction — the officer can still use the derogation surface where eligible).

Neutral

  • Detection is entirely reused from cross_reference_service; this ADR adds only the outward lifecycle on top.
  • The wiring runs at the pre-investigation cross-reference step; higher_risk uses the best-available risk signal there (unknown → standard path, where genuine discrepancies still always report).

Alternatives Considered

Alternative 1: Extend the ADR-0059 DiscrepancyResolution table

  • Add outward-report columns (register_source, deadlines, report status) to the existing internal-resolution table.
  • Why rejected: it models a different thing — the officer's internal resolution of a blocking discrepancy — with different actors, semantics, and audit needs than a statutory report to an external register keeper. A dedicated table keeps the inward/outward distinction clean and lets each evolve independently.

Alternative 2: Make the derogation an officer judgment call with no hard higher-risk block

  • Let officers choose the derogation on any case with a rationale.
  • Why rejected: Art. 24(2) hard-disables the derogation for higher-risk cases. Encoding it as a deterministic guard (never available when higher-risk) rather than an overridable default is the fail-closed, regulation-faithful posture.

Alternative 3: Text-classify discrepancies with an LLM

  • Use a model to decide genuine vs minor/outdated.
  • Why rejected: this is a compliance-output decision (it drives a statutory report). A deterministic, auditable classifier with a fail-closed default (genuine) is reproducible and never silently downgrades a material mismatch — an LLM call here would add non-determinism to a decision that must be traceable and repeatable.