Skip to main content

ADR 0017: Persist the verification status the provider will not timestamp

Context

GET /v1/kyc was a pass-through: the adapter read /kyc/status, stamped updated_at with datetime.now(UTC) at read time, and returned it. The timestamp changed on every poll, so it described the wrapper’s clock, not the customer’s verification. The pinned fiat contract declares no status property at all, so an absent field is contract-legal — and the adapter manufactured not_started for it, the same value it used for every unrecognized provider string. A dashboard reading that answer offered “Continue” to a customer who may have been under review or rejected. Perflo publishes no provider-side status timestamp (PFR-003), so no read can recover when a status actually changed.

Decision

Persist the verification status. A kyc_statuses row is unique per (customer_id, provider_binding_id) — the same scope as beneficiaries and cards, and the correct one: verification belongs to the Perflo subject, and the binding id rotates when a customer connects a different Perflo account, so one subject’s approval can never be pinned onto another’s. The write lives in the service, not the adapter. GET /v1/kyc delegates to NeobankService.kyc_status, which requires the capability, performs the provider read, re-takes the customer row lock — a credential refresh can commit mid-call and drop the lock the provider context took — selects the row for update, applies the transition rule, and recovers from a concurrent first insert by re-reading the winner’s row. A provider error propagates before any write, so a failed read never touches the row. The transition rule is not a total order. Any transition into a settled state always lands — approved to rejected, approved to expired — a bank cannot keep showing approved after the provider revoked it. The two values that mean “the provider gave no usable answer”, not_started and unknown, never overwrite a known status; on such a refused observation only the observation time advances, and the raw string is logged rather than stored, or the row would publish a raw status contradicting its own normalized one. Everything else lands, including under_review to action_required and approved to in_progress — a genuine re-verification cycle. This deliberately does not defend against a blip reporting in_progress for an approved customer: that is a word the provider chose, and suppressing it would hide a real re-verification. The adapters stop fabricating answers. An absent or empty status field returns unknown with a null raw status, and the normalizer’s fall-through becomes unknown with an explicit not_started arm. unknown is a new published status value; unrecognized would describe our parser rather than the customer’s situation, and indeterminate already names an operation state in an unrelated state machine. KycView.updated_at splits into status_changed_at — when this wrapper last observed the normalized status change — and observed_at — when it last read the provider. Both are observation times; the field descriptions say so. requirements is dropped: both adapters hard-coded an empty list, and a permanently empty array reads as “no outstanding requirements”, a claim the wrapper cannot make. The ask for real requirement codes stays in PFR-003.

Consequences

The published timestamps become defensible: status_changed_at moves only on an accepted transition, observed_at on every successful read, and a first observation records both as one moment. A provider blip cannot demote a recorded approval, and the refused observation is logged with the previous status, the observed status, and the raw string. Connecting a different Perflo account starts a fresh row under the new binding instead of inheriting the old subject’s status. KycStatusValue gains unknown, consumers of updated_at and requirements must migrate, and a test pins the published value set to the normalizer’s output set. If Perflo later proves to flap between working states, the defense is tightening the untrusted-demotion set, not reshaping the rule.

Rejected alternatives

Keeping updated_at and changing its meaning: the type does not change, so no consumer is forced to notice that the semantics did. A total-order monotonicity guard: it would swallow approved to rejected, the one direction that must never be blocked. Writing the row from the adapter: the adapter holds no database session in any protocol method and is constructed from settings alone; only the service knows the provider binding and can apply the transition inside the transaction.