Your AI agent refunded a customer. Then it crashed.

support · case #4471

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.

We broke a $20 refund 10 ways. Here's who paid out wrong.

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

See all 10 faults
FaultPlain retryRequest IDsDurable workflowsInterlock
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$20refused
Permission pulled before the refund landsrefundedrefundedrefundedrefused
Order stops being eligiblerefundedrefundedrefundedrefused
Support refunds by hand during the outage$40$40$40$20, retry refused
Permission pulled during the outagerefundedrefundedrefundedrefused
Retry after the 24-hour ID window$40$40$40$20, found by lookup

Two systems. Two different answers.

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.

example · your records
orderrefundstatus
order_880$45.00refunded
order_881$20.00pending
order_882$12.50paid
they disagree
example · payment service
refundamountstatus
re_1a7...$45.00succeeded
re_1b2...$20.00succeeded
re_1c9...$9.99succeeded

Order 881 is out of sync

What should the agent do?

Try the refund againCould pay the customer twice
Do nothingCustomer may never get paid
Ask the payment serviceOnly works if the service can answer

The standard fix handles a crash. Not this.

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

Decides to refund $20

Assumes: nothing has been refunded yet

0:02 · Agent

Crashes before the refund is sent

Refund state: unknown

0:40 · Support rep

Refunds the $20 by hand

A different request with a different ID

1:10 · Agent is back

Retries its own refund

The agent's ID was never used, so the refund goes through

The payment service accepts the retry

$40 refunded on a $20 refund.

The ID only blocks the exact same request. Nothing asked whether "nothing has been refunded yet" was still true.

Interlock writes down why. Then checks again.

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.

Pick what goes wrong. Watch the refund come out right.

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.

Your code
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)},
allowed=lambda order, amount: can_refund(order),
lookup=lambda order, amount, idempotency_key: refund_exists(idempotency_key),
dedupes=True)
def refund_money(order, amount, idempotency_key):
return stripe.Refund.create(charge=charge_for(order), amount=amount,
idempotency_key=idempotency_key)
gate.recover() # once, on startup
Console

# Press Play to replay the recorded run.

order 881refund-agent + interlockReady
    0.00s / 9.00s

    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.

    Same crash. Same person stepping in. One refund.

    Without Interlock

    Refunds on order 881

    • Support refunds by hand$20.00Sent
    • Agent restarts and retries
    • Agent refunds again$20.00Sent
    2 refunds · $40.00Paid twice

    With Interlock

    Refunds on order 881

    • Support refunds by hand$20.00Sent
    • Agent restarts and re-checks the facts
    • A fact changed, so the retry is blocked
    1 refund · $20.00Correct

    Every action gets a receipt. Anyone can check it.

    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.

    interlock-verify receipt.json
    • Happened onceOne $20 refund on order 881, never sent twice
    • Allowed when it firedRefund permission checked right before the send, not only when the agent decided
    • Still made sense"Nothing refunded yet" checked again right before the send
    • Nothing editedEvery step is chained to the step before it
    • Both sides agreeCan be signed with a key the payment service also holds

    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.

    Fewer approvals. Zero wrong refunds.

    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.

    By hand today

    $15 to $16

    to handle one invoice by hand, most of it people's time.

    Levvel Research benchmark, labor share (62%) from APQC, as compiled by Resolve

    With agents, right now

    Under 15%

    of 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 experience
    23%

    say it would be unclear who is accountable for a serious AI error.

    Same Avalara survey
    95.5%

    of 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 government

    Your agents today

    Assumptions

    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.

    Every action approved$2,083,333

    about 21 full-time reviewers

    With Interlock, 36% still reviewed$750,000

    about 7.5 full-time reviewers

    One approval costs $4.17. A receipt takes no reviewer time.

    9:00 · Manager

    Approves a $120 refund

    Sees: nothing refunded yet

    11:40 · Support rep

    Refunds the $120 by hand

    The customer called in

    15:00 · Agent

    Sends the approved refund

    Interlock checks the facts the manager saw

    Refused and sent back to the manager

    The approval went stale. No second refund.

    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.

    We ran 578 live tests against our competition and today's standard setups.

    Their default setups paid twice when support refunded by hand during the outage. Same $20 refund, real Stripe, a real crash.

    Venn diagram of three circles. Picks up after a crash: Temporal, DBOS, Restate, LangGraph checkpoints, OpenAI Agents SDK resume, Google ADK resumability. Won't send the same request twice: Stripe idempotency keys, Stripe ACP. Checks permission before acting: Google ADK confirmation, Salus, HumanLayer, MCP gateways, AP2 mandates. Framework plus idempotency key held a plain crash but paid $40 when support refunded by hand during the outage. ADK resume plus confirmation stops the replay only if the approver's no arrives. Interlock sits in the center: it re-checks at the send and after a crash, and leaves a receipt.
    What each tool checks by default. Closed products and protocols are placed from their public docs, not run.

    How the same failure plays out

    Support refunds by hand while the agent is down

    Went wrong: Temporal, DBOS, LangGraph, OpenAI Agents SDK, open-multi-agent, Google ADK.

    1. Agent decides a $20 refund
    2. Worker crashes before sending
    3. Support refunds $20 by hand
    4. Worker restarts

    Temporal

    1. Temporal retries the activity
    2. Nothing checks that the payment changed
    3. $40 refunded

    With Interlock

    1. Journal shows a refund decided, never confirmed
    2. Re-checks the payment: $20 already refunded
    3. $20 refunded, resend refused

    Where each setup breaks, and what Interlock changes

    Green held. Red paid or sent money it shouldn't have.

    What went wrongTemporalDBOSLangGraphOpenAI Agents SDKopen-multi-agentGoogle ADKInterlock
    Reply lost after Stripe took the refundHeldHeldHeldHeld with a keyHeldHeld with a keyHeld
    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 outageSent $20Sent $20 unless cancelledHeldHeldHeld with its run storeSent $20Held, $0
    Two approvals race one $30 capNot runHeld, with a cap transactionHeld$40 in 10 of 10$40 in 5 of 5$40 in 12 of 18Held with a cap journal; plain gate 25 of 40
    Same framework, Interlock insideHeld rows 1 to 3Held rows 1 to 3Held rows 1 to 4Held rows 1 to 3; row 4 with a cap journalNot runHeld rows 1 to 3, with an AP2 mandaten/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.

    Add Interlock. Keep everything else.

    Wrap the calls that have real effects. Your model, prompts and planning stay as they are.

    Add Interlock
    shell
    pip install git+https://github.com/az-said/Interlock
    refund_agent.py
    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

    Install with your AI

    Paste into Claude Code, Cursor, Codex or Copilot. It wraps your side effects, adds a crash test, and reports what it skipped.

    Read it

    The fair questions.

    We already use Stripe's request IDs. Aren't we covered?

    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.

    Do we need Temporal? We already run 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.

    Does Interlock replace the people who approve refunds?

    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.

    Couldn't we write this check ourselves?

    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.

    How much does it slow things down?

    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.

    What can't Interlock do?

    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.

    Let your agent touch real money. Once.

    Run the crash demo in one command, then read every result the demo produces.

    Get Interlock

    python3 demo.py 2 · no dependencies · MIT licensed