A few weeks ago we shipped a general command tool for a local agent, with a deny-list in front of it and a human approval gate behind it. I wrote at the time that the deny-list is the part that looks like engineering and does not hold, because you cannot enumerate the dangerous set. The gate is the part that holds.
Someone pushed back with a good refinement. The gate only holds if approval is real state with a lifecycle: requested, then approved or rejected, then consumed, then expired. A confirmation step living inside the agent loop cannot survive the process dying while it waits, so it is not a control, it is a prompt.
He is right, and most implementations skip it. What I want to add is that making approval durable does not finish the job. It moves the failure somewhere quieter.
An approval is granted against a world, not a string
When someone clicks approve, they are approving the specific thing they were shown: this file, with this content, going to these two people. What you persist is usually a record that a decision happened, plus an identifier. That is not a record of what was decided.
Between approve and consume, the world keeps moving. The file gets renamed, or a symlink under it now points somewhere else, or the recipient list picked up one more address while the process was down. Your token survives all of it, because it was never bound to any of it. The agent resumes after the crash exactly as designed and executes something the human never saw.
The old name for this
Time-of-check to time-of-use. You verify a condition, then act on it, and something changes in the gap. The textbook examples are filesystem races measured in microseconds.
Human approval stretches that gap to minutes or hours, because a person gets the prompt, goes to lunch, comes back and clicks yes. Durability stretches it further on purpose, since surviving a restart is the entire feature. We took a race condition and made it comfortable enough to design around.
The fix nobody enjoys building
Bind the approval to a fingerprint of what was actually rendered to the human. Not the intent, the literal resolved parameters. Recompute it at consume time, compare, and fail closed on any drift. Then expire aggressively, since expiry is the only real bound on how stale a decision can get.
Fail closed is the part I keep seeing people soften, and I understand why. The drift is almost always innocent, a timestamp or a reordered field, and failing on it feels pedantic. It is pedantic. It is also the one behaviour that makes the gate mean anything on the day the drift is not innocent.
Why this is worse than losing the approval
If approval evaporates when the process dies, you get a bad experience and a safe failure. The agent comes back, finds nothing, and asks again. The human sees the current state and can say no.
If approval survives unbound, you get a good experience and a silent failure. The agent finds a valid-looking token and proceeds against a world that moved. Nobody is asked anything. Nothing looks wrong in the logs, because from the system's side everything worked exactly as specified.
So durability on its own is not a safety property. It is a convenience that only becomes a safety property once the persisted thing is specific enough to check.
What I would do differently
Bind before you persist. Ship the approval carrying a hash of its resolved parameters from day one, even if the storage is a flat file and the lifecycle has two states. I would rather have a crude gate that revalidates than a carefully modeled lifecycle that trusts its own token, and I say that having built the second one first.
The lifecycle framing is right. It is just the easy half.
Written with AI assistance.
Top comments (0)