0:00 · Agent
Decides to refund $20
Assumes: nothing has been refunded yet
example case · timings not measured
I paid $100 and support approved a $20 refund. Can you send it?
order_881 · paid $100 · approved refund $20
$20 refunded. Interlock looked the refund up, found it already landed, and sent nothing.
Crashes at every step, duplicate requests, a model that changes its mind, a person refunding by hand, permission pulled mid-flight. Same faults for every approach.
Wrong outcomes, out of 10 faults
Plain retryRe-run the step on restart
9
Request IDsThe standard fix
5
Durable workflowsTemporal-style, used as documented
5
InterlockSame faults, same service
0
| Fault | Plain retry | Request IDs | Durable workflows | Interlock |
|---|---|---|---|---|
| Crash before the refund is sent | $20 | $20 | $20 | $20 |
| Crash after sending, before the reply | $40 | $20 | $20 | $20 |
| Same request submitted twice | $40 | $20 | $20 | $20 |
| Model says $30 on the retry | $50 | $20 | $20 | $20, $30 refused |
| Same request, different amount | $50 | $20 | $20 | refused |
| Permission pulled before the refund lands | refunded | refunded | refunded | refused |
| Order stops being eligible | refunded | refunded | refunded | refused |
| Support refunds by hand during the outage | $40 | $40 | $40 | $20, retry refused |
| Permission pulled during the outage | refunded | refunded | refunded | refused |
| Retry after the 24-hour ID window | $40 | $40 | $40 | $20, found by lookup |
After the crash, your records say the refund is still pending. The payment service says it went through. The agent has to decide what to do next, and every option can go wrong.
| order | refund | status |
|---|---|---|
| order_880 | $45.00 | refunded |
| order_881 | $20.00 | pending |
| order_882 | $12.50 | paid |
| refund | amount | status |
|---|---|---|
| re_1a7... | $45.00 | succeeded |
| re_1b2... | $20.00 | succeeded |
| re_1c9... | $9.99 | succeeded |
What should the agent do?
Payment services let you tag a request with an ID, so a repeat of the same request is ignored. That works until a person steps in while the agent is down. An example timeline:
0:00 · Agent
Assumes: nothing has been refunded yet
0:02 · Agent
Refund state: unknown
0:40 · Support rep
A different request with a different ID
1:10 · Agent is back
The agent's ID was never used, so the refund goes through
The payment service accepts the retry
The ID only blocks the exact same request. Nothing asked whether "nothing has been refunded yet" was still true.
Before the agent acts, Interlock saves the facts the decision depends on. After a crash, Interlock checks those facts again before anything is sent a second time. The trace below is an illustration; its timings are not measurements.
Second refund blocked · $20 total
01 · Before the call
The facts behind the decision are written to a log on disk. For this refund: the order is eligible, and nothing has been refunded yet.
02 · After a crash
Those facts are checked again, along with whether the agent is still allowed to act. If any of them changed, nothing is sent.
03 · When Interlock can't tell
Interlock uses the best check the service offers: repeat safely, or look the refund up. If the service offers neither, Interlock says so and hands the case to a person instead of guessing.
Choose a fault and press play. Every journal entry on the right comes from the recorded test run, slowed down so you can follow it.
# Press Play to replay the recorded run.
Plain retry
Request IDs
Interlock
Amounts refunded on a $20 refund. Journal entries and totals are from experiments/refund_agent.py, the same run as the table above.
Refunds on order 881
Refunds on order 881
Not a log you have to take on trust. Each step is chained to the one before, so changing an amount or deleting a step breaks the chain. Hand the receipt to the payment service or an auditor and they can confirm the same story.
After a crash nobody can resolve
The receipt says unknown instead of guessing, and the case goes to a person.
If someone edits the record
Change $20 to $2,000 anywhere in the receipt and the check fails.
Checked on real services
Three of the faults ran against Stripe in test mode and four against a real Temporal server, with the same results.
When a person approves every agent action, that person becomes the bottleneck. Most of what they check is mechanical: is the order still eligible, was it already refunded, is this still allowed. Interlock checks exactly that at the moment of sending, so people only review what needs judgment. On a synthetic day of 100 refund requests (the mix is an assumption), rules plus Interlock took reviews from 100 to 36 with zero wrong refunds.
to handle one invoice by hand, most of it people's time.
Levvel Research benchmark, labor share (62%) from APQC, as compiled by Resolveof organizations let AI act on its own in six of seven processes. The usual setup: AI recommends, a person approves.
Avalara survey of 1,500+ finance leaders with agentic AI experiencesay it would be unclear who is accountable for a serious AI error.
Same Avalara surveyof organizations added a safeguard after an AI agent incident, most often a person in the loop.
AvePoint, State of AI 2026: 750 IT leaders in financial services, healthcare and governmentYour agents today
The 36% comes from a synthetic day of 100 refund requests (results/approval_inbox.md), so change it if your mix differs. A full-time reviewer is 8 hours of approving a day. Labor only; recovered duplicate payments are not counted. The same math, with every assumption as a flag: python3 experiments/approval_cost.py
What that costs a year
Save $1,333,333 a year
That's 13 full-time reviewers' worth of time.
One approval costs $4.17. A receipt takes no reviewer time.
9:00 · Manager
Sees: nothing refunded yet
11:40 · Support rep
The customer called in
15:00 · Agent
Interlock checks the facts the manager saw
Refused and sent back to the manager
The refund returns to the queue saying what changed since it was approved. Approvals can also expire, and stop working the moment the approver loses access.
Their default setups paid twice when support refunded by hand during the outage. Same $20 refund, real Stripe, a real crash.
Went wrong: Temporal, DBOS, LangGraph, OpenAI Agents SDK, open-multi-agent, Google ADK.
Temporal
With Interlock
Green held. Red paid or sent money it shouldn't have.
| What went wrong | Temporal | DBOS | LangGraph | OpenAI Agents SDK | open-multi-agent | Google ADK | Interlock |
|---|---|---|---|---|---|---|---|
| Reply lost after Stripe took the refund | Held | Held | Held | Held with a key | Held | Held with a key | Held |
| Support refunded by hand during the outage | $40 | $40 in 3 of 3 | $40 in 3 of 3 | $40 in 3 of 3 | $40 in 2 of 2 | $40 in 2 of 2 | $20 |
| Approval revoked during the outage | Sent $20 | Sent $20 unless cancelled | Held | Held | Held with its run store | Sent $20 | Held, $0 |
| Two approvals race one $30 cap | Not run | Held, with a cap transaction | Held | $40 in 10 of 10 | $40 in 5 of 5 | $40 in 12 of 18 | Held with a cap journal; plain gate 25 of 40 |
| Same framework, Interlock inside | Held rows 1 to 3 | Held rows 1 to 3 | Held rows 1 to 4 | Held rows 1 to 3; row 4 with a cap journal | Not run | Held rows 1 to 3, with an AP2 mandate | n/a |
"With a key": without an idempotency key, the same resume paid $40. A hand-written check of 6 to 12 added lines also held rows 1 to 3 wherever it was added, and every framework settled faster than Interlock inside it. Google ADK with Interlock used an AP2 mandate as the approval, not ADK's own confirmation. Not shown: an unrelated $5 refund during an outage, where Interlock refused the approved $20 and Temporal alone was right.
So what's Interlock for? A hand-written check ties us, but every money path needs one. Interlock does it by default, with a receipt.
Wrap the calls that have real effects. Your model, prompts and planning stay as they are.
pip install git+https://github.com/az-said/Interlock
from interlock import Interlock
gate = Interlock(".interlock")
@gate.effect(key=lambda order, amount: f"refund:{order}",
premises=lambda order, amount, idempotency_key: {
"eligible": is_eligible(order),
"refunded_by_others": refunded_total(order, excluding=idempotency_key)},
dedupes=True)
def refund(order, amount, idempotency_key):
return stripe.Refund.create(charge=charge_for(order), amount=amount, idempotency_key=idempotency_key)
gate.recover() # once, on startup
from interlock.temporal import gated
@activity.defn
def refund(order: str, amount: int) -> str:
# refund_money is the @gate.effect function from the Python tab
return gated(refund_money.gate, refund_money.proposal(order, amount))
python3 -m interlock.mcp_proxy --config interlock.mcp.json -- python3 payments_server.py
{"tools": {"create_refund": {
"key": ["order_id"],
"premises": {"tool": "get_order", "arguments": {"order_id": "order_id"}, "fields": ["refunded_total"]},
"lookup": {"tool": "find_refund", "arguments": {"reference": "$effect_id"}, "found": "found"},
"idempotency_argument": "reference"}}}
# OpenAI or Anthropic tool loops; config is the same as interlock.mcp.json
from interlock.tools import protect
tools = protect({"get_order": get_order, "create_refund": create_refund, "find_refund": find_refund}, config)
tools.recover() # once, on startup
out = tools["create_refund"](order_id="881", amount=20) # hand out["message"] back to the model
# LangChain or LangGraph: BaseTool in, BaseTool out
from interlock.langchain_tools import protect_tools
tools = protect_tools([get_order, create_refund, find_refund], config)
tools.recover()
from interlock.integrations.adk import Guard
guard = Guard(".interlock/adk", leases)
guard.gate("issue_refund",
target_for=lambda effect: refunds, # an EffectTarget: capture, validate_premises, apply, query
# ctx.state["premises"] is set when the agent decides (the tool that reads the order), not at send time
proposal=lambda args, ctx: {"lease": "case-4471", "request_id": "case-4471",
"premises": ctx.state["premises"],
"effect": {"order": args["order_id"], "amount": args["amount"]}})
agent = LlmAgent(name="support", model="gemini-2.5-flash", tools=[issue_refund],
before_tool_callback=guard.before_tool_callback)
guard.recover() # once, when the process starts
Paste into Claude Code, Cursor, Codex or Copilot. It wraps your side effects, adds a crash test, and reports what it skipped.
Install Interlock (https://github.com/az-said/Interlock) into this codebase. Interlock journals each external side effect before it is sent, re-checks the facts the decision relied on right before the send and again after a crash, and recovers by what the target service supports. Work in small, reviewable steps.
1. Detect the stack and the orchestrator: plain Python, Temporal, MCP servers, OpenAI or Anthropic tool-calling loops, LangChain or LangGraph, or Google ADK. Interlock is Python; if an effect is sent from another language, report it and skip it.
2. Find every tool call or function with an external side effect: payments, refunds, payouts, emails, messages, merges, deploys, writes to other systems. Skip pure reads.
3. For each effect, decide and write down:
- Effect key: built from the approved request (order id, case id, ticket id). Never from model output such as an amount or text the model produced.
- Premises: the facts the decision relies on (still eligible, already refunded, still approved) and the exact call that re-reads each one from the system of record. A premise that would count this effect's own result takes idempotency_key and excludes it.
- Tier: 1 if the service dedupes on a key you pass (dedupes=True), 2 if you can look up whether it already happened (lookup=), 3 if neither.
4. Install with: pip install git+https://github.com/az-said/Interlock
Wrap each effect with the matching integration:
- Plain Python: gate = Interlock(".interlock") and @gate.effect(key=..., premises=..., dedupes=True or lookup=...).
- Temporal: the activity body returns interlock.temporal.gated(fn.gate, fn.proposal(...)).
- MCP servers: python3 -m interlock.mcp_proxy --config interlock.mcp.json -- <server command>, with key, premises, lookup and idempotency_argument per gated tool.
- OpenAI or Anthropic tool loops: interlock.tools.protect(tools, config); return out["message"] to the model.
- LangChain or LangGraph: interlock.langchain_tools.protect_tools(tools, config).
- Google ADK: interlock.integrations.adk.Guard, passed as before_tool_callback.
Do not change prompts, models or planning code.
5. Call recover once on startup, before new work: gate.recover(), tools.recover() or guard.recover().
6. Add a test per effect that crashes after the send and asserts exactly one effect. For a decorated function: fn.gate.submit(fn.proposal(*args), crash_after_effect=True) raises SimulatedCrash; then simulate the restart: create a new Interlock on the same directory, decorate the same function on it again, call its recover() and check the status (AMBIGUOUS for tier 3), and assert the target recorded one effect.
7. Never claim exactly-once for a tier 3 service. After a crash it can end AMBIGUOUS, and that case goes to a person.
8. Run the existing tests and the new ones.
Finish with a report: each effect you wrapped (file, function, key, premises, tier, integration); each effect you could not wrap, and why; which tier 3 effects can end AMBIGUOUS; and the test results.
A link card for your docs or blog.
<a href="LIVE_DEMO_URL" style="display:block;max-width:420px;padding:18px 22px;border-radius:16px;border:1px solid rgba(255,255,255,.12);background:#0c0e12;color:#e8eaee;font:15px/1.5 Arial,sans-serif;text-decoration:none"> <b style="display:block;font-size:17px">Interlock live demo</b> <span style="color:rgba(232,234,238,.7)">One refund case, a worker killed mid-send, with and without Interlock.</span> <span style="display:block;margin-top:8px;color:#a8f0f6;font-weight:700">Try it live →</span> </a>
For a plain crash or a double click, yes, and you should keep using them. They can't help when something changes while your agent is down, like a person refunding by hand or a permission being pulled. The ID only knows the request, not the reason behind it.
You don't need it. Interlock works in plain Python, or in front of the MCP server your agent already uses. If you run Temporal, keep it: it picks up where a crash left off, and Interlock can be the step that sends the refund, checking whether the reason still holds. On a real Temporal server, a hand refund during an outage became $40 with Temporal alone and $20 with Interlock inside the step.
Only the mechanical part of their work. Checking that an order is still eligible, that nothing was already refunded, and that the agent is still allowed happens automatically, right before every send. Deciding whether a $400 refund for a flagged customer is fair stays with a person. When a person does approve, Interlock checks the facts they saw again at the moment the refund goes out, so a stale approval can't pay out twice.
Yes, and on money it would tie. In our live runs, a careful hand-written check left Stripe with the same refunds as Interlock in every case. Interlock is that check written once instead of in every step, plus a claim so two workers don't both send while the service answers within its timeout, an honest "can't tell" when nobody can know, and a receipt that proves what happened.
An ordinary send adds a journal write and one more read of the facts. Recovery is slower: in our live runs the median from crash to a closed case was 43 seconds with Interlock, against 15 to 16 seconds without, because Interlock waits out any send still in flight before it decides.
Interlock can't tell you whether the agent's decision was a good one. A carefully executed bad refund is still a bad refund. When a service gives no way to confirm what happened, Interlock stops and flags the case for a person instead.
Run the crash demo in one command, then read every result the demo produces.
python3 demo.py 2 · no dependencies · MIT licensed