Interlock

Results: refund agent, Temporal and Stripe, end to end with real crashes

Generated 2026-09-13 21:57 UTC by experiments/e2e_live.py. Model claude-haiku-4-5-20251001, temporalio 1.32.0, Temporal’s local dev server, Stripe test mode.

Each cell is one support case: a new $100 test card payment, one $20 refund approved by support (in the case text, and as the approval’s amount cap), a real LLM that reads the payment through a tool and decides the refund, and a Temporal workflow that runs the decision on a worker process. Mid-refund, the worker kills itself with SIGKILL. The harness then acts through the backend’s HTTP API (a hand refund, a revocation, or nothing), starts a new worker process, and lets Temporal’s retry policy finish the case. Totals and refund counts are Stripe’s own refund list, re-read at the end. “Want” comes from the scenario and the approved $20, never from the model’s output. “Answer” is what the workflow itself reports (sent, refused, or AMBIGUOUS), checked against the refunds carrying this case’s own metadata.

scenario Temporal: idempotency key, no re-check in the activity Temporal: idempotency key plus a hand-written re-check Temporal with Interlock as the activity body
crash_after_commit REPLAYED_BY_STRIPE; $20 in 1 refund (want $20 in 1); attempt 2, 15.2s crash to close; held; answer matches Stripe FOUND_BY_LOOKUP; $20 in 1 refund (want $20 in 1); attempt 2, 14.7s crash to close; held; answer matches Stripe COMMITTED_BY_RETRY; $20 in 1 refund (want $20 in 1); attempt 8, 44.0s crash to close; held; answer matches Stripe
hand_refund_before_decision REPLAYED_BY_STRIPE; $25 in 2 refunds (want $25 in 2); attempt 2, 15.1s crash to close; held; answer matches Stripe FOUND_BY_LOOKUP; $25 in 2 refunds (want $25 in 2); attempt 2, 14.7s crash to close; held; answer matches Stripe COMMITTED_BY_RETRY; $25 in 2 refunds (want $25 in 2); attempt 8, 42.8s crash to close; held; answer matches Stripe
hand_refund_during_outage REFUNDED; $40 in 2 refunds (want $20 in 1); attempt 2, 16.8s crash to close; VIOLATED, $20 too much; answer matches Stripe REFUSED:stale_premise; $20 in 1 refund (want $20 in 1); attempt 2, 16.0s crash to close; held; answer matches Stripe REFUSED:stale_premise_at_recovery; $20 in 1 refund (want $20 in 1); attempt 8, 43.6s crash to close; held; answer matches Stripe
unrelated_refund_during_outage REFUNDED; $25 in 2 refunds (want $25 in 2); attempt 2, 16.5s crash to close; held; answer matches Stripe REFUSED:stale_premise; $5 in 1 refund (want $25 in 2); attempt 2, 15.7s crash to close; SHORT by $20; answer matches Stripe REFUSED:stale_premise_at_recovery; $5 in 1 refund (want $25 in 2); attempt 8, 43.3s crash to close; SHORT by $20; answer matches Stripe
approval_revoked_during_outage REFUNDED; $20 in 1 refund (want $0 in 0); attempt 2, 16.8s crash to close; VIOLATED, $20 too much; answer matches Stripe REFUSED:lease; $0 in 0 refunds (want $0 in 0); attempt 2, 16.0s crash to close; held; answer matches Stripe REFUSED:lease_at_recovery; $0 in 0 refunds (want $0 in 0); attempt 8, 43.6s crash to close; held; answer matches Stripe
approval_revoked_after_commit REPLAYED_BY_STRIPE; $20 in 1 refund (want $20 in 1); attempt 2, 14.8s crash to close; held; answer matches Stripe FOUND_BY_LOOKUP; $20 in 1 refund (want $20 in 1); attempt 2, 14.8s crash to close; held; answer matches Stripe COMMITTED_ON_QUERY; $20 in 1 refund (want $20 in 1); attempt 8, 43.3s crash to close; held; answer matches Stripe
key_pruned_after_24h (EMULATED) REFUNDED; $40 in 2 refunds (want $20 in 1); attempt 2, 15.9s crash to close; VIOLATED, $20 too much; answer CONTRADICTS Stripe FOUND_BY_LOOKUP; $20 in 1 refund (want $20 in 1); attempt 2, 15.0s crash to close; held; answer matches Stripe COMMITTED_ON_QUERY; $20 in 1 refund (want $20 in 1); attempt 8, 43.1s crash to close; held; answer matches Stripe
no_lookup_after_24h (EMULATED) n/a n/a AMBIGUOUS; $20 in 1 refund (want $20 in 1); attempt 8, 42.1s crash to close; held; answer matches Stripe

Over the 7 rows every column ran (6 of them not emulated):

Read the tally with this next to it: on 7/7 of those rows the hand-written re-check left Stripe with the same refunds, and gave the same answer, as Interlock (rows that differ: none). Both beat the column that re-checks nothing. So the difference from a careful Temporal activity is not the outcome in these rows; it is that Interlock packages the re-check, the lookup after a crash and the claim once, instead of about ten hand-written lines per activity, and adds AMBIGUOUS and a receipt. Interlock is also slower after every crash; see “Timing” below. Not in the tally (run for Interlock only): no_lookup_after_24h / interlock: AMBIGUOUS; $20 in 1 refund (want $20 in 1); attempt 8, 42.1s crash to close; held; answer matches Stripe.

Scenarios

The three columns

What the pitch can claim from this run

Timing

From the crash to the workflow closing, the median Interlock cell took 43s against 16s and 15s for the two Temporal columns. A SIGKILLed sender cannot release its claim, so later attempts return IN_FLIGHT until the claim expires (CLAIM_TTL = 40s in backend/config.py, longer than the Stripe client’s 30s timeout so recovery never overlaps a send still in progress), and then one attempt recovers. That wait is the price of never having two workers send the same effect at the same time; the Temporal columns rely on Stripe’s idempotency key for that, which holds only inside the key window.

Limits of what this shows

What is real

What is emulated

Only the rows marked EMULATED. Nobody waited 24 hours.

key_pruned_after_24h: Stripe keeps an idempotency key for at least 24 hours and cannot be made to forget one on demand, and nobody waited a day. The columns do not get the same emulated input. All three: after the restart, each refund POST uses Idempotency-Key ‘/emulated-pruned', a key Stripe has never seen, which is how a pruned key looks to Stripe (INTERLOCK_EMULATE_24H=1). Interlock only: its gate also recovers with its clock moved 25h ahead (EmulatedClockGate in backend/workflows.py), standing in for the day that would really have passed. Interlock's result in this row depends on that clock, not on the key: the gate compares the clock with the timestamp of its DISPATCHED entry, finds it older than Stripe's 24h window, and looks the refund up instead of resending, so it never sends the pruned key. Given the pruned key without the moved clock, it would resend under the pruned key and create a second refund, as plain Temporal did (read from gate.py, not run: a key lost inside 24h is outside what Stripe documents). The hand-written re-check column needs no clock, because it looks up its own refund before every send; a Temporal activity could also compare activity.info().scheduled_time with now. The timing is emulated too: the retry came seconds after the crash. In this workflow a retry that late happens if no worker picks the task up for more than 24h (an attempt only starts, and only times out, once a worker takes it), or with a retry policy, schedule or reconciler that spans more than a day. What this row shows is that a key alone does not survive pruning and a lookup does.

no_lookup_after_24h: Emulated as in key_pruned_after_24h (pruned key, gate clock 25h ahead), and the Interlock target also declares that it cannot list refunds (INTERLOCK_NO_LOOKUP=1), as for a provider with no way to look up what it did. Stripe itself can list refunds. Run for Interlock only: it shows the gate saying AMBIGUOUS instead of guessing.

LLM decisions

Ids, for checking in the Stripe test dashboard

Re-run

ANTHROPIC_API_KEY=... uv run --no-project --with temporalio python experiments/e2e_live.py
python3 experiments/e2e_audit.py      # independent check of results/e2e_live.json against Stripe