WritingCompliance
A four-eyes approval service that applies a frozen proposal
How to build a maker-checker approval service where the checker approves a frozen, hashed proposal and a job applies it only if the record has not moved.
At 09:04 an onboarding analyst decides that customer C-48213, parked at a risk score of 71, should be approved, and that the account's daily limit should rise from 500000 to 2500000 minor units of EUR. Policy says a second person must agree before either change lands. The service in this post is the maker-checker layer (four eyes) between that analyst and the customer record: it takes the analyst's intent as a proposal, puts the proposal in front of a checker, and writes the record only when the checker has agreed to exactly that proposal.
The constraint that shapes the design is that the checker must approve the thing that will land, not a description of it. So an approval is a frozen proposal that a job applies, not a second person pressing the same button the first one pressed. The proposal carries the full after-state, a hash of those bytes and the version of the record the maker was looking at; the checker echoes the hash back; the applier writes the record only if its version has not moved. Each guard exists because a named failure gets through without it.
flowchart TD M[Maker screen] --> P[Proposal service]:::accent P --> S[Proposal store] S --> N[Notifier] N --> C[Checker screen] C --> P P --> A[Applier] A --> T[Customer record] A --> L[Audit log]
The maker proposes and the service freezes it
customer.approve_and_set_limit, target C-48213, target_version 17, the full after-state (status: approved, daily_limit: 2500000, currency: EUR) and a reason. The service canonicalises the after-state (sorted keys, no whitespace), hashes the bytes with SHA-256 and inserts a pending row with the maker's id and an expires_at 72 hours out. It answers 201 with the id and the hash, or 409 when target_version is behind the record or an open proposal already exists for the target (a partial unique index on target_id over pending and approved rows).The store tells the checkers asynchronously
proposal.pending to a role queue named compliance_checker, never to a named person. The notification is a convenience: the checker screen lists pending proposals straight from the store, so a notifier that is down for an hour costs delay and nothing else. An alert fires on any pending row older than 24 hours.The checker reads the stored proposal
The decision is verified in one transaction
pending, the clock is before expires_at, the hash matches, the checker's id differs from the maker's, and the record is still at version 17. Any miss returns 409 with a reason code (not_pending, expired, hash_mismatch, same_user, target_moved) and changes nothing; a pass writes approved, appends an audit event and inserts an outbox row for the applier.The applier writes the record once or not at all
proposal.approved and runs one conditional update: set status, daily limit, version 18 and last_proposal_id where the id is C-48213 and the version is still 17. One row means the proposal becomes applied and the before and after values go into an audit event in the same transaction. Zero rows means either the record already carries this proposal's id (a redelivery after a crash, so stop) or the record moved after approval, so the proposal becomes stale and is never retried. The message is acknowledged only after the commit.The log ties three events to one id
C-48213 sees who proposed which bytes, who approved those same bytes and what landed, without trusting any screen. A proposal that went stale or expired leaves its event too, so the absence of a change is as explainable as a change.Trade-offs
I have built a workflow builder for compliance review teams, and the rule I hold to is that an approval is a transition the server performs with its own checks, never a flag a screen sets. Each choice below follows from that rule.
| Choice | What it buys | What it costs |
|---|---|---|
| Store the full after-state, not a command such as "raise the limit by 2000000" | The checker approves the exact values that will land, and the diff exists before anyone decides | The proposal goes stale whenever the record moves, and the maker proposes again against the new version |
| Make the checker echo the hash | A checker who read one body cannot approve a different one, whatever their tab cached | Every client must carry the hash back, and a stale tab gets a 409 it has to explain |
| Apply from a job, not inside the approve request | Approve returns in milliseconds, and apply can be redelivered safely after a crash | A window in which the proposal is approved and the record unchanged, which every screen must show as such |
| Allow one open proposal per target | Two checkers can never approve conflicting changes to one customer | A second maker is blocked until the first proposal is decided or expires |
| Route to a role queue, not a named checker | Cover when a checker is on leave, with no reassignment step | The same-user check must live on the server, because anyone with the role can open the proposal |
Failure modes
The failure that justifies the applier's version check is worth walking through, because the approve call already checks the version and it still gets through. At 09:04 the maker proposes against version 17. At 09:31 the checker approves: pending, hash matches, users differ, record still at 17, commit. The applier is mid-deploy and its queue holds the message for four minutes. At 09:33 the ongoing sanctions screen, a different process, returns a possible match and writes status: restricted with a case id, moving the record to version 18. At 09:35 the applier runs the update where the version is 17 and gets zero rows; last_proposal_id is not this proposal, so the proposal becomes stale with stale_version 18 and the maker is notified with the diff. The customer stays restricted, and the sanctions case must close before a new proposal against version 18 can be made.
// Simplified: the applier's single transaction.
async function apply(p: Proposal, tx: Tx): Promise<void> {
const res = await tx.execute(
`UPDATE customers
SET status = $1, daily_limit = $2,
version = version + 1, last_proposal_id = $3
WHERE id = $4 AND version = $5`,
[p.after.status, p.after.dailyLimit, p.id, p.targetId, p.targetVersion],
);
if (res.rowCount === 1) {
await tx.execute(
`UPDATE proposals SET state = 'applied', applied_at = now() WHERE id = $1`,
[p.id],
);
await audit.append(tx, 'customer.updated', { proposalId: p.id, hash: p.hash });
return;
}
const row = await tx.one(
`SELECT last_proposal_id, version FROM customers WHERE id = $1`,
[p.targetId],
);
if (row.last_proposal_id === p.id) return; // redelivered after a crash
await tx.execute(
`UPDATE proposals SET state = 'stale', stale_version = $2 WHERE id = $1`,
[p.id, row.version],
);
}The other failures each have one place that contains them:
- The maker approves their own proposal from a second browser. You observe an approve request whose session user equals
maker_idand a 409same_userin the log. The comparison runs inside the approve transaction and again in the applier; a hidden button is not the control. - The proposal body is changed after the checker read it. You observe 409
hash_mismatch. Proposal rows are immutable (a trigger rejects any update toafter_state), so a changed body is a new proposal with a new id, and the echoed hash pins what the checker actually read. - The applier crashes between the record update and the proposal state write. You observe a redelivered
proposal.approvedmessage for a record that already carries the proposal's id. Both writes share one transaction, so a half-applied state never commits; on redelivery the job sees its own id on the record and stops. - A checker approves a proposal that is 80 hours old. You observe 409
expired. An hourly sweeper moves old rows toexpired, but the approve call comparesexpires_atwith the clock itself, so a late sweeper changes nothing. - Two makers propose against the same customer within a minute. You observe 409
open_proposal_existson the second POST. The partial unique index is the lock; the second maker waits for a decision or an expiry.
Build the proposal table and the conditional update first, with the five server-side checks on approve and the stale path on apply, and test them against a record that moves between every step. The checker screen can start as a list and a diff, and the notifier can wait until the first week has shown how long proposals sit.