Skip to main content

AgentGov

The runtime spend governor and denial-of-wallet circuit breaker for autonomous agent fleets.

CI coverage floor dependencies mypy license python

When an agent spawns sub-agents that spawn tools, nothing in the stack enforces a dollar ceiling on the tree. A per-account rate limit watches one caller. It does not see a fan-out of 50 sub-agents each making a handful of individually reasonable calls that, together, burn a budget in seconds. AgentGov sits below the payment layer as that missing control plane: a hierarchical, capability-scoped spend envelope over an immutable, hash-chained ledger, with a circuit breaker that halts a runaway branch before settlement, not after. A second, cognitive breaker attacks the root cause: it detects the agent looping without making progress and halts it for a fraction of a cent, long before the envelope is touched. The ledger, topology, and any in-flight authorization survive a process crash, backed by SQLite via the standard library.

Zero runtime dependencies. Pure standard library (decimal, hashlib, threading, sqlite3, asyncio-compatible). mypy --strict clean.


The benchmark

examples/denial_of_wallet_benchmark.py runs the identical runaway-agent workload three times and diffs the outcome: with no backstop, with a $5.00 spend envelope, and with the envelope plus cognitive loop detection. Same orchestrator logic, same simulated model, same 3 seconds of wall clock.

The model here is a deterministic offline stub, which is what makes a million-call comparison reproducible. For the same machinery measured against the real Anthropic API, see Live API metering below.

+=======================+==========================+==========================================+======================================================+
| METRIC                | Scenario A - Ungoverned  |    Scenario B - AgentGov (Financial)     |    Scenario C - AgentGov (Cognitive + Financial)     |
+=======================+==========================+==========================================+======================================================+
| Wall clock            |                    3.00s |                                    3.00s |                                                3.00s |
| Sub-agents spawned    |                       63 |                                   13,661 |                                                    1 |
| Calls attempted       |                  627,228 |                                   14,295 |                                              140,075 |
| Calls executed        |                  627,228 |                                      635 |                                                    3 |
| Calls refused         |                        0 |                                   13,660 |                                              140,072 |
| Tokens consumed       |              204,685,404 |                                  207,251 |                                                  770 |
+-----------------------+--------------------------+------------------------------------------+------------------------------------------------------+
| Intended budget       |                $5.000000 |                                $5.000000 |                                            $5.000000 |
| Actual cost realized  |            $4,922.694420 |                                $4.984635 |                                            $0.018330 |
| Cost vs budget        |                98,453.9% |                                    99.7% |                                                 0.4% |
| Budget breached after |                   0.003s |                                    never |                                                never |
+-----------------------+--------------------------+------------------------------------------+------------------------------------------------------+
| Cognitive breaker     |        n/a - not enabled |                        n/a - not enabled | TRIPPED [deterministic/near_duplicate] after 4 calls |
| Financial breaker     | n/a - no backstop exists | LATCHED OPEN - 13660/13662 scopes halted |                     LATCHED OPEN - 1/2 scopes halted |
| verify_integrity()    |   n/a - no ledger exists |             PASS - 70208 entries chained |                            PASS - 13 entries chained |
| verify_conservation() |   n/a - no ledger exists |     PASS - no money created or destroyed |                 PASS - no money created or destroyed |
+=======================+==========================+==========================================+======================================================+

All three run the identical workload: an agent that cannot find a report and keeps re-asking with cosmetic edits. The governed scenarios differ by exactly one constructor argument, so the third column isolates what loop detection buys and nothing else.

A, ungoverned: 627K calls in 3 seconds, $4,922.69 against a $5.00 intended budget, breached in 3 milliseconds and never stopped. B, financial: capped at $4.984635 of the $5.00 envelope, 13,660 further attempts refused. Correct, but the money is gone. The envelope bounds the damage rather than preventing it. C, cognitive + financial: halted after 3 executed calls and $0.018330, 0.4% of the envelope and 272x cheaper than waiting for the budget to run out. The remaining 140,072 attempts cost nothing at all: the check runs ahead of the authorization hold, so a refused call never reaches the ledger. The halt itself is recorded once, as a zero-value entry in the chain (the 13th); nothing after it is written.

Scenario C's cost is bit-for-bit reproducible across runs. It is bounded by the policy, which does not care how many iterations the clock allowed. A and B are not, and the table above is one recorded run rather than a fixed expectation: A's total is however many calls fit in three seconds, and B settles somewhere just under the envelope depending on where the last holds land. For A and B the bound is the guarantee, not the figure.

Reproduce it: uv run python examples/denial_of_wallet_benchmark.py. Add --audit to stream every hash-chained ledger line live instead of a tail sample.

Every number above is checked, not narrated. verify_integrity() re-derives the SHA-256 chain, the pairing of every hold with its release, and the delegation tree and breaker state, all from the chain itself; verify_conservation() checks the identity

Σ(scope balances) + outstanding_holds + settled_spend − reversals == funded

across every scope in the tree. Money is never created, destroyed, or double-counted. it only ever moves between a parent and a child, or out to a vendor.


Live API metering

The benchmark above runs against a deterministic stub, which is the right harness for proving accounting invariants and the wrong one for proving anything about a real provider. scripts/generate_real_usage.py closes that gap: four governed workloads across four Anthropic model tiers, through the official anthropic SDK, with every raw response payload written to disk as evidence.

+-----------+---------------------------+-------+--------+---------+-------------+-----------+
| TIER      | MODEL SERVED              | CALLS | IN TOK | OUT TOK | COST        | HALTED BY |
+-----------+---------------------------+-------+--------+---------+-------------+-----------+
| runaway   | claude-haiku-4-5-20251001 | 3     | 54     | 384     | $0.00197400 | cognitive |
| workhorse | claude-sonnet-5           | 3     | 112    | 426     | $0.00448400 | -         |
| analyst   | claude-opus-5             | 1     | 124    | 900     | $0.02312000 | -         |
| heavy     | claude-fable-5-1          | 1     | 155    | 407     | $0.02190000 | -         |
+-----------+---------------------------+-------+--------+---------+-------------+-----------+
| TOTAL     |                           | 8     | 445    | 2,117   | $0.05147800 |           |
+-----------+---------------------------+-------+--------+---------+-------------+-----------+

  ledger settled total      $0.05147800
  independent re-price      $0.05147800
  drift                     $0E-8  (MATCH)
  verify_integrity()        PASS - 33 entries chained

Recorded with v0.1.1. From v0.1.2 the runaway tier's halt is itself a zero-value entry in the chain, so the same run chains one entry more.

Pricing is exact, not approximately right. Every settled call is re-priced a second time straight from the published rates and compared against what the ledger captured. Across 445 input and 2,117 output tokens on four different price tiers the drift is $0.00000000. The metering path reads a real usage object and turns it into the same number twice, independently. anthropic 1.6.0; claude-fable-5-1 served natively.

The cognitive breaker fires on live traffic. Tier 1 drives a thrashing loop: four cosmetically-edited restatements of one request. It halted after 3 executed calls and $0.001974, then refused 5 retry attempts with no balance movement at all. Tier 2, a genuinely progressing three-step workflow, ran to completion untouched: the detector discriminates, rather than simply halting whatever runs longest.

This run found two real bugs. Against the live API the breaker initially did not fire, despite the loop being obvious. Two defects, both invisible to a stub-backed test suite:

  1. The result comparison was measuring the SDK envelope, not the answer. Rendering an anthropic.types.Message through repr meant most of the compared characters were Message(id=…, TextBlock(citations=None, type='text'…, usage=Usage(…): boilerplate every response from that SDK shares. Two completely unrelated answers scored 0.42 while two genuinely progressing ones scored 0.48: no usable signal. extract_result_text() now pulls the prose out first, which roughly doubled the separation between thrashing and progress (1.20x → 1.81x).
  2. result_similarity_threshold was calibrated on templated stub output. Real prose that means the same thing shares far fewer character trigrams than two renderings of one template. The inherited 0.70 detected 0 of 4 thrashing trajectories against live traffic.

scripts/calibrate_result_threshold.py is the reproducible re-calibration: 36 measured pairs across thrashing, pagination, and genuinely progressing traffic. Its finding is more interesting than a new constant. the per-pair distributions genuinely overlap (thrashing 0.32–0.66, pagination 0.13–0.88), so no single-pair threshold separates them. What separates them is the streak requirement: pagination's similarity is erratic, thrashing's stays persistently elevated. Sweeping the real rule, 0.25–0.30 catches 4/4 thrashing trajectories with zero false positives on either control family; 0.20 begins false-positiving on pagination. The shipped default is now 0.30, the conservative end of that band.

Reproduce it. This spends real money, so rehearse first:

export BENCHMARK_API_KEY=sk-ant-...        # never falls back to ANTHROPIC_API_KEY
uv run python scripts/generate_real_usage.py --dry-run   # free rehearsal, no network
uv run python scripts/generate_real_usage.py             # ~$0.05
uv run python scripts/calibrate_result_threshold.py      # ~$0.03

The full write-up is ARCHITECTURE.md, Part A: envelope dominance, prose entropy, and why the per-pair distributions cannot be separated by any constant.

Every call carries an explicit max_tokens, the whole run executes inside a $1.50 AgentGov envelope, and the runaway loop is bounded by a hardcoded counter that breaks at four iterations, so a regression in the breaker still cannot run away. Raw payloads, the cost summary, the ledger, and the metering journal are written to timestamped files under benchmarks/live_data/ (gitignored: they are evidence, not repository content).


Why naive rate limiters fail here, and what replaces each failure

Naive approach Where it fails AgentGov's answer
Decrement-then-check a counter Two concurrent sub-agents can both read "funds available" before either writes. A classic TOCTOU race that lets both spend the same dollar Authorize → Hold → Settle. A call reserves its worst-case cost before it runs; the hold is encumbered and invisible to concurrent siblings until it settles. Two agents racing for the last cent cannot both win, proven under 64 threads and 100 asyncio tasks contending for one envelope simultaneously.
Retry until it works An agent (or a human debugging one) that ignores a rejection and keeps calling will eventually get through once a competing hold releases A latching circuit breaker. Once a scope overdraws, its breaker trips and stays tripped. Every subsequent call fails fast with CircuitOpenError, independent of balance, until an operator explicitly calls reset(). Retry storms cannot wear it down.
A dashboard counter or log line If someone edits the number after the fact, there is no way to tell A cryptographic double-entry ledger. Every economic event is an immutable entry chained to its predecessor by SHA-256 (prev_hash → entry_hash); verify_chain() re-derives every hash and catches any retroactive edit, including a tampered balance.
A dedupe cache on tool calls Catches byte-identical repeats and nothing else. An agent nudging one word per attempt defeats it on every call while making no progress at all A cognitive breaker. Character-shingle similarity, call-graph cycle detection, and an off-thread novelty tracker catch the soft loop, cosmetically different calls that go nowhere, and halt it for cents rather than dollars.
A single flat quota per API key Says nothing about which sub-agent in a fan-out is responsible, and can't bound a tree that recurses A budget DAG. A root agent's envelope is sub-delegated down a tree of scopes; no descendant can ever spend or re-delegate more than its ancestors granted it, at any depth.

Quickstart

Wrap an existing model call and give a sub-agent tree its own budget in a handful of lines:

from agentgov import BudgetManager, Interceptor, money

gov = BudgetManager()
gov.open_root("orchestrator", money("5.00"))  # the whole fleet's envelope
gov.delegate("orchestrator", "researcher", money("1.00"))  # a sub-agent's slice

metered = Interceptor(gov, "researcher", model="claude-opus-5")
result = metered.invoke(client.messages.create, model="claude-opus-5", messages=[...])

print(result.cost, gov.available("researcher"))  # exact settled cost, remaining budget

Run it now, with no API key, by metering anything that reports token usage:

from agentgov import BudgetManager, Interceptor, money

gov = BudgetManager()
gov.open_root("orchestrator", money("5.00"))
gov.delegate("orchestrator", "researcher", money("1.00"))

metered = Interceptor(gov, "researcher", model="claude-opus-5")
result = metered.invoke(lambda **_: {"input_tokens": 1200, "output_tokens": 350})

print(result.cost, gov.available("researcher"))
# 0.01475000 0.98525000

The accounting is the same on either path: invoke() authorizes a worst-case hold, runs your call with no lock held, prices the token usage it reports, and settles atomically. Let it run past budget and you get DenialOfWalletError, not a silent overspend:

from agentgov.exceptions import CircuitOpenError, DenialOfWalletError

try:
    metered.invoke(client.messages.create, model="claude-opus-5", messages=[...])
except DenialOfWalletError:
    ...  # refused pre-flight; the breaker just latched for this scope and its subtree
except CircuitOpenError:
    ...  # already latched from an earlier trip; this call never touched the ledger

See src/agentgov/core.py for the ledger and budget DAG, src/agentgov/interceptor.py for the call-site wrapper, and tests/test_runaway.py for the recursive-spawn scenario the design exists to stop.

The cognitive circuit breaker

The financial breaker is reactive by construction: it fires when the money is gone. The cognitive breaker attacks the cause: the open-loop execution cycle that spends it. Attach one and every call is checked for thrashing before its hold is placed:

from agentgov import BudgetManager, CognitiveBreaker, Interceptor, money

gov = BudgetManager()
gov.open_root("researcher", money("5.00"))
breaker = CognitiveBreaker(manager=gov)  # trips latch the financial breaker too

metered = Interceptor(gov, "researcher", model="claude-opus-5", cognitive=breaker)
metered.invoke(client.messages.create, model="claude-opus-5", messages=[...])
# agentgov.exceptions.AgentThrashingError: Agent thrashing halted for scope 'researcher'
#   after 4 calls [deterministic/near_duplicate, confidence 0.78]: 4 consecutive
#   near-identical calls with no semantic progress

Two tiers, one actuator.

Tier 1 runs inline and is deterministic. Three stateless detectors over a bounded ring buffer: ExactRepeatDetector (identical fingerprints), NearDuplicateDetector (character-shingle Jaccard between consecutive calls, the soft loop), and CallCycleDetector (an A→B→A→B oscillation that no pairwise check can see). Measured overhead is 77µs mean / 122µs p99 even with pathological 8KB arguments, roughly 0.005% of a real model call. Arguments are truncated before shingling and comparisons are window-capped, so the cost is bounded rather than merely small.

Tier 2 runs off-thread and is semantic. A daemon worker drains a bounded queue and runs a SemanticObserver. The shipped TrajectoryEntropyObserver tracks novelty decay: what fraction of each call is vocabulary the trajectory has never produced. An agent cycling six phrasings of one idea has low pairwise similarity but near-zero novelty. The agent thread never waits for it. A verdict is latched and enforced on the next call, so detection is one call late rather than blocking, and a saturated queue sheds samples instead of applying backpressure. This is the seam where an LLM-judge observer plugs in: implement evaluate() and change nothing else.

A cognitive trip calls BudgetManager.trip(), so it inherits latching, subtree propagation, hash-anchored control events, and durable persistence from the machinery that already exists: one halt mechanism, two families of sensor. The verdict is computed under the cognitive lock and the financial lock is taken only after releasing it, so the two are never held at once and no lock-ordering hazard exists.

Extending it. LoopDetector and SemanticObserver are Protocols; a detector that knows your tool schema will beat any generic text heuristic. Pass detectors=[...] to extend or replace the built-ins. A detector that raises is logged and skipped, so one bad custom heuristic degrades detection rather than breaking production traffic.

What it keeps. Nothing readable, by default. Each observed call is reduced to a salted BLAKE2b digest (the salt is per-instance, so a digest is not a lookup key for a known prompt) plus a bounded set of character trigrams. Readable text is retained only under an explicit CognitivePolicy(retain_arguments=True), and is truncated even then. For regulated data, install a Redactor. It runs at the single ingress every argument and result passes through, before anything is fingerprinted or stored, and a redactor that raises causes the text to be dropped entirely rather than retained unredacted. SECURITY.md is the full data map: what lands in SQLite, in memory, and in logs, and what encryption at rest AgentGov does not provide.

Where it does not reach. Legitimate iteration (pagination, map-over-a-list) looks like a soft loop on input similarity alone. The discriminator is the result: near-identical inputs producing near-identical outputs is thrashing; near-identical inputs producing different outputs is progress. The Interceptor feeds results back automatically, which handles the common case. It still cannot see through a templated result whose only variation is an index (record-1-0, record-2-0). Those are near-identical in trigram space even though the agent is advancing. That limitation is pinned by a test, along with its remedy: CognitivePolicy(exempt_tools={"fetch"}), a raised threshold, or a custom detector.

Dropping it in

invoke() is the primitive, and it stays the contract. But adopting it in an existing codebase means editing every call site, so there is a wrapper that governs the calls already written:

import anthropic

from agentgov import govern

client = govern(anthropic.Anthropic(), gov, "researcher", model="claude-opus-5")

response = client.messages.create(model="claude-opus-5", messages=[...])  # unchanged
print(client.agentgov.last_call.cost)

The proxy returns exactly what the SDK returns, because anything else would not be a drop-in, and forwards everything that is not a model call untouched. It is sugar over client.agentgov.interceptor, which hands the primitive back.

Streaming, the shape most real agents use, is governed as a context manager:

with client.messages.stream(model="claude-opus-5", messages=[...]) as events:
    for event in events:
        render(event)
print(events.cost)

The hold is resolved on every path out of that block: clean exhaustion, break, an exception, or a timeout. An abandoned stream never strands funds. On abandonment it settles at whatever usage was actually observed rather than voiding: those tokens were generated and billed, and a spend governor that forgot them would under-report. Only a stream that produced no usage at all is voided. astream() is the async equivalent.

Holds are sized from the payload. A static worst-case hold is wrong in both directions, and the dangerous direction is too small: an under-reserved hold does not encumber, so concurrent callers all pass the pre-flight check and settle for more than they reserved. Measured on a 150K-character prompt with ten concurrent callers, static holds breached a $1.00 envelope by 2.3x; payload-derived holds bounded it. Input tokens are estimated from character length, the output bound is taken from the call's own max_tokens, and a configurable safety_buffer (1.5x by default) absorbs the heuristic's error. Unrecognised payloads fall back to the static ceiling rather than under-reserving.

When the cost is only known part-way through

invoke() covers a call that returns once. When the cost emerges as you consume something — a stream you must settle whatever it produced before re-raising, or a vendor call that reports usage out of band — SpendGuard is the same authorize/settle pair as a context manager:

from decimal import Decimal

from agentgov import BudgetManager, SpendGuard, money

gov = BudgetManager()
gov.open_root("researcher", money("5.00"))

with SpendGuard(gov, "researcher", money("0.25"), memo="batch") as guard:
    consumed = sum(len(chunk) for chunk in ["alpha", "beta", "gamma"])
    guard.settle(Decimal(consumed) / 1000)

print(guard.cost, gov.available("researcher"))
# 0.013 4.987

The hold is placed on entry and resolved on every path out, including an exception: an abandoned block never strands funds. Settling twice raises rather than double-booking.

Verifying a ledger

Three checks, all on the manager:

from agentgov import BudgetManager, money

gov = BudgetManager()
gov.open_root("orchestrator", money("5.00"))
gov.delegate("orchestrator", "researcher", money("1.00"))

gov.verify_chain()  # every SHA-256 link, running balance and hold release
gov.verify_conservation()  # no money created, destroyed or double-counted
gov.verify_integrity()  # both, plus the tree and breaker state the chain implies
print("ok")

Each hold release names the hold it releases (its ref, inside the hash), so verify_chain() proves no hold was released twice, into the wrong scope, or for the wrong amount. Breaker trips, resets and every delegation are entries in the chain too, so verify_integrity() re-derives the tree and the breakers from the chain instead of trusting the tables that cache them.

verify_conservation() checks

Σ(scope balances) + outstanding_holds + settled_spend − reversals == funded

across every scope in the tree.

Framework adapters

Two lines on an agent you have already written. Neither adapter imports its framework. Every object is duck-typed, so they load without LangChain or CrewAI installed and keep working when those libraries reshuffle internals.

from agentgov.adapters.langchain import GovernedChatModel

model = GovernedChatModel(ChatAnthropic(model="claude-opus-5"), gov, "researcher")
answer = model.invoke(messages)  # unchanged call site, now metered

For LangGraph, or any chain deep enough that you never touch the model object, use the callback handler instead. Callbacks propagate down the whole run tree, so one handler governs every model call in a graph, keyed by run_id so concurrent branches settle independently:

from agentgov.adapters.langchain import GovernedCallbackHandler

graph.invoke(state, config={"callbacks": [GovernedCallbackHandler(gov, "researcher")]})

CrewAI settles per run, because CrewAI reports usage per run:

from agentgov.adapters.crewai import GovernedCrew

crew = GovernedCrew(Crew(agents=[...], tasks=[...]), gov, "research-crew")
result = crew.kickoff()

Two details these get right that are easy to get wrong. LangChain swallows exceptions raised inside callbacks unless the handler sets raise_error = True. Without it a denial-of-wallet halt would be logged and the graph would keep spending. And CrewAI's usage_metrics are cumulative across kickoffs, so settling the reported total each run would bill run one again on run two; GovernedCrew charges only the delta.

For a framework with no adapter, govern_agent wraps any function that runs a unit of agent work, given a callable that reads usage off its return value.

Reconciling the invoice

The question a finance or security team actually asks is not "what did we budget?" but "the provider billed us $40,000. Which agent caused it, and is there anything on there we never authorized?"

$ agentgov reconcile governor.db provider_invoice.json --journal tokens.jsonl

  CATEGORY                             COUNT             SPEND
  ------------------------------------------------------------
  Matched (billed and metered)             6         $0.034423
  Discrepant (cost mismatch)               0         $0.000000
  Phantom (billed, NOT metered)            1         $2.470000
  Unsettled (metered, not billed)          0         $0.000000

  PHANTOM CALLS - billed with no local authorization
    2026-09-14 06:42:29  claude-opus-5   $2.470000  req_LEAKED_KEY_7f3a

  AUDIT FAILED  1 phantom call(s) worth $2.470000

A phantom is the finding that matters: spend the provider billed that AgentGov never authorized: a leaked key, or a service calling the model outside the governor. Any phantom line exits 1, so this belongs in a pipeline.

Matching is fuzzy on purpose. Timestamps drift by network latency and token counts drift because pre-flight sizing is a chars/4 heuristic; exact matching would report a healthy ledger as entirely broken. Both windows are configurable (--time-tolerance, --token-tolerance, --cost-tolerance).

Token counts come from a MeteringJournal, an additive sidecar. The ledger records money and structure and deliberately not token metadata. Without a journal reconciliation still runs on cost and time, and says so.

Reading a ledger

$ agentgov inspect governor.db
BALANCE TREE
  orchestrator  available $3.00000000  of $5.00000000
  `- researcher  available $1.48349000  of $2.00000000
     `- scraper  available $0.49248500  of $0.50000000   [HALTED by scraper]

$ agentgov verify governor.db
  ok    hash chain, balances + hold pairing
  ok    conservation identity
  ok    topology + breakers, derived from the chain

PASS  governor.db  (14 entries verified; head 56d879d72557ec6e)

verify exits 0 on PASS and 1 on FAIL, so it drops straight into a pipeline to assert an archived ledger is intact. Both commands open the database read-only, so they are safe to run against a governor that is live and holding the write claim. agentgov repair governor.db is the one command that writes: see Durability.

Action receipts (ARC1)

Unreleased, on the v0.2 line: agentgov.receipts and agentgov verify-receipt.

A ledger proves what an agent spent. A receipt proves what it did. Every governed action against a system of record can leave an ARC1 receipt, including refused ones. A receipt is a signed record of:

  • who authorized the action;
  • what the agent said it would do;
  • what the action measurably did, and what the measurement could not see;
  • what was decided, and by which checks;
  • what it cost;
  • how it ended.

Receipts go into an append-only RFC 9162 Merkle log, and a witness cosigns the log's checkpoints. A receipt therefore cannot be edited, moved, dropped from history, or shown differently to different people without it showing. Row data stays out of the receipt. A salted Merkle root commits to every changed row, and the issuer can later disclose any subset to an auditor, with proofs, while the rest stay hidden.

$ cd vectors/arc1
$ agentgov verify-receipt valid/committed-refund.bundle.json --pubkey keys/issuer.pub \
      --witness witness/cosignatures.jsonl --witness-pubkey keys/witness.pub \
      --rows valid/committed-refund.rows.json
  ok    ARC1 schema        receipt f87ad35e-c540-5f93-8c5a-62d3006db353 (committed, ...)
  ok    receipt signature  ed25519, key bcc542d53c8c1a1f
  ok    log inclusion      leaf 0 of 7 in log 'arc1-vectors' (root 5b025750f43bc1be)
  ok    witnessed          by 'arc1-vectors-witness' (key ac51d42df2b0ebbd) at 2026-09-25T15:05:00.000000Z
  --    agentgov ledger    no ledger given
  ok    disclosed rows     2 of 3 committed rows verify under row_root 9ab84da7294fe5de

PASS  valid/committed-refund.bundle.json  (receipt f87ad35e-c540-5f93-8c5a-62d3006db353)

--ledger governor.db also checks the receipt's cost against the AgentGov ledger that paid for the action. The ledger must have settled exactly that amount, in transactions recorded before the receipt was anchored. The exit code says what failed, so a pipeline can gate on it:

Exit Meaning
0 every check you gave evidence for passed
2 usage error, or a file that cannot be read
3 not a well-formed ARC1 document
4 the receipt's signature does not verify
5 the log checkpoint's signature, or the audit path to it, fails
6 the checkpoint was never witnessed, or the witness saw a different history (a split view)
7 the receipt disagrees with the AgentGov ledger
8 a disclosed row is not one the receipt committed to

The same checks are available in code:

from pathlib import Path

from agentgov.receipts import load_cosignatures, parse_key, verify_bundle

vectors = Path("vectors/arc1")
report = verify_bundle(
    (vectors / "valid" / "committed-refund.bundle.json").read_bytes(),
    issuer_key=parse_key((vectors / "keys" / "issuer.pub").read_text()),
    cosignatures=load_cosignatures(vectors / "witness" / "cosignatures.jsonl"),
    witness_key=parse_key((vectors / "keys" / "witness.pub").read_text()),
)
print(report.exit_code, [check.status for check in report.checks])
# 0 ['pass', 'pass', 'pass', 'pass', 'skip', 'skip']

Verifying needs only the standard library, including a built-in RFC 8032 Ed25519 verifier. Issuing receipts with Ed25519 keys needs pip install 'agentgov[sign]'; HMAC-SHA256 keys, for internal use, need nothing. receipts.ReceiptLog.issue() places, signs and logs a receipt in one step, and receipts.verify_bundle() is the verifier behind the command.

docs/RECEIPTS.md is the specification. It covers the canonical encoding, the signed bytes, the log, witnesses, row commitments, the verification order, and what a receipt does not prove. vectors/arc1/ holds deterministic reference vectors, regenerated and byte-compared on every CI run. Its manifest.json lists each case with the exit code a conforming verifier must produce.

Durability

An in-memory ledger is not an audit trail: restart the process and it is gone. Swap BudgetManager() for BudgetManager.open_sqlite(path) and every operation goes to disk first, before it ever touches memory, as one SQLite transaction: a capture's release and spend, the hold it closes, the breaker trip it causes, all or none of it:

from agentgov import BudgetManager, money

with BudgetManager.open_sqlite("governor.db") as gov:
    gov.open_root("orchestrator", money("5.00"))
    ...  # identical API; every write is durable before it's visible in memory

Reopen that file in a brand new process and the ledger, the delegation tree, every circuit-breaker trip, and any authorization left mid-call are restored exactly. examples/persistence_demo.py proves this with two genuinely separate python subprocesses, not just a discarded object:

uv run python examples/persistence_demo.py
--- process 1: writes state, then exits completely ---
WRITER  balance(researcher)=0.65000000
WRITER  halted(scraper)=True
WRITER  chain_length=16
WRITER  open_authorization_id=24b1af62-e7ac-4b54-b2fb-2475f6c40006

--- process 2: independent interpreter, same file ---
READER  verify_integrity() = PASS
READER  balance(researcher)=0.65000000
READER  halted(scraper)=True
READER  chain_length=16
READER  open_authorizations=1
READER  scraper still refuses calls: Scope 'scraper' is halted by its own breaker: ...

A database that has been tampered with, or merely corrupted by a crash mid-write, is refused at open time, not served with a wrong balance: open_sqlite() re-runs verify_chain() and verify_integrity() before handing back a governor at all. This uses only sqlite3 from the standard library, so durability adds zero runtime dependencies.

The chain is the record; the other tables are caches of it. The delegation tree, the breaker events and the open authorizations are also kept in their own tables for fast reads, and at open they must agree exactly with what the chain implies. One that does not, such as the half-finished capture a v0.1.1 crash could leave behind, is refused with a message saying which rows disagree. agentgov repair governor.db (or open_sqlite(path, repair=True)) rebuilds those tables from the chain. No money moves, and BudgetManager.repairs lists what was rebuilt.

Upgrading from v0.1.0 or v0.1.1. A database written by either is upgraded in place the first time v0.1.2 opens it for writing: its schema gains the columns v0.1.2 needs, and a migration seal, a zero-value entry carrying a digest of the old breaker events, commits those events into the chain. Opened read-only, it is served as it is. Entries keep the audit version they were written with (AGOV1 or AGOV2), and each verifies under its own rules.

One writer, enforced. A governor takes an exclusive advisory lock on its database. A second process, a second gunicorn worker or a second replica, is refused at open with a ConcurrentGovernorError naming the PID that holds it, because two writers would each cache authoritative balances in memory and diverge. The lock is an open file descriptor, so the kernel releases it even on SIGKILL: a crashed governor leaves a stale file but never a stale lock, and there is no timeout heuristic to get wrong. To inspect a ledger another process is governing, open it read-only:

audit = BudgetManager.open_sqlite("governor.db", read_only=True)
audit.verify_integrity()  # reads and verifies; every write is refused
audit.refresh()  # catch up with the writer, verifying every new entry

A read-only view is a snapshot taken at open until it is refreshed. refresh() reads everything committed since in one consistent read, verifies it against the head the view already trusts, and applies it all or none of it: balances, tree and breaker state move forward together. If the ledger was rewritten under the view, refresh() raises and the view refuses to follow it any further.

Dangling holds are findable. A hold left open by a process that died between authorize() and capture() encumbers funds with nothing left to settle it. stale_authorizations(older_than) surveys them without touching anything, and void_stale(older_than) releases them. Deliberately an operator action rather than a background timer: voiding asserts the call will never settle, and AgentGov cannot know that. If such a call does complete later, its capture raises DoubleSpendError. The ledger refuses to book the same encumbrance twice, and since v0.1.2 that refusal is a rule of the chain itself: a release that names a hold already released is rejected before it is written.

See tests/test_persistence.py and tests/test_hardening.py for the restart, corruption, lock-contention, and durable-write-failure matrices.

A ledger shared by a fleet (PostgreSQL)

SQLite gives a ledger one writer. When N workers on N hosts need one authoritative balance, put the ledger in PostgreSQL and open a governor in each of them:

pip install 'agentgov[postgres]'
from agentgov import BudgetManager, money

# In every worker, on every host: the same database, the same schema.
gov = BudgetManager.open_postgres("postgresql://governor@db/fleet", schema="agentgov")
gov.open_root("orchestrator", money("5.00"))  # once, anywhere in the fleet
auth = gov.authorize("orchestrator", money("0.02"))  # a dollar held here is held fleet-wide
gov.capture(auth, money("0.013"))

Every governor keeps a verified copy of the ledger in memory, but none of them is the ledger any more; the database is. So every mutation is one database transaction that:

  1. takes a fleet-wide writer lock (pg_advisory_xact_lock on the ledger's schema);
  2. reads everything the other governors committed since this one last looked, and verifies it entry by entry, hash links, balances and hold pairing, exactly as a fresh open would;
  3. only then checks the balance or the breaker and builds its entries on the head it just verified;
  4. commits, which releases the lock.

A hold one governor places is visible to every other: they all check against the same balance. A breaker one of them trips halts the subtree for all of them, and latches once, however many raced into it. A hold placed on one host can be captured on another. The first governor to open a database creates the ledger's tables; after that a role granted only DML on them can run one, and cannot edit history or lift the triggers.

The lock is for liveness; the chain is for safety. Two triggers keep the chain linear by themselves: an entry is admitted only if it links, by sequence and hash, to the committed head, and entries refuse UPDATE, DELETE and TRUNCATE. A governor whose view went stale, or one that never took the lock, is refused rather than allowed to fork the chain. tests/test_fleet_concurrency.py removes the lock from every governor and races them: writes get refused, and the ledger still balances to the cent.

What happens when something fails.

  • A governor that cannot get the lock within lock_timeout (30s) raises StorageError and changes nothing.
  • A governor that stalls while holding the lock (a paused process, a hung host) is cut off by the server after idle_timeout (60s), its writes rolled back and the lock freed. One that is killed outright frees it at once.
  • A write whose commit fails, or whose answer is lost with the connection, is reported as a StorageError, and the governor re-reads the whole ledger, under the lock, before it acts again. A lost answer is never retried blind: the ledger holds the write once or not at all.
  • A governor that reads an entry that does not verify stops writing. Every later write raises LedgerIntegrityError; reads keep serving what it last verified.

What to know before relying on it.

  • Reads are as of this governor's last write. available(), is_halted() and the rest are served from memory; call refresh() first when the answer must be current. Writes always catch up first.
  • The runaway-loop velocity window is per governor, not fleet-wide: eight governors with a 1000/s limit admit up to 8000/s between them.
  • One global chain serializes every write in the fleet, and it is slower than SQLite. Measured on one laptop against PostgreSQL 16 in Docker, spend() ran at about 500 a second from one governor and about 180 a second in total with eight contending, against about 4,500 a second for a single SQLite governor on the same machine. Each write is a dozen round trips, and must first read what the others wrote. That is the price of one authoritative balance; a chain per root scope is how it would scale out.
  • Tested on PostgreSQL 14 and 16, connected directly. Not yet tested behind a connection pooler.
  • The CLI does not read PostgreSQL yet. Use open_postgres(..., read_only=True) and verify_integrity() for an audit.

tests/test_postgres_store.py pins the store's contract: install, restart, tamper evidence, least privilege, and every failure above. tests/test_fleet_concurrency.py races 64 workers across 8 governors, and 8 separate processes, against one envelope.

Settling with someone else's commit. When the ledger shares a database with what an agent changes, a governor can make what the change owes the ledger commit exactly when the change does. It places a hold first, joins the transaction that makes the change, records a settlement claim there, and books the claim into the chain after the commit. This is how Interlock settles a plan's cost with the plan's effects:

gov.store.grant_join("app_writer")  # once, as the ledger's owner
hold = gov.authorize("orchestrator", money("0.02"))  # before the caller's transaction
# ... app_writer opens a transaction on conn and makes its own writes, then:
with gov.joined(conn) as txn:  # the writer lock is taken inside conn's transaction
    claim = txn.claim("orchestrator", money("0.013"), memo="order 42", hold=hold)
    conn.execute("COMMIT")  # the claim commits with the caller's writes, or not at all
gov.redeem(claim)  # the hold's release and the spend, booked into the chain

The claim reads nothing of the chain, so a REPEATABLE READ transaction whose snapshot is older than other governors' commits claims all the same: however busy the ledger, nothing conflicts. A claim left pending by a process that died is booked by any governor's redeem(). The joined role gets no privilege on the ledger's tables; its one function admits a claim only with a token derived from a secret the role cannot read and from that transaction's own id, so nothing else running in the transaction can claim anything. See tests/test_postgres_joined.py.

Core primitives

  • Authorize → Hold → Settle. BudgetManager.authorize() places an encumbering hold before a call is made; capture() releases it and posts the true cost as one atomic ledger transaction. Funds are unavailable to siblings for the entire in-flight duration of a call, not just at the instant of debit. That is what makes double-spend structurally impossible rather than merely unlikely under load.
  • Latching circuit breaker. A trip on overdraft, runaway-loop velocity, or budget exhaustion halts the scope and every descendant beneath it, and stays halted until an operator calls reset(). A DenialOfWalletError on overdraw and a RunawayLoopDetectedError on call-frequency abuse both trip it; every subsequent attempt then fails fast with CircuitOpenError without touching the ledger at all. The trip and the reset are themselves zero-value entries in the chain, so a halt cannot be deleted without breaking verification.
  • Cryptographic double-entry ledger. Every line is a DEBIT or CREDIT against exactly one scope; internal transfers post balanced pairs in a single transaction. Entries are hash-chained with SHA-256 and never mutated. Corrections are compensating entries, never edits, so verify_chain() can prove the entire history is exactly what it claims to be, and verify_conservation() can prove no money was invented along the way.
  • A cognitive breaker on the same actuator. Deterministic loop detection inline (77µs mean) plus a semantic observer off-thread, halting a thrashing agent for cents instead of dollars and recording the verdict in the same hash-anchored audit trail as every financial event. See The cognitive circuit breaker.
  • Write-through durability. BudgetManager.open_sqlite() writes each operation, its ledger entries, topology change, breaker trip and open authorization, to disk as one transaction before committing it to memory, so a store failure or a crash aborts the whole operation instead of leaving memory ahead of disk or half of it on disk. A corrupted or tampered file refuses to load rather than being trusted. See Durability above.
  • Anchors. anchor(scope_id, memo) commits an external record, typically another hash chain's head, into this chain as a zero-value entry. It moves no money, and editing the memo afterwards breaks verification. This is how interlock binds its record of database writes to this ledger.

Known limitations & v0.1 scope

Every claim on this page is measured, and the boundaries of what was measured matter as much as the numbers. This section states them plainly rather than leaving them to be discovered in production.

SQLite is single-writer by design: one process, one host

BudgetManager.open_sqlite() takes an exclusive advisory lock on the database. One process governs one SQLite ledger. A second process is refused at open with ConcurrentGovernorError naming the holding PID; it is not silently allowed to diverge. For a fleet, see A ledger shared by a fleet.

Measured throughput on that single writer, all of it behind one global mutex:

Configuration Mean per governed call p99 Sustained ceiling
In-memory ledger 0.062 ms n/a ~16,000 calls/sec
SQLite, synchronous=FULL 0.613 ms 3.448 ms ~1,600 calls/sec

This is a deliberate trade, not an oversight. The alternative, shipping a coordination service, would mean infrastructure to deploy, a network hop in the hot path, and a dependency tree, all before anyone could evaluate whether the governor is worth having. pip install agentgov with zero runtime dependencies and a local file is what makes the thing adoptable in an afternoon. The cost of that choice is that AgentGov v0.1 governs a process, not a fleet.

The fleet path is PostgreSQL, behind an extra. agentgov.storage.SharedStore extends the PersistenceStore seam to a ledger several governors write, and agentgov.postgres.PostgresStore implements it: the database is the serialization point, so N processes across N hosts share one authoritative view of every balance, without a bespoke consensus cluster. It did need more than a new store: a governor over a shared ledger has to catch up with the others, under their shared lock, before every check, which is what BudgetManager now does when its store is shared. The ledger's rules, the budget DAG and the breaker are unchanged. SQLite stays the default, so the zero-dependency install is unaffected, and so does its throughput: the shared ledger is several times slower (see the measurements in that section).

A guardrail inside a process, not a sandbox around it

AgentGov enforces at the call site, in your process. Anything that can import agentgov can also call the provider SDK directly and spend unmetered. The hash chain is tamper-evident, not tamper-resistant. verify_chain() will prove a file was edited, but nothing stops a process with write access from editing it. The chain is keyless and there is no external witness yet, so someone who can compute SHA-256 can rewrite the whole chain consistently, or cut entries off its tail, and it will still verify. Only a copy of the head held somewhere else can show that. The reconciliation engine is the backstop for out-of-band spend, and it is detection after the fact, not prevention. Treat AgentGov as a budget guardrail against runaway and accident, the failure mode that actually burns money today. It is not a security boundary against a hostile agent.

Published rates are a snapshot

agentgov.interceptor.PRICING is a hardcoded table of published list rates, current as of the date in its docstring. Vendors change prices, and partner platforms (Bedrock, Vertex) bill differently. Pass an explicit ModelPricing for anything not in the table, and treat reconciliation against the real invoice as a production requirement rather than a nicety. That is precisely why the reconciliation engine exists.

Settlement follows the model that served. The provider can run a different model than the one requested — a server-side refusal fallback substitutes one mid-request, and an undated alias resolves to a dated snapshot (claude-haiku-4-5 → claude-haiku-4-5-20251001). invoke() reads response.model, folds any dated suffix with normalize_model_id() (exported from the package, so a caller pricing its own calls folds the same way), and settles at those rates, so the ledger books what will actually be invoiced. MeteredCall.model_id reports what served. Two gaps remain: the hold is sized before the call and can only use the configured model, and streamed calls keep the configured rates because a stream has no single response object to read the served model from.

What has been proven deterministically, and what has been proven live

These are different claims and this project keeps them separate.

Proven deterministically. The accounting invariants are verified against DummyLLM, a deterministic offline stub, under 64 contending threads and 100 concurrent asyncio tasks: no double-spend under concurrency, conservation of value, an unbroken SHA-256 chain, the latching breaker. This is the right harness for these properties: a governor that is only probably correct under load is not correct, and a nondeterministic backend cannot prove a race is absent.

Proven against the live API. Correct accounting says nothing about whether the metering layer reads a real provider response. That gap is closed by scripts/generate_real_usage.py, which drives four governed workloads across four Anthropic model tiers through the official anthropic SDK, re-prices every settled call independently from the published rates, and writes every raw API payload to disk as evidence. See Live API metering below.

Roadmap: Phase 2

Phase 1 is a correct, single-process governor: one ledger, one mutex, durable to a local SQLite file, with financial and cognitive breakers on the same actuator. Beyond that:

  • State recovery after a trip. A halt protects the budget and leaves the task unfinished, and a human then has to notice, diagnose and restart it. The design brief covers cryptographic state checkpointing over a Merkle prefix tree, an append-only intervention ladder ordered by prompt-cache invalidation cost, and a budgeted stopping rule over the breaker's own novelty signal. See ARCHITECTURE.md, Part B.
  • Scaling the shared ledger out. A fleet can now share one ledger through PostgreSQL (see A ledger shared by a fleet), but one global chain serializes every write. Next: fewer round trips per catch-up, then a chain per root scope, so unrelated envelopes stop contending for one lock.
  • x402 / AP2 settlement integration. Use AgentGov's authorize/capture holds as the enforcement point those protocols gesture at but don't enforce at runtime, settling real sub-cent agent transactions through a payment rail instead of a simulated ledger.
  • A shipped LLM-judge observer. Tier 2's SemanticObserver seam already accepts one; what is missing is a batteries-included implementation with its own cost accounting, so the observer that watches for waste cannot itself become a source of it.
  • Enterprise SecOps control plane. Centralized policy management, breaker-trip alerting, and per-tenant audit export, so the hash-chained trail this library already produces plugs into an org's existing compliance and incident-response tooling.

Code quality & packaging

uv sync                              # install (zero runtime dependencies)
uv run pytest -v
uv run pytest --cov=agentgov         # CI fails the build below 95%
uv run ruff check . && uv run ruff format --check .
uv run mypy src/                     # strict, zero errors
uv run python examples/denial_of_wallet_benchmark.py
uv run python examples/persistence_demo.py

Every gate above runs in CI (.github/workflows/ci.yml) on Python 3.11 and 3.12, on Linux and macOS, with a 95% coverage floor and a build that fails on packaging warnings. Both example scripts are executed end to end so a broken demo cannot merge.

docs/DEMO_RUNBOOK.md walks the four commands end to end (adopt, halt a runaway, read the chain, catch unmetered spend); start with uv run python examples/live_demo.py. demo.tape renders that same walkthrough as a terminal recording with VHS (vhs demo.tape), and examples/streamlit_dashboard.py gives the resulting ledger a web UI (balance tree, hash chain, and a reconciliation button) via uv sync --extra ui && uv run streamlit run examples/streamlit_dashboard.py.

Packaged with uv; metadata, license, and classifiers live in pyproject.toml. Security policy and data map: SECURITY.md. Contributing guide: CONTRIBUTING.md. Release notes: CHANGELOG.md. Licensed under Apache 2.0.

Metadata

Release files for interlock-agentgov 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for interlock-agentgov 0.4.0
File Size Uploaded
interlock_agentgov-0.4.0.tar.gz 620.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for interlock-agentgov 0.4.0
File Interpreter ABI Platform
interlock_agentgov-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 832.9 kB

Release files / interlock_agentgov-0.4.0.tar.gz

Download URL interlock_agentgov-0.4.0.tar.gz
Size 620.7 kB
Tags Source
SHA-256 checksum
How to use checksums
73978a9b1757a12e99ef052effb6fa6a167ed423acb0b16ab900ca3c60257c44
BLAKE2b-256 checksum
How to use checksums
6e160ebbe13fe7018c54cbabcdac129f3e2711bcda7cabe13a0bc96d7db8d422
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.10 {"installer":{"name":"uv","version":"0.12.10","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / interlock_agentgov-0.4.0-py3-none-any.whl

Download URL interlock_agentgov-0.4.0-py3-none-any.whl
Size 212.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
275e64bc5bd655af2c1ccbb00237163721c5411e59a4c6fe031080a0101da0f4
BLAKE2b-256 checksum
How to use checksums
61c94ac4482de5c1f05b3442f817a38206f4b17b47ad0d5d972fdff4e3e4793b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.10 {"installer":{"name":"uv","version":"0.12.10","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 release files

0.3.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page