Skip to content

Approval stores

Available · from agent_adapter import SQLiteApprovalStore, RedisApprovalStore; from agent_adapter.approval import SQLAlchemyApprovalStore, DynamoDBApprovalStore

A DeferredApproval parks a call in an ApprovalStore until a reviewer decides. When the call pauses in one process and resumes in another (Celery, several HTTP workers, a job re-run later), every process must see the same records. Five stores ship; all honour the same protocol, checked by one shared test suite.

flowchart LR
  W["web worker<br/>call pauses"] -- create --> S
  R["reviewer<br/>list + decide"] --> S
  K["other worker / host<br/>resumes the call"] -- find + consume --> S
  S{{"ApprovalStore"}} --> M[("MemoryApprovalStore<br/>this process")]
  S --> Q[("SQLiteApprovalStore<br/>one file, one host")]
  S --> D[("RedisApprovalStore<br/>every host")]
  S --> P[("SQLAlchemy / DynamoDB<br/>your database")]

Which store to use

Situation Store
tests, scripts, one process MemoryApprovalStore(*, max_records=10_000)
several processes on one host (multi-worker server, local Celery, a cron job) SQLiteApprovalStore(path, *, max_records=10_000, timeout=5.0)
several hosts, containers or pods RedisApprovalStore(client_factory, *, prefix="aa:approval:", ttl_s=604800) (extra redis)
Postgres (or another host's SQLite) through SQLAlchemy SQLAlchemyApprovalStore(engine, *, table="aa_approvals") (extra sqlalchemy), see Database stores
AWS serverless (Lambda), no Redis DynamoDBApprovalStore(client_factory, *, table, ttl_s=604800) (extra dynamodb), see Database stores
another database (a Django model) implement ApprovalStore; follow the SQL recipe

Every store: create is atomic per (run_id, call_id) and returns the call's open record unchanged; consume is a test-and-set that is True exactly once; find returns only the unconsumed record; get, decide and consume raise ApprovalNotFound for an unknown id. The methods are on Approval.

Memory: one process

MemoryApprovalStore holds one threading.Lock and never awaits under it, so loops in several threads (run_sync) can share one store; 100 concurrent decide calls produce one winner. Past max_records it evicts the earliest consumed record, then the oldest decided one, then (with a WARNING) the oldest pending one; each eviction is O(1).

SQLite: one file, every process on the host

def executor_for(path: str) -> tuple[Executor, SQLiteApprovalStore]:
    """Every process on this host opens the same file; WAL lets them share it safely."""
    store = SQLiteApprovalStore(path, max_records=100_000, timeout=5.0)
    return Executor(registry, approval=DeferredApproval(store)), store

The web process pauses the call and a reviewer decides it:

executor, store = executor_for(path)
first = await executor.run_call(CALL, executor.context(run_id=RUN_ID))
print("web:", first.status)  # pending: nothing ran

[waiting] = await store.list(run_id=RUN_ID, status="pending")
edit = {"order_id": "o-7", "amount": 450}
await store.decide(waiting.approval_id, Decision(approved=True, edited_args=edit))

A second process opens the same file and resumes with the same run and call id:

executor, _ = executor_for(path)  # a new process: only the file is shared
resumed = await executor.run_call(CALL, executor.context(run_id=RUN_ID))
replay = await executor.run_call(CALL, executor.context(run_id=RUN_ID))
print("worker:", resumed.status, resumed.value, "| replay:", replay.status)
Rule Detail
File WAL journal mode; tables aa_approvals and aa_approval_meta (schema version 1)
First use creates the schema; another version, a foreign aa_approvals table, or a file that cannot use WAL → ConfigError
Atomic steps create, decide, consume run in BEGIN IMMEDIATE; a partial unique index keeps one unconsumed row per call
Threads and loops each call runs in asyncio.to_thread on its own connection; safe across run_sync loops and forked processes
timeout seconds a writer waits for the file lock, then sqlite3.OperationalError
Eviction past max_records: the earliest consumed record, else the oldest decided, else the oldest pending, as MemoryApprovalStore; evicting a pending or a decided-but-unused record logs a WARNING (that call asks again)
File mode a new file is created 0600; SQLite gives -wal and -shm the same mode; an existing file keeps its mode. Windows ignores the mode: the file takes its folder's ACL
Not for network file systems (WAL needs shared memory on one host); use Redis or your database

path must be a file path: "", ":memory:" and file: URIs are refused, because each call opens its own connection.

Redis: every host

def shared_store(url: str = "redis://localhost:6379/0") -> RedisApprovalStore:
    """Needs the redis extra; one client per event loop; records expire after a week."""
    from redis.asyncio import Redis

    return RedisApprovalStore(
        lambda: Redis.from_url(url), prefix="{myapp}:approval:", ttl_s=7 * 24 * 3600
    )
Rule Detail
Client client_factory() builds a redis.asyncio.Redis, once per event loop (LoopLocal); keep decode_responses=False; await store.aclose() closes the running loop's client
Keys under prefix: rec:<id> (a hash), open:<run>:<call>, sorted sets all, run:<run_id>, status:<status>, exp, and the counter seq
Atomic steps create is one Lua script; decide and consume compare-and-set the stored record in a script, so a lost race re-reads and never double-consumes
TTL a record expires ttl_s seconds after it was created (a decision does not extend it); then get raises ApprovalNotFound, find returns None, list skips it, and the next create prunes its ids from the indexes
maxmemory-policy noeviction, for every ttl_s: under volatile-* or allkeys-* Redis may drop records (pending or decided) early and silently, and the call asks again
ttl_s=None records never expire
prefix non-empty and ending in : (else ConfigError); one prefix per store, never nested in another ("app:" and "app:rec:" share keys)
Cluster put a hash tag in prefix ("{myapp}:approval:"): the scripts build record keys from it, so every key must share one slot
Outages Redis errors propagate (logged at ERROR with the type only): the HTTP server answers 500, nothing is approved

Pick ttl_s longer than your slowest review plus the resume: an expired decision cannot be consumed, so the resumed call opens a new review (it fails closed, never runs).

Your own database

SQLAlchemyApprovalStore is the SQL recipe, shipped: a partial unique index on the call's unconsumed row, INSERT … ON CONFLICT DO NOTHING for create, and a guarded UPDATE … RETURNING for decide and consume. Copy it for a Django model; see Database stores.

Access and permissions

The store is where approvals live. Anyone who can write it can forge an approval (a consistent approved row passes every check), and anyone who can read it sees the tool arguments. Treat it like a credential store.

Store Do
SQLite put the file in a directory only the service user can open (chmod 0700); keep the file, -wal and -shm at 0600 (new files are); on Windows, give the folder an ACL for the service account only
Redis a Redis user (ACL) per service, limited to its prefix (~{myapp}:approval:*) and to the commands the store and its scripts run (+eval +hmget +get +set +exists +hset +incr +pexpire +time +zadd +zrem +zrangebyscore +zrevrange); TLS (rediss://) between hosts; no other writer under the prefix
Every durable store never share the store with a lower-trust component; back it up with the same protections

Failing closed

Every durable store checks every row it reads. A row that is not JSON, is not an ApprovalRecord, has a digest that disagrees with its request, or whose indexed columns (id, run, call, status, consumed) disagree with the record raises ApprovalStoreCorrupt. It subclasses AdapterError (kind internal-error), is logged at ERROR without row contents, and is raised before anything is consumed.

Error When
ApprovalStoreCorrupt a corrupt, tampered or foreign row; a Redis record that keeps changing under a swap
ToolUsageError a taken approval id on another call; metadata that JSON cannot carry; edited_args that are not plain JSON
ConfigError bad constructor arguments; an unknown status or negative limit in list; SQLite schema problems
sqlite3.Error, redis.RedisError, sqlalchemy.exc.SQLAlchemyError, botocore ClientError the backend failed; logged, then raised unchanged

Stored records are ASCII JSON. Metadata is stored as plain JSON (a tuple comes back as a list); arguments and edited_args must already be plain JSON, so what runs is exactly what was approved. MemoryApprovalStore runs the same edited_args check, so code that works on it never breaks on SQLite or Redis.

Files

File Does
approval/store.py ApprovalRecord, the ApprovalStore protocol, MemoryApprovalStore
approval/persist.py shared by the durable stores: record codec, row checks, ApprovalStoreCorrupt
approval/sqlite_store.py SQLiteApprovalStore: schema, transactions, eviction
approval/redis_store.py RedisApprovalStore: key layout, retries of lost swaps, paging for list
approval/redis_scripts.py the four Lua scripts (CREATE, FIND, SWAP, PAGE)
approval/sqlalchemy_store.py, approval/dynamodb_store.py the database stores

Data enters through create and decide (from DeferredApproval, the HTTP server's /approvals/{id}/decide, or your reviewer UI) and leaves through get, find, list and consume.