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_atfreshness). - Debuggability: every expired-document outcome is a typed row (
document_expiry_decisions) plus anaudit_eventsentry; every staleness/expiry detection is aMonitoringTriggerTyperouted 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.pygains one more 409 gate in the existing gate chain;ContinuousMonitoringWorkflowgains asweep_document_expiryactivity on the existing schedule. No existing check is removed or weakened — the BE-eIDcard_expirycheck 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:
- No persisted validity data anywhere.
expiry_date,issue_date, andissuing_countryexist in no table and no persisted model (grep-verified). The Belgian eID validator extractsvalid_until(app/services/document_validators/belgian_eid.py:45—BelgianEidFields.valid_until; extraction regex at:594-601insideextract_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. - The one live expiry check is an automatic hard FAIL.
validate_expiry(belgian_eid.py:369-389) returns FAIL/severityhighfor any expired card;_run_belgian_eid_checks(app/workflows/activities.py:463, called BE-only fordirector_idat:736) then flipsis_valid=Falsefor 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_iddocument get no expiry check at all — silently, with no "not assessed" surfacing, violating the ADR-0067/0068 honesty contract. - Staleness machinery exists but is dead.
app/services/source_ttl.pydefines 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:58attribute_verification_status,:98evaluate_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
- Persist typed validity data:
identity_documents. Create (next sequential Alembic revision, one migration for the wave) an RLS tableidentity_documentswith 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 thevalidate_documentsactivity after Docling conversion.extraction_sourcerecords provenance (belgian_eid_regex,passport_mrz,eid_easy_session, …) per the every-output-carries-provenance rule. Every INSERT setstenant_idexplicitly (architecture §1.4, the PR #177 lesson — noserver_default). - 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 adocument_validatorsextension (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 explicitexpiry_assessment: "not_assessed"finding — the ADR-0068 pattern ("not assessed for this document type"), never a silent pass. Unparseable dates remain INCONCLUSIVE (mirroringvalidate_expiry's existing behaviour), which is a data-gap finding, not a pass. - Replace automatic expired=FAIL with a risk-based decision gate. The
card_expiryFAIL check keeps firing and stays visible — the never-suppress rule is untouched. What changes is the consequence:card_expiryFAIL no longer trips the blanket high-severityis_valid=Falseoverride inactivities.py:518-525; instead an expired identity document raises a case-level gate. At decision time,case_decisions.py409-blocks approval (the ADR-0059/ADR-0065 gate pattern already chained there) until adocument_expiry_decisionsrow exists for each expiredidentity_documentsrow:decision∈ {recollect,accept_with_rationale},factorsJSONB capturingrisk_tier,issuing_country,days_expired(the AMLA-named factors),rationaleNOT NULL,decided_by, append-only, with anaudit_eventsrow per decision (ADR-0064).recollectroutes through the standard follow-up/portal path — outreach states what is required, never why (AMLD Art. 39, ADR-0071 tipping-off boundary).accept_with_rationaleon a high-risk case falls under the existing maker-checker regime (ADR-0070) as a fail-closed-gate override. - Ongoing expiry monitoring. Add
MonitoringCheckType.document_expiryandMonitoringTriggerType.document_expired(architecture §2.1, byte-exact).ContinuousMonitoringWorkflowgains asweep_document_expiryactivity on the existing schedule (architecture: "same schedule, new activities"): documents within an approaching-expiry horizon per tier (tighter for EDD) emit a trigger routed throughtrigger_router_service(ADR-0083) — defaulttargeted_updatefor expired,record_onlyfor approaching — landing asmonitoring_alertsrows, never a bare list entry. - Verification staleness:
source_ttl.pygets its first caller.document_validity_serviceevaluatesVerificationRecord.collected_atagainstget_ttl(source_category)and emitsMonitoringTriggerType.verification_stalewhen 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. eudi_walletVerificationMethod — model level only. Appendeudi_wallet = "eudi_wallet"toVerificationMethod(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-gatedNotImplementedErrorexactly 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_idgain 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_expirymonitoring 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_expiryFAIL 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_TTLSdefaults (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_walletis 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
- 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.
- Store expiry/issue/issuing-country in the existing
extracted_fieldsJSONB, untyped. Rejected: no schema means no indexable date-range query for thedocument_expirymonitoring sweep (a full-population JSONB scan per run), no typed FK fordocument_expiry_decisionsto 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. - 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.
- 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.