The problem, the five rules, the core in code, and where it plugs in. Back to the README.
A customer paid $100. A support case approves one $20 partial refund. An AI agent issues it. The service commits the refund; the process dies before the response comes back. On restart, nothing in the system can answer three questions: did the refund happen? may I retry? was I still allowed to do it?
Today’s answer is a human with two logs and a spreadsheet. Or, worse, the workflow re-runs, the model says “$30” this time, and the customer gets $50. Interlock answers all three questions mechanically, refuses the $30, and is honest about the one case where nobody can know.
python3 demo.py naive # today: the refund lands twice
python3 demo.py 2 # Interlock: lands once, receipt says so
python3 demo.py 3 # Interlock against an API that can't help: refuses to guess
The whole system on one screen — the crash, and the three ways recovery can honestly end:
sequenceDiagram
participant A as Agent
participant G as Interlock
participant J as Journal on disk
participant S as Stripe
A->>G: refund $20 on order 881
G->>J: PROPOSED, with the facts it relied on
Note right of J: paid $100, refunded $0,<br/>case 4471 approves $20
G->>G: lease still live? facts still true?
G->>J: DISPATCHED — fsync, before the send
G->>S: create refund, idempotency key = effect id
S->>S: refund committed
Note over A,S: the process dies before the response arrives
Note over G,J: restart — journal says DISPATCHED, no COMMITTED
G->>G: re-check the lease and the facts first
alt the world held still
G->>S: look it up by effect id
S-->>G: refund exists, $20
G->>J: COMMITTED (recovery)
else support refunded by hand during the outage
G->>J: REFUSED — refunded was 0, now 20
else the API can neither dedupe nor answer
G->>J: AMBIGUOUS — surfaced, never guessed
end
G-->>A: receipt: happened once, authorized when it fired, facts held
Five rules, one append-only log, Python with no dependencies.
DISPATCHED entry, fsync’d, before the call goes out. After any crash, “things I started and never confirmed” is a query, not a guess.AMBIGUOUS out loud. Before it sends anything a second time, it re-checks the lease and the premises, because the outage is exactly when the world moves.stateDiagram-v2
direction LR
[*] --> PROPOSED: agent decides, premises captured
PROPOSED --> AUTHORIZED: lease is live
AUTHORIZED --> DISPATCHED: intent on disk first, then the send
DISPATCHED --> COMMITTED: target confirmed it
PROPOSED --> REFUSED: lease dead or premise stale
DISPATCHED --> REFUSED: world moved during the outage
DISPATCHED --> AMBIGUOUS: target cannot dedupe or be queried
COMMITTED --> [*]
REFUSED --> [*]
AMBIGUOUS --> [*]
The output of every effect is a receipt: the four facts and a final state. executed is what the target confirmed, not what was attempted, so after a crash nobody can resolve it reads unknown.
proposed True
authorized True
executed True
recorded True
final COMMITTED
That is the object the brief asked for. An auditor reads receipts instead of reconciling logs.
Strip away every adapter, exporter, viewer and experiment, and what is left — the new thing this repo adds — is three decisions, in a small core of Python. This section is the whole invention, in plain language, down to the lines that carry it.
The moment a request is approved, its identity is computed and never changes:
# interlock/journal.py — effect_id_for()
canonical = json.dumps(key if key is not None else proposal["effect"], sort_keys=True, default=str)
return hashlib.sha256(canonical.encode()).hexdigest()[:12]
The effect id is a hash of the approved request. Not a random UUID, not something the model picks. That one choice buys two guarantees with one mechanism:
REFUSED:conflicting_payload — “payload differs from recorded decision”).Everyone treats “remember what was decided” and “don’t do it twice” as two systems: an audit log and an idempotency key. Deriving the key from the decision makes them the same fact. The dedup key is the decision record. That is why a model changing its mind on retry is caught by arithmetic, not by a reviewer.
Immediately before the network call — after the authority and premise checks pass, and recording exactly which checks passed — one line goes to disk and is forced onto the platter:
# interlock/journal.py — append()
f.write(json.dumps(entry) + "\n")
f.flush()
os.fsync(f.fileno()) # durable before we return
That line is DISPATCHED: I am about to do this, here is why, under this authority. The process can now die at any nanosecond, and on restart the question “what did I start and never confirm?” is a file scan, not archaeology across two companies’ logs. Every entry also carries the hash of the previous entry for its action (entry["prev"]), so the record is a chain: edit an amount, drop an entry, reorder anything, and verify() fails. The write-ahead idea is fifty years of database practice; the new part is what’s in the entry — the premises and the authority, not just the intent — because those are what recovery needs next.
On restart, for every DISPATCHED with no COMMITTED, gate._recover_one() runs this order, and the order is the invention:
AMBIGUOUS and say so to a person (tier 3) — because “sent, ack lost” and “never sent” are the same observation from our side, and that is the Two Generals result from 1975, not an engineering gap. The gate does not beat the impossibility. It prices it, per service, out loud.Steps 1 and 2 before step 3 is the exact thing missing everywhere else. Temporal, DBOS, Restate and LangGraph replay the recorded decision — deliberately, that is their contract. ATR re-checks premises but excludes crashes. Cordon parks the unclear case but states no per-target guarantee. And it is the thing missing from our own first version: until Finding 4, our recover() resent at tiers 1 and 2 without re-checking, and the fault table caught it paying $40. The hardest bug in the system was here too: a recovery running while the original send was still in flight could send twice, so a send now holds a claim that recovery must wait out — that wait is the 43-second median on the live runs, the measured price of never racing yourself.
| remove | what happens | the row that proves it |
|---|---|---|
| identity from the decision (1) | the retry is “a different request”; the re-decided $30 lands; customer gets $50 | table 1, “model re-decides $30 on retry” — every baseline that lacks I5 |
| the fsync’d intent (2) | after a crash you cannot list what you started; you either always retry or never retry, both wrong | table 1, both crash rows |
| the re-check at recovery (3) | the world moved during the outage and the old decision fires anyway: $40 double refund, or a refund under revoked authority | table 1, last three rows — naive, keys, and durable execution all fail them |
| the tier honesty | you claim exactly-once against an email API, which is provably impossible; the failure surfaces as a silent guess | table 1, tier 3 column — AMBIGUOUS, never a wrong payment |
That is the entire addition to the world, and it is small on purpose. Receipts, escalations, the inbox, the exporters, the runtime and the adapters are how this core meets the tools people already run. The tables and the 405 tests are the evidence that the core does what this section just said.
Pieces of this exist separately, in durable-execution engines and in papers published this summer. Interlock joins three steps into one system:
AMBIGUOUS where neither is possible. Cordon (Tsinghua, EuroSys 27) parks unclear effects for a human; nobody states the guarantee per target.The last three rows of the refund table below are steps 2 and 3, measured.
$ python3 demo.py 2
system: INTERLOCK, payments API at tier 2
customer paid $100. case #4471 approves one $20 partial refund.
agent decides: refund $20
── refund executes, then the process dies before the ack ──
💥 crash
── process restarts ──
journal says DISPATCHED with no COMMITTED → recovery: COMMITTED_ON_QUERY
refunded on order #881: $20 (1 refund record(s))
journal:
PROPOSED 8351811bed3d
AUTHORIZED 8351811bed3d
DISPATCHED 8351811bed3d
COMMITTED 8351811bed3d recovery-query
Same crash, same agent, against an API that offers neither dedup nor lookup:
$ python3 demo.py 3
...
journal says DISPATCHED with no COMMITTED → recovery: AMBIGUOUS
refunded on order #881: $20 (1 refund record(s))
final AMBIGUOUS
It refused to retry, so there is no duplicate. It also refused to claim success, because it can’t know. That is the whole design philosophy in one row: never guess, and say exactly what you can’t establish.
Open viewer/index.html. It replays the gate’s real journal for every fault as an animated trace: the proposal and its premises, the lease check, the intent written to disk, the crash, what changed during the outage, and how recovery resolved it, with the receipt filling in as it plays. Switch tiers to see the same fault against an API that dedupes, one that can be looked up, and one that can do neither. No server, no build. After changing the gate, regenerate the data with python3 viewer/build.py.
Or watch it against the real thing: python3 demo/serve.py serves a browser page that runs the same support case side by side, with and without the gate, against Stripe test mode — kill the worker, restart it, watch recovery resolve. Needs a model key and a Stripe test key; demo/README.md has the one-liner. The same page runs on real Temporal retries if temporalio is installed.
Interlock is a wrapper around the point where an agent calls a tool. It does not touch the model, the prompt, or the agent framework’s planning loop.
flowchart LR
subgraph agents ["your agent stack, unchanged"]
MCP["MCP server, proxied"]
LC["LangChain / LangGraph tools"]
ADK["Google ADK + AP2"]
TMP["Temporal activity body"]
PY["plain Python function"]
end
subgraph gate ["Interlock"]
J["journal: intent on disk before the send, hash-chained"]
L["leases: authority checked at dispatch and at recovery"]
P["premises: facts re-checked when the action actually lands"]
end
subgraph target ["the service, by what it can do"]
T1["tier 1: dedupes — retry is safe inside the window"]
T2["tier 2: can be queried — recovery looks it up"]
T3["tier 3: neither — AMBIGUOUS, out loud"]
end
R["receipt per effect: happened once, authorized, premises held"]
EXP["exports: BigQuery, Cloud Logging, OpenTelemetry, SIEM"]
agents --> gate
gate --> target
gate --> R
R --> EXP
gate = Gate(target=StripeTarget(client), journal_path="~/.interlock/journal.jsonl", leases=leases)
result = gate.submit({"agent": "refund-bot", "lease": lease_id,
"premises": target.capture(order_id),
"effect": {"order": order_id, "amount": amount}})
An EffectTarget is an adapter with four methods: capture premises, validate_premises, apply, and optionally query. It declares its tier. Writing one for a real service is an afternoon:
| target | tier | how |
|---|---|---|
| Stripe | 1 | Idempotency-Key: <effect_id> header; Stripe dedupes for 24h |
| GitHub merge | 2 | look up the merge commit by branch SHA on recovery |
| Postgres write | 2 | INSERT ... ON CONFLICT keyed on effect id, or a lookup |
| SendGrid / most email | 3 | no dedup, no lookup: AMBIGUOUS on crash, surfaced for reconciliation |
| Natural, agent-native rails | 1 | receipt-bearing ledger; the cooperating side, being built now |
If you don’t run Temporal, the repo also carries runtime/: a durable workflow runtime on Postgres alone (about 900 lines, one dependency), with the gate’s dispatch-and-commit transactions and tiered recovery built into the step primitive, and its recovery design model-checked in spec/. It is not the pitch; it is the proof that the gate’s contract fits any runtime — docs/07-runtime.md.
Integration sketch for LangGraph, MCP, and the OpenAI Agents SDK in docs/04-integration.md.
The platform engineer who gets paged when an agent double-fires. The team running coding agents in parallel and finding green builds that don’t run. The finance team about to let an agent touch refunds and being told by compliance that “we have logs” is not an answer to “who authorized this.”
Reconciliation is already the most expensive manual process in corporate finance. Agent-originated actions are about to multiply the volume that needs reconciling. A receipt per effect is cheaper than a human per incident.
interlock/ the gate
journal.py append-only and hash-chained, JSONL or shared SQLite; effect_id_for() binds identity to the approved request
leases.py authority that expires and is checked at dispatch
gate.py eight invariants, recovery by tier, claims registry, three baselines
easy.py the three-line decorator: Interlock(dir), @gate.effect(...), gate.recover()
approvals.py rules, routes with SLAs, a queue rebuilt from the journal, approvals as authority re-checked at send time, Envelope for repair
escalation.py shapes for escalations, decisions and confirmations; what changed and safe repairs
confirm.py Stripe webhook signature check and refund lookup, appended as CONFIRMED
scoreboard.py approval stats derived from the journal
receipts.py receipt bundles and verify(): happened once, authorized, assumptions held, who decided, target confirmed
mcp_proxy.py zero-line integration in front of any MCP server
tools.py protect() for in-process tool lists, and the refusal-and-repair message both share
langchain_tools.py protect_tools() for LangChain and LangGraph BaseTool lists
temporal.py gated(): the gate as a Temporal activity body
export/ receipts to BigQuery, Cloud Logging, OpenTelemetry and SIEM formats
integrations/ the Google ADK Guard callback and AP2 mandate checks
targets/
payments.py simulated refund API at tiers 1 / 2 / 3, Stripe-style key semantics
repo.py local code repo with file-hash and symbol premises
stripe_api.py real Stripe refunds, test-mode keys, standard library only
runtime/ durable workflow runtime on Postgres alone; the gate's contract as a step primitive (docs/07-runtime.md)
spec/ TLA+ model of the recovery design; broken/ reproduces every counterexample TLC found
scenarios/ eight live-service scenario harnesses behind docs/10-scenarios.md
experiments/ the fault-injection harnesses; run_all.py writes results/
results/ the tables above, as markdown and json, live runs with every Stripe id
viewer/ index.html replays real journals as an animated trace; build.py writes traces.js
demo/ browser side-by-side: the same crash with and without the gate, live
tests/ every README claim as an assertion, a 2,000-case fault sweep, the decorator through a restart
docs/
00-the-whole-story.md the narrative: root → decisions → field → business
interlock-explained.html the same story as an interactive page
01-problem.md the root, from three lines of code to the four facts
02-contract.md invariants, failure model, cooperation ladder, baselines
03-landscape.md who owns which layer; the positions we disagree with
04-integration.md the EffectTarget adapter; LangGraph / MCP / Temporal; the notary
05-reading.md what we read and what each paper hands you
07-runtime.md the Postgres runtime, and what it borrows from Temporal, DBOS and Restate
10-scenarios.md the eight live scenarios, per-scenario commands and outcomes
checkpoints/ what was submitted, what was scored, what changed
demo.py one refund, one crash, one receipt
Saturday we wrote two specs that looked like different projects: one refund, at most once, and concurrency control for parallel coding agents. Sunday morning we noticed they were the same mechanism pointed at two targets, built the core once, and ran both.