Skip to main content

ADR-0087: Identity-document validity model, risk-based expired-document decisions & verification staleness

Date: 2026-07-03 Status: Accepted (implemented 2026-07-03, W5) Deciders: Adrian (Soft4U), Claude Fable 5 (AMLA remediation design session)

Decision context:

  • Latency: not measured because the hot path adds only (a) one INSERT per uploaded identity document during validate_documents (already an I/O-heavy Temporal activity) and (b) one indexed date-range query per case during the monitoring sweep. The expired-document gate is a single lookup at decision time, same shape as the ADR-0059 discrepancy gate already on that endpoint.
  • Dependency surface: no new packages. Two new RLS tables (Alembic, next sequential revision at implementation time), one new service (document_validity_service.py), one enum member on an existing enum (VerificationMethod.eudi_wallet), and first callers for two pieces of currently-dead code (source_ttl.py, VerificationRecord.collected_at freshness).
  • Debuggability: every expired-document outcome is a typed row (document_expiry_decisions) plus an audit_events entry; every staleness/expiry detection is a MonitoringTriggerType routed through the ADR-0083 trigger spine — nothing terminates in a transient dict that dies with the workflow run.
  • Reversibility: additive schema + one behavioural change (the automatic expired=FAIL becomes a gated decision). Reverting restores the old hard-fail by re-enabling the severity override; the tables are append-only records and can simply stop being written.
  • Blast radius: validate_documents (activities.py) gains a persistence step; case_decisions.py gains one more 409 gate in the existing gate chain; ContinuousMonitoringWorkflow gains a sweep_document_expiry activity on the existing schedule. No existing check is removed or weakened — the BE-eID card_expiry check keeps firing; only its consequence changes.
  • Alternative considered: keep the automatic hard-FAIL and bolt an override endpoint onto it (rejected below — it leaves every other document type unassessed and inverts the AMLA decision model).

Context

The AMLA draft ongoing-monitoring guidelines (2026) require obliged entities to treat an expired identity document as a risk-based decision point — weigh risk tier, issuing country, and how long the document has been expired, then either re-collect or accept with a documented rationale — and to keep customer-file data current (AMLR (EU) 2024/1624 Art. 26; the "keep documents, data and information up to date" duty). The 2026-07-03 gap analysis (docs/research/2026-07-03-amla-ongoing-monitoring-gap-analysis.md, §3.1 "Document freshness" row) verified that this repo is wrong in both directions:

  1. No persisted validity data anywhere. expiry_date, issue_date, and issuing_country exist in no table and no persisted model (grep-verified). The Belgian eID validator extracts valid_until (app/services/document_validators/belgian_eid.py:45BelgianEidFields.valid_until; extraction regex at :594-601 inside extract_fields_from_markdown, :512) but the value lives only in a transient dataclass inside one activity run. PersonIdentityRecord.document_valid_until (packages/trustrelay-models/src/trustrelay_models/person_identity.py) exists but is only populated by the mock eID-Easy channel.
  2. The one live expiry check is an automatic hard FAIL. validate_expiry (belgian_eid.py:369-389) returns FAIL/severity high for any expired card; _run_belgian_eid_checks (app/workflows/activities.py:463, called BE-only for director_id at :736) then flips is_valid=False for any high/critical FAIL (activities.py:518-525). An expired card is automatically invalid — no risk-tier weighing, no officer decision, no record of one. That is the opposite of the AMLA model. Meanwhile passports, residence permits, and every non-BE, non-director_id document get no expiry check at all — silently, with no "not assessed" surfacing, violating the ADR-0067/0068 honesty contract.
  3. Staleness machinery exists but is dead. app/services/source_ttl.py defines per-source-category TTLs (SOURCE_TTLS, is_source_fresh, get_ttl) and has zero callers (gap analysis §3.2.5). VerificationRecord.collected_at (packages/trustrelay-models/src/trustrelay_models/person_verification.py:27) is persisted on every verification record but never read for freshness — the min-2-source gate (app/services/verification_gate.py:58 attribute_verification_status, :98 evaluate_profile_gates) counts distinct sources only; a verification collected three years ago gates identically to one collected yesterday.

The canonical remediation vocabulary is fixed by the architecture document (docs/superpowers/specs/2026-07-03-amla-remediation-architecture.md §2.1, §2.3, §2.4, wave W5): tables identity_documents and document_expiry_decisions, service document_validity_service.py, MonitoringCheckType.document_expiry, MonitoringTriggerType.document_expired / verification_stale, and VerificationMethod.eudi_wallet (AMLR Art. 22(6)). This ADR records the decision cluster behind that W5 slice.

Decision

  1. Persist typed validity data: identity_documents. Create (next sequential Alembic revision, one migration for the wave) an RLS table identity_documents with columns exactly as architecture §2.3: id, tenant_id, case_id, document_id (MinIO record ref), doc_type, expiry_date, issue_date, issuing_country, extraction_source, created_at. document_validity_service.py (new, backend/app/services/) writes one row per identity document whose validity fields could be extracted, called from the validate_documents activity after Docling conversion. extraction_source records provenance (belgian_eid_regex, passport_mrz, eid_easy_session, …) per the every-output-carries-provenance rule. Every INSERT sets tenant_id explicitly (architecture §1.4, the PR #177 lesson — no server_default).
  2. Promote extraction; extend to passport MRZ; stay honest elsewhere. The existing BE-eID extraction (belgian_eid.py:512/594-601) is promoted from transient to persisted. A passport MRZ expiry parse is added as a document_validators extension (MRZ TD3 line 2 carries expiry + issuing state; check digits give a deterministic parse-confidence signal). For every document type with no validity extractor, the validation result carries an explicit expiry_assessment: "not_assessed" finding — the ADR-0068 pattern ("not assessed for this document type"), never a silent pass. Unparseable dates remain INCONCLUSIVE (mirroring validate_expiry's existing behaviour), which is a data-gap finding, not a pass.
  3. Replace automatic expired=FAIL with a risk-based decision gate. The card_expiry FAIL check keeps firing and stays visible — the never-suppress rule is untouched. What changes is the consequence: card_expiry FAIL no longer trips the blanket high-severity is_valid=False override in activities.py:518-525; instead an expired identity document raises a case-level gate. At decision time, case_decisions.py 409-blocks approval (the ADR-0059/ADR-0065 gate pattern already chained there) until a document_expiry_decisions row exists for each expired identity_documents row: decision ∈ {recollect, accept_with_rationale}, factors JSONB capturing risk_tier, issuing_country, days_expired (the AMLA-named factors), rationale NOT NULL, decided_by, append-only, with an audit_events row per decision (ADR-0064). recollect routes through the standard follow-up/portal path — outreach states what is required, never why (AMLD Art. 39, ADR-0071 tipping-off boundary). accept_with_rationale on a high-risk case falls under the existing maker-checker regime (ADR-0070) as a fail-closed-gate override.
  4. Ongoing expiry monitoring. Add MonitoringCheckType.document_expiry and MonitoringTriggerType.document_expired (architecture §2.1, byte-exact). ContinuousMonitoringWorkflow gains a sweep_document_expiry activity on the existing schedule (architecture: "same schedule, new activities"): documents within an approaching-expiry horizon per tier (tighter for EDD) emit a trigger routed through trigger_router_service (ADR-0083) — default targeted_update for expired, record_only for approaching — landing as monitoring_alerts rows, never a bare list entry.
  5. Verification staleness: source_ttl.py gets its first caller. document_validity_service evaluates VerificationRecord.collected_at against get_ttl(source_category) and emits MonitoringTriggerType.verification_stale when exceeded. The min-2-source gate (verification_gate.py) stays count-based — staleness does not silently subtract sources from an already-passed gate (that would retroactively mutate onboarding outcomes); it routes forward through the alert spine so the response is a documented refresh, not a quiet re-fail.
  6. eudi_wallet VerificationMethod — model level only. Append eudi_wallet = "eudi_wallet" to VerificationMethod (packages/trustrelay-models/src/trustrelay_models/person_identity.py:9), acknowledging AMLR Art. 22(6) EUDI-wallet verification as a first-class method in the data model. The live channel stays vendor-gated NotImplementedError exactly like the real eID-Easy channel (app/services/eid_easy_service.py:160,180) — never silently mocked in production.

Consequences

Positive

  • Expired-document handling matches the AMLA guideline in both directions: a documented risk-based decision (recollect vs accept-with-rationale, with the named factors) replaces the automatic FAIL, and document types beyond BE-eID director_id gain either a real expiry assessment (passport MRZ) or an honest "not assessed" finding instead of silence.
  • Validity data becomes queryable, tenant-scoped, provenance-tagged rows — enabling the document_expiry monitoring check, case-pack rendering (ADR-0069), and the W6 Monitoring Framework Record to state document-freshness coverage truthfully.
  • Two verified pieces of dead code (source_ttl.py, collected_at-freshness) become load-bearing rather than being deleted, closing gap-analysis §3.2.5 without new machinery.
  • No risk signal is weakened: card_expiry FAIL remains a visible finding; the accept path is an evidence-traceable, officer-decided, maker-checker-guarded, audited record — strictly more scrutiny than today's undocumented binary.

Negative

  • Officer friction increases: an expired document now hard-stops approval (409) until a decision record exists, where today a BE case simply showed an invalid document. For low-risk cases with a document expired by days, this is extra ceremony the old model did not demand — the price of the documented-decision requirement.
  • Extraction quality becomes load-bearing for a compliance decision. The BE regex and the new MRZ parse can misread dates (OCR noise in Docling markdown); a wrongly-parsed future date suppresses the gate. Mitigation is the INCONCLUSIVE/data-gap path plus MRZ check digits, but the residual false-negative risk is real and must be stated in the W6 limitations register.
  • SOURCE_TTLS defaults (24h sanctions … 90d financial) were written for OSINT re-query caching, not verification-staleness policy, and have never been calibrated per tenant — first-caller adoption inherits uncalibrated defaults. This is exactly the "default settings" practice the gap analysis flags (§3.1 calibration row); the W6 defaults-review record must cover them.

Neutral

  • Two more append-only RLS tables and one more monitoring sweep on the existing schedule — write volume grows with uploaded identity documents, which is small per case.
  • The BE-eID checksum/cross-reference checks (NRN, MRZ, name-match) are untouched; only the expiry consequence path changes.
  • eudi_wallet is a vocabulary commitment, not a capability claim: conformity and capability surfaces must continue to report the channel as not implemented until a vendor integration lands.

Alternatives Considered

  1. Keep the automatic expired=FAIL and add an officer override endpoint. Rejected: it preserves the inverted decision model (system decides, officer un-decides) instead of the AMLA-required risk-based decision with documented factors; it does nothing for the larger half of the gap — passports and all non-BE documents would still carry no expiry assessment and no honest "not assessed" surfacing; and an override-on-top-of-hard-fail records that an exception happened but not the risk-tier/issuing-country/days-expired factors the guideline names.
  2. Store expiry/issue/issuing-country in the existing extracted_fields JSONB, untyped. Rejected: no schema means no indexable date-range query for the document_expiry monitoring sweep (a full-population JSONB scan per run), no typed FK for document_expiry_decisions to anchor to, and the same drift failure mode as the three unreconciled cadence tables (gap analysis §3.2.4) — untyped duplicates diverge. Architecture §2.3 fixes the typed table as canonical.
  3. Per-country validators only (extend the belgian_eid pattern to more countries), without a decision record. Rejected: it improves detection but not response — the audit's core finding is that detections die without a routed, documented outcome. More validators feeding the same automatic FAIL multiplies the wrong behaviour; the decision record and gate are the substance of the AMLA requirement, the validators are inputs to it.
  4. Do nothing. Not viable: the current behaviour is affirmatively non-compliant in both directions (automatic non-risk-based fail for one document type, zero assessment plus silence for all others), and the AMLA guidelines make document currency an explicit supervisory expectation under AMLR Art. 26.