Human Approval for Agents: Store and Check the Exact Decision
An approval is useful only if the person can see what will happen and the executor applies that same proposal. “Continue?” is too vague when the target, arguments or record version may change while the run is paused.
This lesson builds a stored approval for one synthetic ticket label change. You will run ten scenarios, inspect the state transitions and see why a completed retry has different behavior from an expired action that never ran.
Run the approval lifecycle
You need Python 3.10 or newer with sqlite3, included in standard Python installations. The planning lesson explains the proposal that precedes this decision. Download and extract the approval example, then run:
python3 approval_lesson.py > observed.json
Use python if that is your system's Python command. Run without -O: the example uses assertions to check its results. It creates temporary databases, runs ten scenarios and prints their tickets, approval states, receipts and event histories. It makes no network or model calls.
The reviewer name, permissions and clock are trusted fixture inputs. There is no login screen or identity provider. In an application, derive the reviewer from an authenticated session and load permissions and server time from trusted systems. Passing an arbitrary name from a browser form would not establish approval authority.
Make the proposed change visible
The proposal contains 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 observed version and the evidence supporting the change. The fixture starts with unassigned, so the concrete change is unassigned → billing. If there are several target records, show that full scope before the decision.
request() validates the proposal and stores its canonical JSON plus a SHA-256 fingerprint. The approval record A1 begins with this shape:
| 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 the boundary easy to test. They are an injected clock, not dates. The digest is a fingerprint, not a credential. The executor also compares the actual stored proposal bytes. Someone able to rewrite the database could change both, so database access remains a separate security boundary.
Record a decision before executing
decide() checks that the fixture reviewer has permission for the owner and that the requested state transition is valid. From pending, the reviewer may approve or reject. An approved action may be revoked before execution. Expiry at the time of a decision produces expired.
The main paths are:
pending -> approved -> completed
| |-----> revoked
| |-----> expired
| |-----> stale (target record changed)
|-----> rejected
|-----> expired
These terminal states do not reopen. After rejection, expiry or a stale target, prepare and review a new request with its own identifier. An invalid attempt to change approved arguments leaves the original approval intact. It does not approve the replacement arguments.
A framework pause solves a different part of the problem. LangGraph interrupts save graph state and wait for input. Their documentation also explains that the interrupted node restarts when resumed. This SQLite example does not use LangGraph. Whichever pause mechanism you choose, recheck the actual action at execution and account for code that can run again.
Apply the change and receipt in one transaction
apply() uses BEGIN IMMEDIATE to begin a local write transaction. It reads the stored approval, checks the exact proposal, then looks for a prior completion receipt. For a new execution, it requires active approval, checks the reviewer's current fixture permission and rejects expiry when now is equal to or greater than the stored limit.
The ticket update is conditional on its observed version:
UPDATE tickets
SET label = ?, version = version + 1
WHERE owner = ? AND id = ? AND version = ?;
If no row matches, the approval becomes stale, with no ticket change or completion receipt. If it matches, the transaction stores the changed ticket, the receipt and the completion event together. Any injected failure between the update and receipt rolls the transaction back. The Python SQLite documentation explains explicit transaction control. This example uses isolation_level=None and issues its own BEGIN, commit and rollback operations.
The successful receipt records ticket T100, label billing, version 2. On a retry of the same approval and exact proposal, the executor returns that stored result with replayed: true. It does not update the ticket again. A replay after the original expiry is permitted because it reports an already completed action. It does not authorize a new action after expiry.
Read the ten observed scenarios
The download includes the executed output in expected-results.json. In every refused case the label stays unassigned and the receipt count stays zero.
| 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 |
The stale scenario deliberately changes the ticket version to 2 before execution. That change is the fixture's simulated concurrent edit, not the rejected approval updating the record.
The reopen checks use new SQLite connections in the same Python process. They demonstrate committed persistence and rollback, not process-kill recovery. This transaction also cannot make a remote CRM update atomic with SQLite. A remote adapter needs the provider's idempotency or operation-status mechanism and a reconciliation path for uncertain results.
Inspect one record yourself
Run this from the extracted folder to see a successful decision and 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. On a fresh in-memory database, try making the first application at now=200. It should return state: "expired" and applied: False.
Then try approving an already approved request. decide() should raise ValueError: invalid_transition. Repeating an approval decision is not the same operation as reading a completion receipt. Your application's interface needs to show those outcomes clearly.
Continue with agent evaluation to turn accepted, rejected and failed cases into a dataset with explicit scoring rules. Return to the course index for the surrounding lessons.
