Exactly-once approvals: node-level replay is not an audit trail
Human-in-the-loop is having its moment. Every agent framework now ships some form of "pause for approval": the agent proposes an action, a human says yes, the agent continues. And for demos, they all work.
The differences appear when you ask the question an auditor asks: does the record of what happened correspond, exactly, to what executed? For an approval system, that is not a nice-to-have: correspondence is the entire point. An approval workflow whose record can drift from its execution is security theater with extra steps.
This post is about one specific, underexamined way that drift happens: resume-time replay granularity. It is a design decision most teams don't know they are making until it bites.
The pause is easy; the resume is the design decision
Any durable approval flow has the same skeleton: the run reaches a gated action, persists its state, and stops. Later, after minutes or days, a decision arrives, and the run resumes. The whole question is: resumes from where?
The pattern most frameworks chose is checkpoint-at-node-granularity. The run's state is snapshotted between nodes (or steps, or supersteps) of a graph; an interrupt inside a node pauses the run; and on resume, execution restarts from the beginning of the interrupted node. LangGraph (the most mature implementation of this pattern, and to be clear, a system whose persistence design is genuinely good) documents this behavior plainly: when you resume after an interrupt(), code inside the node that ran before the interrupt runs again. Their docs tell you to structure nodes accordingly: keep side effects after the interrupt, or make them idempotent.
That is a reasonable engineering tradeoff, fully documented. Node-level checkpointing is cheaper and simpler than journaling every operation, and for many workloads it is exactly right.
But look at what it means for an approval workflow specifically:
def process_refund(state):
log_to_case_system(state.claim) # side effect BEFORE the gate
reserve_funds(state.amount) # side effect BEFORE the gate
decision = interrupt({"approve": state.amount}) # pause here
if decision:
execute_refund(state.amount) # the gated action
On resume, log_to_case_system and reserve_funds run a second time. The framework did nothing wrong; it told you it would do this. But now your case system shows two entries, your funds are double-reserved, and (this is the part that matters) your audit trail's relationship to reality is "usually one-to-one, unless a pause happened mid-node." The exception swallows the guarantee. An auditor cannot sample their way to confidence in a record that is exact except when it isn't; "the trail reflects what ran, modulo replay artifacts" is not a sentence you want to say in a SOC 2 interview, and it is definitely not one you want to say to a financial regulator.
You can discipline your way around it: keep every node pure before its interrupt, push all effects downstream, review every graph change for violations. But a guarantee maintained by team discipline across every node in every graph forever is a policy, not a property. Properties are what audits want.
Step-exact: the log is the execution path
The alternative is to journal at the granularity of the step. Every model decision, every tool execution, every approval request and every human decision is an event appended to a durable log, and the run is driven from the log rather than logged about:
seq type payload
5 model_decision {tool_calls: [refund(order=..., amount=840)], reasoning: ...}
6 approval_requested {call_id: c3, tool: refund, arguments: ..., reasoning: ...}
7 approval_granted {call_id: c3} <- appended days later, any process
8 tool_result {call_id: c3, content: {ok: true, psp_ref: ...}}
The loop only ever does one of two things with a step: if the log already holds its result, replay the fact, meaning do not re-ask the model and do not re-run the tool; if it doesn't, execute it and append before continuing. Resume is not "restart the node and hope its first half is idempotent"; resume is "fold the log and continue from the first step without a recorded result." There is no code that ran before the pause that can run again, because "before the pause" is, by construction, a sequence of recorded facts.
Under this model, the audit-trail property stops being a discipline and becomes an identity: the trail cannot drift from the execution because the trail is the execution. A step that isn't in the log didn't happen; a step that is in the log happened once.
The crash window, and what keys are for
Exactly-once purists will object, correctly, that no journal closes every gap. There is always a window between "the action was performed" and "the fact was appended." Crash inside that window, resume, and the loop sees no recorded result, so it executes again.
Two things close it, and both belong in the design rather than the fine print:
- Idempotency keys, derived from position. Every step's key is
f(run_id, seq): stable across crashes, retries, and resumes. The tool passes it downstream, and the downstream system (a payment provider, an IdP, your own ledger) deduplicates. The crash window still exists; it just stops being able to move money twice. Retries reuse the same key, so a timeout-then-retry is also covered by the same mechanism. - Say what's at-least-once out loud. In drangue (the runtime I built this way), model calls fall in that category: a recorded decision is never re-asked, but a crash in the window before it is recorded re-calls the model on resume. That costs tokens, not correctness (a decision is not a side effect), and it is documented rather than discovered. Every system has residual at-least-once corners; the difference between a trustworthy system and a demo is whether they are chosen, bounded, and written down.
What to demand from any approval implementation
If an agent in your organization pauses for human approval, these are the questions that separate the pattern from the property, whatever framework you use:
- Resume granularity. When a paused run resumes, exactly what re-executes? If the answer is "the interrupted node from its start," what enforces that pre-gate code is effect-free: a reviewer, or the runtime?
- Trail provenance. Is the audit record written beside execution (a tracer, a logger, which can drift) or is it the thing execution is driven from (which cannot)?
- The gated action's execution count. After an approve, then a crash, then a resume, how many times did the action run? What is the idempotency story for the window your journal cannot see?
- Decision durability. Is the human's approval a first-class recorded event with the case they saw (arguments + the agent's reasoning), or a callback that lives in process memory?
- The rejection path. Is "no" recorded with the same fidelity as "yes"? Rejections are the most valuable evidence your eval set will ever get.
None of these questions is exotic. They are what your payments team already asks of any system that moves money, applied to the system that now proposes to. The frameworks' HITL demos all pause. The question that decides whether compliance signs off is what, precisely, happens when they continue.