Most products start with a kyc_verified boolean. It is true or false, it gates the features that need it, and it is wrong within a month of talking to a compliance team.
What the boolean cannot say
Real verification has intermediate states that carry real permissions. A user who has submitted documents but is awaiting review may be allowed to hold a balance and not withdraw. A user whose address proof was rejected needs to be told which document failed and why. A user verified two years ago may need re-verification under a rule that did not exist when they signed up.
A boolean flattens all of that into a permission decision made somewhere else, usually in a controller, usually differently in each place.
Model the states explicitly
kyc_status
unstarted
documents_pending -- waiting on the user
under_review -- waiting on us or a provider
additional_info_needed
verified
rejected
expired
Each transition is a row, not an update:
kyc_transitions
user_id
from_status
to_status
reason_code
actor -- system | provider | reviewer id
evidence_ref -- what was looked at
occurred_at
Append-only, for the same reason the ledger is. When a regulator asks why this account was allowed to transact on a given date, “the flag was true” is not an answer. The transition log is.
Permissions derive from state
One function maps status to capabilities, and every caller goes through it. Withdraw requires verified. Holding a balance is allowed under review. Deposits are allowed for anything but rejected.
Scatter these checks across controllers and the day the rules change you will find four places that agree and one that does not.
Expiry is a scheduled transition, not a cron hack
A verified record carries a valid_until. A job moves rows to expired when it passes, writing a transition like everything else. The user’s capabilities change because their state changed — not because a different query started returning different results.
The cost
An enum, a transitions table, and one permissions function. Perhaps two days. It is the difference between a compliance review that reads your log and a compliance review that reads your code.