Recording rejected approvals in LangGraph without breaking the audit trail
Write every rejection as its own append-only record in a store outside graph state and checkpoints. Key it to the thread and run identifiers and to a digest of the exact proposed tool call, and persist it before the graph routes on the decision. Then enforce the approval requirement at the tool call as well, so a rejected call cannot run even if graph logic is wrong.
- 1
Identity and linkage
A unique record ID, the thread ID, the run ID and the checkpoint ID at the point of interruption.
- 2
Proposed action
Tool name, redacted arguments and a digest of the exact call as proposed.
- 3
Approver
A verified identity from your authentication system, not a free-text name supplied in the resume payload.
- 4
Decision and rationale
A constrained decision value (approved, rejected, edited) and the approver's stated reason.
- 5
Timestamp
Server-side time of the decision, set by the writer rather than the client.
- 6
Integrity and follow-up
A hash of the previous record to expose gaps or edits, plus a later linked record describing what the agent did after the rejection.
- 7
1. Surface the proposed call
When the graph interrupts, present the tool name, arguments and digest, and assign an approval request ID that stays fixed for this proposal.
- 8
2. Authenticate the response
Resolve the approver identity outside the graph. The resume value carries the decision and rationale, not the proof of who sent it.
- 9
3. Append the record
As the first action after resume, insert the record using the approval request ID as an idempotency key so a re-executed node does not create duplicates or overwrite.
- 10
4. Update state and route
Only then write the outcome into graph state and select the branch. State may hold a reference to the record ID, not the only copy of the decision.
- 11
5. Return a result to the agent
Give the model an explicit rejection result for that call so it can respond, and append a follow-up record of what it did next.
Tests that verify the design
- Reject a call, resume, and confirm exactly one record exists with all fields populated and the tool never executed.
- Resume the same interrupt twice, and replay from an earlier checkpoint, and confirm no duplicate or overwritten records.
- Prune or delete checkpoints for a thread and confirm the rejection records remain and still resolve to the thread and run IDs.
- Attempt an update or delete on a record using the application's credentials and confirm it is refused and the attempt is itself recorded.
- Edit arguments after approval and confirm the changed call is not executed under the earlier approval.
- Inspect stored records for secrets, and confirm sensitive argument values are redacted or hashed.
Why graph state is the wrong home for a rejection
LangGraph human-in-the-loop approval usually combines an interrupt, a checkpointer and a resume command. The graph pauses with a proposed tool call, a reviewer responds, and the graph continues. The weak point is what happens to the reviewer's response afterwards. In many implementations it becomes a field in graph state or a routing condition, and the graph then moves to an alternate branch, overwrites the field, or ends the run.
Each of these loses evidence. A branch that handles rejection often drops the original tool call from state because it is no longer needed. A later node that reuses the same state key replaces the decision. A terminated run may leave nothing that says who rejected what, or why. Checkpoint history does not fix this. It exists to support resume, replay and forking, and checkpoints can be replayed, forked or pruned. A decision that exists only inside that history is mutable application data, not reliable evidence.
A useful principle comes from OWASP guidance on prompt injection: record the structured policy decision (allow, block or human review) separately from whether the task completed. A rejection is a decision with its own record. What the graph does next is a separate fact, and the two should be stored separately and linked.
Patterns that erase or obscure rejections
Edited approvals deserve particular care. If a reviewer changes arguments and the graph executes the edited call, the original proposal should not vanish. Record the original as rejected or superseded, then treat the edited call as a new proposal with its own digest and decision. Approval must apply to the exact action. A different call must never run under an earlier approval.
Other patterns to remove: deleting the pending tool call from message history on rejection, reusing a single approval field that each new interrupt overwrites, ending the run without recording the decision, and relying on checkpoint retention as the audit store. Forking from an earlier checkpoint is a legitimate debugging step, but a fork should create new records under its own run identifier. It should never rewrite or hide the records of the original path.
Finally, human approval is one layer of defence, not a substitute for others. Approval fatigue is real, and sudden shifts in approval rates or in the distribution of refusal reasons can precede a bypass. Report on those distributions from the record store.
Enforcing approval at the tool call
Everything above lives in application code. If a graph node is misconfigured, a branch skips the check, or a new tool is added without an interrupt, the record design does not stop execution. A prompt asking for human review does not itself stop a tool from running, and the component that executes the tool should verify approval for the exact action. That argues for a second layer at the tool call, outside the agent's graph code.
Trussed provides this layer. It enforces policy in the execution path on individual tool calls, with outcomes that include allow, redact, block, rate limit and escalate for human approval. Enforcement happens at runtime, not through review after the fact, and the audit-ready evidence is generated by that enforcement rather than reconstructed from application logs. The application-level record described here still matters, because it captures the graph's own context. The two are complementary, and neither replaces input validation or least-privilege tool scopes.
When evaluating any tool-call enforcement point, ask how its decision records can be matched to LangGraph thread and run identifiers so a rejection stays attributable to the correct execution, and whether the evidence store is self-managed with configurable retention in your own cloud.
Rejection as an auditable event
Proposed call
Tool name, arguments and a digest of the exact call.
Decision record
Approver, decision, rationale and timestamp, appended once.
Durable store
Separate from graph state and checkpoint history.
Tool-call enforcement
A rejected call is stopped at execution, outside graph code.
Add enforcement beneath your graph logic
See how Trussed escalates tool calls for human approval in the execution path and produces audit-ready evidence for regulated enterprises.
See How Evidence Is Produced