Skip to content

Solution

How to build an approval workflow an auditor cannot break

An auditor asks who approved a payment in March, under which policy, on what evidence. If the answer requires reconstructing it from a status column and a changelog, the answer does not exist.

The short answer

Copy the stages and the named approvers onto the request when it is filed, so later policy edits cannot rewrite work in flight. Store an append only decision log and derive status from it. Fingerprint what was agreed and recheck it before applying. Escalate on timeout, never approve.

A permission granting entry to the screen, a request that has snapshotted its own stages and eligible approvers at filing time, and a decision that requires both holding the permission and sitting in the current stage, with a later policy edit leaving the filed request unaffected
The request carries its own governance, so editing the policy later cannot rewrite what was agreed in March. Tap to open full size.

The four questions an auditor asks

Everything in this design exists to answer one of these without reconstruction.

  1. Who approved this, and were they entitled to at the time?
  2. What exactly did they approve? Not the title, the amounts.
  3. What was the policy on that date?
  4. Can you prove the record has not been altered since?

A workflow built as a status column and a role check can answer none of them a year later, and the gap only becomes visible at the exact moment it is expensive.

Step 1. Snapshot the policy onto the request

When a request is filed, copy the entire governance into it: the stages, how many approvals each stage needs, and the specific people eligible at that moment.

request 8841
  stage 1  needs 1 of [user 12, user 47]        filed with these names
  stage 2  needs 2 of [user 3, user 9, user 21]
  policy_version 14

The request now carries its own rules. Later edits to the policy apply to new requests only, and nothing in flight silently changes shape.

Without this, a policy edited on Tuesday rewrites the meaning of everything filed on Monday, and the answer to question three becomes “whatever the policy says today”, which is not an answer.

Snapshot the people, not the role. Somebody who has since changed jobs was still the right approver in March, and a role lookup at read time will say they were not.

Verify: file a request, change the policy, and confirm the request still shows the original stages and names.

Step 2. Make the decision log the truth

An append only table of events. Nothing in it is ever updated or deleted.

request_events
  id, request_id, seq, type, actor_id, at, payload

FILED       by 88
CLAIMED     by 12
APPROVED    stage 1, by 12, comment
ESCALATED   stage 2, by system, reason: sla_breach
APPROVED    stage 2, by 3
APPLIED     effect_reference: ledger entry 99213

Status is then a projection over that log, not a column somebody writes. If a status is ever wrong, it can be recomputed. If a status column is wrong, it can only be guessed at, which is how you end up with a repair script that has to decide what probably happened.

Keep a materialised status column for query performance if you need one, but treat it as a cache and be able to rebuild it from the log at any time. The test is simple: can you drop and recompute the column and get identical results.

Step 3. Fingerprint what was actually agreed

A request is a description of an intended effect. Between filing and approval, the world moves.

Somebody requests a refund of a balance. Three days pass. The balance has changed. Approving now applies an effect nobody agreed to.

So compute a fingerprint of the material facts at filing, and recheck it at the moment of applying.

at filing:   fingerprint = hash(amount, target_id, balance_at_filing, line_items)
at applying: recompute, compare
             different -> stop, show precisely what changed, require re-approval

The failure without this is quiet and specific: the approval is valid, the log is honest, and the effect is wrong. This is the guarantee that turns an approval into a decision about a known thing.

Verify: file a request, alter the underlying data, then approve. It must refuse and say what changed.

Step 4. Commit the effect and the decision together

The approval, the effect it authorises, and any outbound message all commit in a single transaction keyed by an idempotency key.

BEGIN
  INSERT APPROVED event
  apply the effect
  INSERT APPLIED event with the effect reference
  INSERT outbox row for the notification
COMMIT

Never send the notification inside the transaction. If the send succeeds and the transaction rolls back, somebody has been told about something that did not happen, and they have the message to prove it. Write the intent, commit, deliver separately.

Idempotency matters because approve is a button, and buttons get double clicked on a slow connection.

Step 5. Escalate on time, never approve on time

Deadlines live in the database, not only in a delayed queue entry. If the queue is flushed, a sweep re-arms every ladder from the stored deadline.

A reasonable ladder:

+4h    remind the current stage
+12h   widen the eligible pool within the stage
+24h   escalate to the named fallback
+48h   raise a visible breach
never  auto approve

The reason auto approval is never acceptable is worth stating plainly, because it gets proposed regularly as a pragmatic convenience: the system would approve the most during the period when everybody is busiest, which is exactly the period when the approvals most need a human.

Step 6. Let a decision be claimed

Without this, two approvers open the same request, both act, and either you have a double effect or one of them gets an error after committing to a decision.

Claiming is a soft lock: visible to others, released automatically after a timeout so it cannot become a permanent block, and recorded in the log because who picked something up is itself useful.

Step 7. Make refusal explain itself

When somebody cannot act, say which reason applies. Not “not authorised”.

You filed this request.
This is at stage 2 and you sit in stage 1.
Your authority limit is below this amount.
This action is currently suspended by an administrator.

Four different causes, four different next actions. A generic denial produces a support ticket every time, and the information needed to answer it is already in the system.

What this costs

Snapshots make requests larger and mean policy improvements do not apply retroactively, which is usually what you want and occasionally frustrating. The event log grows and needs an archival plan. Fingerprinting means legitimate small changes require re-approval, which people will complain about until the first time it catches something real.

All of it is worth paying the moment the actions are irreversible and involve money or somebody’s record. None of it is worth paying for a workflow that publishes a blog post.

What good looks like

Any request can be replayed from its log. The policy in force on any past date is recoverable from the request itself. Nothing has ever been applied against data that changed after it was agreed. And when an auditor asks the four questions, the answer is a query rather than a project.

Questions people actually ask

Why snapshot instead of reading the policy live?
Because policy changes. If a request reads live policy at decision time, then editing the policy retroactively changes the meaning of every request in flight, and an auditor asking what the rule was in March gets today's answer.
Why is a status column not enough?
Because a status column holds the current state and nothing about how it was reached. It can also be corrupted by a half finished migration or a cron job, and once it is wrong there is nothing to recompute it from.
Should approvals ever expire into approval?
Never. A system that approves on timeout approves the most during the busiest week of the year, which is exactly when nobody is checking. Escalate, widen, notify harder, raise a breach. Never yes.

Published June 2026, updated July 2026.

Hiring someone to own work like this?

I am open to senior backend and full stack roles, remote and permanent.

Get in touch / Read the CV