> ## Documentation Index
> Fetch the complete documentation index at: https://docs.neobank.proofof.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Persist the verification status

> KYC status is persisted per customer and provider binding with observation timestamps, and an absent or unrecognized provider answer never demotes it.

# 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.
