# Human Approval for Agents: Store and Check the Exact Decision

> Run a SQLite approval lifecycle that rejects changed, stale, expired and revoked proposals, then returns a completed local receipt on retry.

- Source: https://www.zarifautomates.com/blog/how-to-build-ai-agent-human-in-loop-approval
- Published: 2026-09-17
- Updated: 2026-09-19
- Pillar: Agents & AI Engineering
- Tags: agent-course, ai-engineering, practical-guide
- Author: Zarif

---

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](/blog/how-to-build-ai-agent-that-plans-and-executes-tasks) explains the proposal that precedes this decision. Download and extract the [approval example](/downloads/agent-course/approval-lifecycle-v1.zip), then run:

```bash
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:

```json
{
  "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:

```text
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](https://docs.langchain.com/oss/python/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:

```sql
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](https://docs.python.org/3/library/sqlite3.html#transaction-control) 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:

```python
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](/blog/evaluate-ai-agent-performance-reliability) to turn accepted, rejected and failed cases into a dataset with explicit scoring rules. Return to the [course index](/blog/pillar/agents-and-ai-engineering#agent-course-heading) for the surrounding lessons.

## Related Guides

- [Build a Ticket Workflow: Signed Delivery, Evidence and Human Approval](/blog/ticket-agent-workflow-capstone)
- [Use Cursor to Build a Ticket Approval Interface You Can Test](/blog/how-to-use-cursor-ai-to-build-web-applications)
- [Agent Design Patterns: Run Four Control Flows and Their Failures](/blog/ai-agent-design-patterns-production-systems)
- [Reactive vs Proactive AI Agents: Architecture Comparison](/blog/reactive-vs-proactive-ai-agents-architecture-comparison)

## Continue the course

Lesson 9 of 17.

Previous lesson: [Plan and Execute: Build a Bounded Ticket Workflow](https://www.zarifautomates.com/blog/how-to-build-ai-agent-that-plans-and-executes-tasks.md).

Next lesson: [Evaluate a Ticket Agent: Dataset, Scoring Code and Failure Report](https://www.zarifautomates.com/blog/evaluate-ai-agent-performance-reliability.md).

[Browse available lessons](https://www.zarifautomates.com/blog/pillar/agents-and-ai-engineering#agent-course-heading).
