What happens when nobody clicks approve
The confirm dialog is the easy part. The design problem is a run that has been waiting eleven days on a person who has left the company.
Approval gates get designed for the happy path and the rejection path. Approve, and it proceeds. Reject, and it stops.
The path that actually needs designing is the third one, and it is the most common of the three in any real deployment: nobody does anything. The item sits in the queue for eleven days. The person it was assigned to is on leave, or has changed teams, or has 340 unread items and yours is not urgent.
An approval queue with no expiry policy is not a safety mechanism. It is a place where work goes to become quietly stale.
Pending is not a resting state
The tempting model is that a run waits indefinitely and that this is safe, because nothing irreversible happens while it waits.
That is true, and it is not the risk. The risk is that the world moves while the run does not, and the draft was built from a snapshot of a world that has since changed.
Concretely, from an outbound system: a drafted email cites a specific problem found on a prospect's website — no online booking, say. Eleven days later somebody approves it. In the meantime the prospect added online booking. The email now opens by confidently describing a problem that visibly does not exist, to a reader who can see their own website.
That email is worse than not sending. It was correct when it was written, and the approval queue turned it into a liability without anything failing.
Give every pending item an expiry
The fix is to make staleness a first-class state with a deadline attached at draft time:
type Pending = {
runId: string
step: number
action: Action
draftedAt: Date
/** Set when drafted, from the action kind. Not a global constant. */
expiresAt: Date
/** What the world looked like when this was drafted. */
evidenceHash: string
}
// The window is a property of how fast the underlying evidence decays.
const TTL: Record<Action["kind"], Duration> = {
send_cold_email: days(7), // site facts drift; a week is generous
publish_post: days(3), // scheduled content has its own moment
refund_customer: days(14), // the fact does not decay, the goodwill does
delete_records: days(30), // deliberately long; nobody should rush this
}
Two things to note.
The TTL is per action kind, not global. A refund request does not go stale the way a scraped web fact does. One constant for everything means either expiring careful decisions too fast or letting perishable ones rot.
evidenceHash is recorded at draft time. This is what lets you distinguish
old from wrong, which are different problems with different answers.
Expiry should re-draft, not cancel
When something expires, cancelling is the obvious move and usually the wrong one. The underlying intent is still valid — you still want to contact that prospect. Only the artefact has decayed.
So on expiry, re-run the evidence gathering and compare:
async function onExpire(p: Pending) {
const fresh = await gatherEvidence(p.action.target)
if (hash(fresh) === p.evidenceHash) {
// The world did not move. The draft is still accurate, just old.
// Extend rather than discard, and stop pretending this needs a
// human's attention more urgently than it does.
return extend(p, TTL[p.action.kind])
}
// The evidence changed. The draft is now wrong, not merely stale.
await discard(p, "evidence_changed")
return redraft(p.action.target, fresh)
}
The first branch is the one that matters operationally. In a system where most evidence is stable, most expiries are extensions and the queue does not churn. The second branch is rare, and is exactly the case that would have embarrassed you.
Escalation, and then a decision
An expiry policy still assumes someone eventually looks. Two more mechanisms close that gap.
Reassign on absence. If an approver has not acted on anything for some period, they are not going to act on this one. Route to a fallback. This is one query — last approval timestamp per approver — and it prevents the most common stall, which is a single person's holiday.
Bound the queue, not just the item. If the pending count crosses a threshold, stop generating new work. A queue growing faster than it drains is telling you the approval step is under-resourced, and adding to it makes that worse. Pausing generation surfaces the problem to someone who can fix it. Continuing to generate hides it until the queue is unreviewable and somebody bulk-approves 300 items to clear it — and bulk approval is how a human gate stops being a human gate.
What to record
Every transition, with a person and a time on it. Approvals are the part of the system somebody will eventually ask questions about:
create table approval_events (
id bigserial primary key,
run_id text not null,
step int not null,
event text not null, -- drafted | approved | rejected | expired
-- | extended | redrafted | reassigned
actor text, -- user id, or null when the system acted
reason text, -- required for rejected and expired
evidence_hash text,
at timestamptz not null default now()
);
actor being nullable and reason being required on the automatic transitions
is the useful shape. When somebody asks in six months why a particular email was
never sent, the answer should be a row, not an inference from a log file.
That is the same argument as everywhere else on this site: the system should be able to explain itself. An approval gate that cannot say who approved what, when, and against which evidence is a checkbox rather than a control.
More in the approval gate is a state machine, the agent sent the email twice, and agentic workflows with human approval gates.