Skip to main content

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_documents activity (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 existing check_document_expiry.
  • Debuggability: a business document's expiry becomes a queryable row + an audited override + a document_expired monitoring 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 IdentityDocument table 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.

  1. tracked_document_expiry RLS table (one row per non-identity uploaded document): case_id, tenant_id (explicit, RLS WITH CHECK), document_type, requirement_id, minio_key, expires_at, expiry_source (default_rule | officer_set | extracted), set_by, notes, timestamps.
  2. 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).
  3. Populate during validate_documents (where IdentityDocument rows are already created): upsert per non-identity document, expires_at = validated_at + rule[type], expiry_source=default_rule, idempotent on (case_id, requirement_id).
  4. Officer override POST /cases/{workflow_id}/documents/{requirement_id}/expiry (CASE_DECIDE): set expires_at + notes, expiry_source=officer_set, audited.
  5. GET /api/documents/expiring (CASE_READ, tenant-scoped): a unified business + identity expiring-soon view, banded expired/≤30/≤60/≤90 days.
  6. ExpiringDocumentsQueue dashboard widget: band counts + a case-linked list, skeleton loader, honest empty-state.
  7. Monitoring: extend check_document_expiry to also scan tracked_document_expiry, emitting the existing document_expired trigger (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=extracted is 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_documents path; write volume grows with uploaded business documents (small per case).
  • Two expiry surfaces now exist (identity IdentityDocument.expiry_date + business tracked_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_DECIDE permission 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_decisions decision-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.