WritingCompliance
A KYC review service that books the next review the day it verifies
How to build a periodic review service whose due dates are rows written at verification time, so expiry notices, cases and restrictions run on schedule.
A customer passes identity verification on 14 October 2026 with a passport that expires on 20 January 2027. Their account is live, they can deposit and withdraw, and nothing in the system has yet recorded that in 98 days the document behind that decision stops being valid. Most platforms find out with a nightly query over the documents table, a spreadsheet from the compliance team, or a complaint. The periodic review service described here exists so that the date is decided, stored and acted on the moment the verification result is committed.
The constraint that shapes it is that nothing downstream may compute a due date on its own. The verification transaction writes one review_due row per reason (document expiry, risk-tier review, registry refresh) with three dates: notice_at, due_at and restrict_at. A single scheduler reads that table and nothing else, sends the notice, opens the case and restricts the account. If you disagree, it will be because you prefer to query expires_at < now() + interval '30 days' each night; that is what this design replaces, and the failure modes below are the reason.
flowchart TD V[Verification event] --> D[Due calculator]:::accent D --> T[Review due rows] S[Daily scheduler] --> T S --> N[Customer notice] S --> C[Review case] N --> U[New document upload] U --> V C --> R[Reviewer queue] R --> P[Account permissions] S --> P
The result books its own review
customer_id, document_type, document_id, expires_at, risk_tier and verified_at. The due calculator is a synchronous call inside the same database transaction as the verification result, so if it throws, the customer is not marked verified. It applies the tier's policy: for a standard-tier passport it writes notice_at 45 days before expiry, due_at on the expiry date and restrict_at 14 days after, plus a separate periodic_review row twelve months out. Whether any grace after expiry is allowed is a policy decision for the compliance owner; the engine only needs the three dates.One table carries the schedule
review_due row holds customer_id, reason, source_ref (the document id or the tier change id), the three dates, state and case_id. State moves scheduled, noticed, case_open, done, with superseded reachable from any of the first three. A unique constraint on customer_id, reason and source_ref makes a replayed verification event an upsert rather than a second row. Dates on a row are never edited: a changed fact produces a new row and supersedes the old one.The scheduler sends the notice
state = scheduled and notice_at <= today, never = today, so a skipped run is caught by the next one. For each row it publishes an asynchronous review.notice_requested message carrying customer_id, reason, due_at and an upload link, and marks the row noticed only after the broker confirms. If the notification provider is down, the message waits in its queue; the row is already noticed, and the provider's retry handles delivery. The scheduler itself never calls the provider.A new document restarts the loop
document_expiry row for the new passport (expiring, say, 4 January 2037) and marks the old row superseded in the same transaction. If the upload is rejected, no new row is written and the old one keeps its dates; the rejection reason is attached to the case the old row will open.The case opens with the evidence
due_at, rows still in noticed get a review case: the reason, the original document id, the notice timestamps and restrict_at. The reviewer queue is ordered by restrict_at, so the case with the least time left is at the top, not the one opened first. A reviewer can close the case approved (a replacement arrived through another route), request a document (which re-sends the notice and moves nothing else) or close it rejected. Each closure writes the row to done and carries the reviewer id.Restriction is a column, not a lookup
restrict_at, if the case is still open, the scheduler writes account_permissions.state = restricted with restricted_reason = review_overdue and the case id. The payout API reads that column and returns a specific error code the customer-facing app maps to "we need a new identity document". It never queries review_due on the transaction path. The only way back to active is the case closing approved, which writes the column with the same case id; support staff cannot edit the column directly.Trade-offs
I have built a centralised KYB, KYC and AML dashboard, and the choice I would defend hardest is the first row of this table: the schedule is data written once, not a query run nightly.
| Choice | What it buys | What it costs |
|---|---|---|
| Due rows written at verification time, not a nightly query over documents | One table to monitor; the date is fixed before anyone can argue with it; replay of a verification event is harmless | A supersede path for every fact that can change a date: new document, tier change, policy change |
| Three dates per row rather than one | A notice window and a grace window the customer can see; restriction on a date, never on a reviewer's mood | Three per-tier policy values to configure and test, and a migration when the policy changes |
| Restriction as a permission column written by the scheduler | The payout path reads one column and stays fast; the restriction has a case id to explain it | Up to one scheduler interval of lag between restrict_at and the restriction |
| Supersede rather than delete or edit | Every date the system ever intended is still there for an auditor | Every query over the table filters out superseded rows, and someone will forget once |
| A daily batch rather than a delayed message per row | Replayable, inspectable, one place to look when something is late | Granularity of a day; a message-per-row design would restrict at the exact minute |
Failure modes
- The scheduler run is skipped by a deploy at its fixed hour. You observe rows with
notice_atyesterday stillscheduled. The<= todaypredicate picks them up on the next run, and an alert on the oldest unprocessednotice_atfires once it passes 36 hours. - The extractor reads the expiry wrongly. A passport expiring in 2027 is stored as 2021, or 2072. The calculator rejects any
expires_atalready in the past and any further out than the document type's maximum validity, and fails the verification transaction rather than writing a row that would never fire. The reviewer confirms the printed date on any document that trips the bound. - The customer uploads, but re-verification rejects the document. No new row is written, the old row keeps its dates and the rejection reason is carried into the case. The customer sees the rejection and a new upload link; nothing restricts earlier than planned.
- The customer never reads the notice and
restrict_atarrives. The account moves torestrictedwith the case id. A payout attempt returns the specific error, the customer contacts support, support opens the case from the error's case id and sees the whole timeline. Reinstatement happens by closing the case, so the audit trail has one shape for every restriction. - The risk tier rises mid-cycle. A tier change is itself a verification-style event. The calculator writes a new
periodic_reviewrow with the shorter cadence and supersedes the old one. If the newdue_atis already in the past, the next run opens the case immediately rather than waiting. - Two verification events arrive for the same document. The unique constraint on
customer_id,reasonandsource_refturns the second insert into a no-op upsert, so one document never books two cases and two notices.
Build the review_due table and the calculator first, and wire the calculator into the verification transaction before any scheduler exists. A platform that writes the dates but does nothing with them yet is already better than one that queries for them nightly, because the dates are now facts a person can read, and the scheduler, the notices and the restriction can be added one at a time against rows that are already there.