ADR-0098: Uploaded business-document expiry tracking
Date: 2026-07-06 Status: Accepted Deciders: Adrian (Soft4U), Claude Opus 4.8
Decision context:
- Latency: one idempotent upsert per non-identity uploaded document during the already-I/O-heavy
validate_documentsactivity (off any user request), one indexed date-range query for the dashboard widget, one extra scan in the existing monitoring sweep. Not measured — batch/activity path, not request-serving. - Dependency surface: no new packages. One new RLS table (
tracked_document_expiry), one default-rules constant module, one officer endpoint, one read endpoint, one dashboard widget, and one added branch in the existingcheck_document_expiry. - Debuggability: a business document's expiry becomes a queryable row + an audited override + a
document_expiredmonitoring alert, instead of an invisible MinIO object. The forward-looking widget makes "what's expiring" explicit rather than discovered when it's already stale. - Reversibility: additive — new table (nullable/defaulted), new endpoints, one monitoring branch. Reverting stops the writers; the widget degrades to empty. ~5-file change to undo.
- Blast radius: additive on
validate_documents, the decision surface (a new override endpoint, no change to existing gates), and the monitoring sweep. Identity-document handling (ADR-0087) is untouched. - Alternative considered: reuse the
IdentityDocumenttable for business docs — rejected (its columns and the ADR-0087 decision-gate semantics are identity-specific; overloading it would conflate identity verification with generic document freshness).
Context
ADR-0087 (W5) built an expiry lifecycle for identity documents: IdentityDocument.expiry_date,
a document_expiry_decisions gate at approval, and a document_expired monitoring alert.
Uploaded business documents — annual financial statements, UBO register extracts, proof of
address, certificates of incorporation, bank statements — have no persisted record at all:
the portal tracks them by listing MinIO objects under a case prefix (portal.py upload path),
so nothing carries an expires_at. A UBO extract goes stale at ~6 months, financials at ~12,
proof of address at ~3 — but the system has no forward-looking view and no lifecycle. This is
issue #13, and the KBC "perpetual KYC" gap for document freshness beyond identity cards.
Decision
Add a business-document expiry lifecycle that mirrors — but does not overload — the ADR-0087 identity path.
tracked_document_expiryRLS table (one row per non-identity uploaded document):case_id,tenant_id(explicit, RLSWITH CHECK),document_type,requirement_id,minio_key,expires_at,expiry_source(default_rule|officer_set|extracted),set_by,notes, timestamps.- Default rules
DEFAULT_EXPIRY_MONTHS_BY_TYPE(financials 12, UBO 6, proof-of-address 3, bank statement 3, incorporation cert 12, shareholder register 12; fallback 12). - Populate during
validate_documents(whereIdentityDocumentrows are already created): upsert per non-identity document,expires_at = validated_at + rule[type],expiry_source=default_rule, idempotent on (case_id, requirement_id). - Officer override
POST /cases/{workflow_id}/documents/{requirement_id}/expiry(CASE_DECIDE): setexpires_at+notes,expiry_source=officer_set, audited. GET /api/documents/expiring(CASE_READ, tenant-scoped): a unified business + identity expiring-soon view, bandedexpired/≤30/≤60/≤90days.ExpiringDocumentsQueuedashboard widget: band counts + a case-linked list, skeleton loader, honest empty-state.- Monitoring: extend
check_document_expiryto also scantracked_document_expiry, emitting the existingdocument_expiredtrigger (ADR-0083 routing) for expired business docs.
Consequences
Positive
- Business-document freshness gets a lifecycle, alerts, and a forward-looking officer view — closing the non-identity half of document-expiry (issue #13 / KBC perpetual-KYC).
- The expiring-soon widget lets officers act before expiry, not after a stale-doc finding.
- Identity-document handling (ADR-0087) is entirely unchanged; the two paths stay distinct.
Negative
- Default-rule expiry is an approximation — a financial statement's real staleness depends on
its fiscal-year end, not upload date;
expiry_source=extractedis reserved but unimplemented, so v1 can flag a document "expiring" on a rule that's coarser than reality. - One more RLS table + writer on the
validate_documentspath; write volume grows with uploaded business documents (small per case). - Two expiry surfaces now exist (identity
IdentityDocument.expiry_date+ businesstracked_document_expiry); the widget and monitoring must union both, a small ongoing coupling.
Neutral
- No OCR extraction of business-document dates in v1 (rules only).
- The officer override reuses the
CASE_DECIDEpermission rather than a new doc-review permission.
Alternatives Considered
Alternative 1: reuse IdentityDocument for business documents
- Store business docs in the same table with a nullable
expiry_date. - Why rejected: its schema and the ADR-0087
document_expiry_decisionsdecision-gate semantics are identity-specific (issuing country, MRZ, the expired-ID approval gate). Overloading it conflates identity verification with generic document freshness and would entangle the identity approval gate with business-doc staleness.
Alternative 2: do nothing (identity-only expiry)
- Leave business documents untracked.
- Why rejected: it's the explicit #13 gap — a UBO extract or financials silently ageing out with no alert and no officer view is exactly the perpetual-KYC freshness hole AMLR Art. 26 targets.