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.
-
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,discrepancyJSONB (the entity's findings + the register value + reasons),classification,status,report_deadline,derogation_deadline,reported_at,higher_risk, plusofficer_id/report_reference/resolution_noteand audit columns. FORCE ROW LEVEL SECURITY with both atenant_isolationpolicy and anadmin_bypasspolicy (the migration mirrors the 017/023 two-policy pattern, deliberately NOT the 085 single-policy shape that omitted admin_bypass — the #561 gap). Noserver_defaultontenant_id— every writer sets it explicitly (WITH CHECK, PR #177 lesson). Two CHECK constraints pin theclassificationandstatusvocabularies at the DB. -
Deterministic, fail-closed classification (
classify_discrepancy, pure).genuine|minor_typo|outdated_reliable. Cardinal rule: a HIGH/CRITICAL mismatch is ALWAYSgenuine— 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 togenuine(the report obligation stands, never silently dropped). -
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). -
The lifecycle (
register_discrepancy_service.RegisterDiscrepancyService).create_reportclassifies, decides the opening status, stamps deadlines, records the consultation timestamp:genuine(or any higher-risk) discrepancy → statusdetected,report_deadline = detected_at + 14 days, no derogation window.minor_typo/outdated_reliableon a NON-higher-risk case → statusderogation_invited,derogation_deadline = detected_at + 14 days(customer-correction window), no report obligation yet.record_report_to_register→reported(valid only from an open report obligation).record_derogation_outcome(corrected=True)→resolved;(corrected=False)→escalated_to_reportwith a freshreport_deadline = now + 14 days.check_derogation_deadlinesis the fail-closed sweep: aderogation_invitedrow whose window has lapsed uncorrected escalates toescalated_to_report(never silently expires).
-
Deadline tracking (
list_due_reports). Rows with a live obligation are bandedbreached/due/upcomingon their report or derogation deadline; terminalreported/resolvedrows are excluded. Reuses the banded pattern of the ADR-0098 document-expiry surface. -
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 requireCASE_DECIDE; reads requireCASE_READ. Wrong-state transitions → 409, not-found → 404. -
Wiring (
cross_reference_evidence_activity, guard-and-swallow, idempotent per case). After the cross-reference completes,create_reports_from_cross_referenceselects 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 forhigher_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). -
Every transition is audited — immutable, hash-chained
register_discrepancy_detected/_derogation_invited/_reported/_resolved/_escalatedrows inaudit_events(ADR-0064/0109), written via the canonicalAuditService.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 -1drops 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
DiscrepancyResolutioninternal-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 togenuine; 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_riskuses 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.