Engine design
How to design an approval engine that survives an audit
Every system eventually grows an action nobody should be able to take alone. Refunding money, releasing results, deleting a customer, messaging forty thousand people at once. The usual answer is a permission, and a permission is the wrong tool.
The short answer
Separate the door from the chair. A permission gets somebody into the screen. A seat in a named stage gives them authority over this specific request. Snapshot the stages onto the request when it is filed, fingerprint the intent, and make the decision log the truth rather than a status column.
The action nobody should take alone
Some actions are ordinary and some are consequential. Editing a description is ordinary. Forgiving a debt, releasing exam results, running payroll, and blasting a message to every parent in a school are not, because none of them can be undone by clicking again.
The instinct is to protect them with a permission: only finance managers may forgive a debt. That is better than nothing and it is still wrong, for a reason worth stating plainly.
A permission answers “can this person do this kind of thing”. An approval answers “is this particular act, with these particular numbers, allowed right now”. Those are different questions and only the second one is auditable.
Permission is the door, the seat is the chair
The separation that makes the whole design work:
- A permission gets somebody into the approvals screen at all. It is capability.
- A seat in a named stage of a specific request gives them authority over that request. It is authority.
Holding the approve permission never means you can approve this. It means you can see the screen where approving happens, and the button is live only if you sit in the current stage.
This matters because permissions get assigned in bulk, by people in a hurry, sometimes by copying an existing role. If capability implies authority, then one careless copy hands a junior clerk the power to sign off on a large payment, and nobody notices until an auditor asks. Decoupling the two means a permissions mistake is a visibility mistake, not a money mistake.
The four guarantees
An approval engine that is worth building owes the system four things. Each one exists because its absence causes a specific, recognisable disaster.
One. The effect and the decision commit together
The approval, the effect it authorises, and any outbound message about it commit in a single transaction keyed by an idempotency key. Kill the process halfway and the whole thing lands or none of it does.
Without this you eventually send a customer a message congratulating them on something that never happened, because the notification fired and the write rolled back. That is the bug that destroys trust fastest, because the customer has proof and you do not.
Two. No blind approvals
The request carries a typed, versioned description of what will happen, not a pointer to a record that can change. The screen renders it into the actual amounts, the actual counts, and a before and after.
An approver clicking yes on a title and a description is not approving, they are guessing. If the interface cannot show what will change, the engine is not finished.
Three. No stale approvals
Fingerprint the intent when it is filed and check the fingerprint again at the moment of applying. If the world moved between filing and approval, stop and say exactly what changed.
Requests sit for days. A refund approved against a balance that has since been paid is not the request anybody agreed to.
Four. The log is the truth
An append only decision log, with status as a projection over it rather than a column that gets updated. Status columns drift, get corrupted by a half finished migration, and cannot answer questions about the past.
If a status column is authoritative, then repairing a bad state means writing a script that guesses. If the log is authoritative, repair means recomputing.
Snapshot the policy at filing
Policy changes. Stages get added, approvers leave, thresholds move. If a request reads live policy at decision time, then editing the policy retroactively changes the meaning of every request in flight.
So copy the stages and the seats onto the request when it is filed. The request carries its own governance. New policy applies to new requests, and nothing already in motion silently changes shape.
This is the same principle as the approver snapshot: the request records who was supposed to decide at the time it was raised, not who holds that job today. Ask an auditor which of those two answers they want and the design question disappears.
Escalation that never approves for you
Deadlines need to be durable, which means they live in the database, not only as a delayed queue entry. Flush the queue and a sweep re-arms every ladder from the stored deadlines.
A reasonable ladder looks like: remind the current stage after a few hours, widen the pool of people who may act after half a day, escalate to a named fallback after a day, and raise a visible breach after two.
What it must never do is auto approve. Silence is not consent, and a system that approves on timeout will approve the most during the busiest week of the year, which is exactly when nobody is checking.
The switchboard
The last piece is the ability to turn an action off in seconds without a deploy, and to choose what off means:
- Block. The action cannot be filed. Users see a real reason, not a generic error.
- Queue. Filing is accepted and held, applied once the block lifts.
- Bypass. The gate is skipped, recorded loudly, and attributed to a named person.
Every one of those is recorded against whoever set it. The value is speed: when a bug is corrupting data, the gap between noticing and stopping it should be seconds, not the length of a release cycle.
What this costs
It is more machinery than a permission check, and honesty about that matters. You are adding a gateway that every consequential action passes through, which is a dependency and a bottleneck for changes.
It is worth it exactly when the actions are irreversible and involve money, people’s records, or mass communication. It is not worth it for a blog editor. Build the switchboard and the log first, because those are the two that are painful to add later, and let the rest follow the actual actions.
Questions people actually ask
- Why not just use a permission for approvals?
- Because permissions are about capability and approvals are about authority over one specific thing. If holding a permission grants approval power, then any mistake in role configuration silently hands somebody the ability to sign off on money. Keep the permission as the door and put the authority in a seat.
- What breaks when policy changes mid flight?
- Everything, unless the stages were copied onto the request when it was filed. Editing a policy should never alter the meaning of work already in progress, and the only reliable way to guarantee that is to snapshot.
- Should an approval engine ever auto approve on timeout?
- No. Escalate, widen the pool, notify harder, and raise a breach, but never let silence count as a yes. Anything else means the busiest week of the year approves itself.
- How do you stop two approvers doing the same work?
- Let a decision be claimed, show who is holding it, and release the claim on a timer so a claim cannot become a lock.