- Step 9 of 17 in the agent-building course
- Step 6 of 10 in the Agent Engineer path
Human Approval for Agents: Store and Check the Exact Decision

An agent wants to change a support ticket's label from unassigned to billing. It pauses and asks a person, "Continue?" The person clicks yes. By the time the change runs, someone else has edited the ticket, or the agent has swapped in a different label.
That yes was for something else.
An approval only means something if the person sees exactly what will happen, and the code that makes the change applies that same exact thing. This lesson builds a stored approval for one made-up ticket label change. You'll run ten scenarios, watch the approval move between states, and see why retrying a change that already finished is different from running one whose approval expired.
Run the approval lifecycle
You need Python 3.10 or newer with sqlite3, which comes with standard Python. The planning lesson shows how the agent produces the proposal you'll approve here. Download and extract the approval example, then run:
python3 approval_lesson.py > observed.json
If your system's Python command is python, use that. Don't add -O. The example uses assertions to check its results, and -O switches them off.
The script creates temporary databases, runs ten scenarios and prints the tickets, approval states, receipts and event history for each. It makes no network or model calls.
The reviewer's name, their permissions and the clock are fixed test inputs that the script trusts. There's no login screen. In a real app, get the reviewer from a logged-in session, and load permissions and the current time from systems you trust. If a browser form can send any name it likes, that name proves nothing about who approved.
Make the proposed change visible
The proposal has exactly four fields:
{
"owner": "demo-a",
"ticket_id": "T100",
"expected_version": 1,
"label": "billing"
}
A review screen should show the ticket, its current label, the proposed label, the version the agent saw, and the evidence for the change. The example ticket starts as unassigned, so the actual change is unassigned → billing. If the change touches several records, show all of them before anyone decides.
request() checks the proposal and stores it as canonical JSON, meaning one fixed way of writing it, so the same proposal always produces the same bytes. It also stores a SHA-256 fingerprint of those bytes. The approval record A1 starts like this:
| Field | Stored value |
|---|---|
| Requester | fixture-requester |
| Approver | Empty until a decision |
| Created time | 100 |
| Expiry | 200 |
| State | pending |
| Proposal | The exact JSON above |
| Digest | SHA-256 of the canonical proposal bytes |
The small time values make expiry easy to test. They come from a fake clock the script controls, not real dates.
The digest is a fingerprint, not a password. When the change runs, the code also compares the actual stored proposal bytes. Someone who can rewrite the database could change both, so protecting the database is a separate job.
Record a decision before executing
decide() checks that the reviewer has permission for this ticket's owner, and that the requested move from one state to another is allowed. From pending, the reviewer can approve or reject. An approved action can be revoked before it runs. If the approval has already expired when someone decides, it becomes expired.
The main paths are:
pending -> approved -> completed
| |-----> revoked
| |-----> expired
| |-----> stale (target record changed)
|-----> rejected
|-----> expired
The end states don't reopen. After a rejection, an expiry or a stale target (the ticket changed after the agent looked at it), make a new request with its own ID and review it fresh.
If someone tries to change the arguments of an approved request, the original approval stays as it was. The new arguments don't inherit the approval.
Pausing a workflow is a different part of the problem. LangGraph interrupts save the workflow's state and wait for input. Their docs also explain that when you resume, the paused step starts over from its beginning. This SQLite example doesn't use LangGraph. Whatever you use to pause, check the actual action again right before it runs, and plan for code that may run twice.
Apply the change and receipt in one transaction
A transaction is a group of database writes that either all happen or none do.
apply() starts a write transaction with BEGIN IMMEDIATE. It reads the stored approval, checks that the proposal matches exactly, then looks for a receipt from an earlier finished run. If there isn't one, this is a new run. So it requires an active approval, checks that the reviewer still has permission, and refuses if now is equal to or later than the expiry time.
The ticket update only works if the ticket's version is still the one the agent saw:
UPDATE tickets
SET label = ?, version = version + 1
WHERE owner = ? AND id = ? AND version = ?;
If no row matches, someone changed the ticket in the meantime. The approval becomes stale, and nothing else changes: no ticket update, no receipt.
If the row matches, the transaction saves the changed ticket, the receipt and the completion event together. If anything fails between the update and the receipt, the transaction rolls back and none of it is saved. The Python SQLite documentation explains how to control transactions yourself. This example sets isolation_level=None and issues its own BEGIN, commit and rollback.
The successful receipt records ticket T100, label billing, version 2.
Now say the same approval and the exact same proposal arrive again, maybe because the caller retried after a dropped connection. The code returns the saved receipt with replayed: true and doesn't touch the ticket again. This works even after the expiry time, because it only reports something that already finished. It doesn't let anything new happen after expiry.
Read the ten observed scenarios
The download includes the real output in expected-results.json. In every refused case, the label stays unassigned and there are zero receipts.
| Scenario | Result |
|---|---|
| No decision | Refused: approval is pending |
| Label changed after approval | Refused: proposal differs |
| Ticket version changed | Stored stale; no label change |
| Execute exactly at expiry | Stored expired; no label change |
| Reviewer rejects | Refused: approval is rejected |
| Reviewer revokes | Refused: approval is revoked |
| Unknown reviewer | Decision refused; request stays pending |
| Reviewer's permission removed | Execution refused despite earlier approval |
| Failure after ticket update | Rollback, reopen connection, then one successful retry |
| Success followed by retry | Reopen connection and return the same receipt; version stays 2 |
In the stale scenario, the script changes the ticket version to 2 on purpose before the change runs. That stands in for someone else editing the ticket at the same moment. It isn't the refused approval updating the record.
The "reopen" checks open a fresh SQLite connection in the same Python process. They show that committed data stays saved and rolled-back data doesn't. They don't show recovery after the process is killed.
This transaction also can't cover a change in a remote system like a CRM. SQLite can't roll back someone else's server. For that, you need the provider's own way to recognize a request it already handled (called idempotency) or to look up whether an operation finished. You also need a way to sort out results you aren't sure about.
Inspect one record yourself
Run this from the extracted folder to see a successful decision and a replay:
from approval_lesson import connect, initialize, request, decide, apply, PROPOSAL
db = connect(":memory:")
initialize(db)
request(db, "demo", PROPOSAL, now=100, expires_at=200)
decide(db, "demo", actor="reviewer-a", choice="approved", now=110)
print(apply(db, "demo", PROPOSAL, now=120))
print(apply(db, "demo", PROPOSAL, now=300))
db.close()
The first response has replayed: False. The second has replayed: True, with the same version and label. Then, on a fresh in-memory database, try making the first apply() call at now=200. It should return state: "expired" and applied: False.
Next, try approving a request that's already approved. decide() should raise ValueError: invalid_transition. Approving twice is a different thing from reading the receipt for finished work, and your app's screens need to make that difference clear.
Up next, agent evaluation turns accepted, rejected and failed cases into a dataset with clear scoring rules. The course index has the surrounding lessons.



