Interlock

Scenario shared_cap: two agents, one $30 approval cap, real Stripe, real SIGKILL

Generated 2026-09-13 23:06 UTC by experiments/scenario_shared_cap.py. Model claude-haiku-4-5-20251001, Stripe test mode, 20 runs per cell, 4 runs at a time (each run has its own PaymentIntent, run directory and journal).

Each run: a new $100 test card payment. Case #4471 approves at most $30 of refunds in total. A support-bot process and a billing-bot process start behind one barrier; each asks the model for its refund from its own ticket (both tickets call for $20) and sends it through the system under test. The first bot to reach the crash point is SIGKILLed by its own process (before_send: right before the refund POST; after_commit: after Stripe’s response arrived, before anything recorded it). The harness restarts that bot immediately while the other bot carries on. Ground truth is Stripe’s refund list for the payment, read after both processes exit. Invariant: total refunded <= $30, so at most one of the two $20 refunds may land.

crash point no_check (stable idempotency key, retry on restart) hand_check (read Stripe refunds, then send; no shared state) hand_lock (hand_check inside an flock on the shared dir) interlock_core (unmodified Gate, headroom premise) interlock + CapJournal (scenario subclass, cap reserved at dispatch)
after_commit held 0/20; Stripe: 20x $40 in 2; crash in 20/20; median 1.1s crash to settled; provable 0/20 held 3/20; Stripe: 3x $20 in 1, 17x $40 in 2; crash in 20/20; median 1.1s crash to settled; provable 0/20 held 20/20; Stripe: 20x $20 in 1; crash in 20/20; median 0.5s crash to settled; provable 0/20 held 5/20; Stripe: 5x $20 in 1, 15x $40 in 2; crash in 20/20; median 40.1s crash to settled; provable 20/20 held 20/20; Stripe: 20x $20 in 1; crash in 20/20; median 40.1s crash to settled; provable 20/20
before_send held 0/20; Stripe: 20x $40 in 2; crash in 20/20; median 2.3s crash to settled; provable 0/20 held 1/20; Stripe: 1x $20 in 1, 19x $40 in 2; crash in 20/20; median 2.3s crash to settled; provable 0/20 held 20/20; Stripe: 20x $20 in 1; crash in 20/20; median 1.4s crash to settled; provable 0/20 held 20/20; Stripe: 20x $20 in 1; crash in 20/20; median 41.0s crash to settled; provable 20/20 held 20/20; Stripe: 20x $20 in 1; crash in 20/20; median 41.7s crash to settled; provable 20/20

“held” is the invariant from Stripe’s refund list. “crash to settled” runs from the harness seeing the SIGKILLed process exit to the last bot process exiting. “provable” means the system left a record naming who did what and which checks ran, and that record agrees with Stripe (see below).

Reading it

Every number in this section is computed from the runs by the harness.

Held on every run: hand_lock, interlock. Per system, across both crash points:

Why the check-then-send systems broke, measured from each run’s timeline (runs where both $20 refunds landed):

Verdict against the strongest hand-written arm:

Limits that apply to every arm:

What each bot reported (crashed bot / other bot)

The race window, measured

The five systems

All five run as two OS processes that share one run directory on one host.

Proposed core change

The cap reservation lives in a scenario subclass because interlock/journal.py and interlock/gate.py were not edited, and interlock_core above shows the core alone does not enforce a cross-effect cap. Proposed for core: Journal.dispatch(..., reserve=fn), where fn(entries, effect) runs inside the same lock or transaction as the DISPATCHED write and returns a refusal reason or None, plus a REFUSED:<reason> status from Gate.submit for that blocker (today an unknown blocker is reported as IN_FLIGHT, which CapGate.submit has to translate). A budget over journaled effects (reserved_cents in cap.py) is then about fifteen lines for any shared limit: a refund cap, a spend limit per agent, a quota of emails per case.

Its limit: the reservation only sees effects that go through this journal. A refund issued outside it (a person in the dashboard) is seen by the premise re-check, which reads Stripe, and that read still has a check-then-send gap against people. It closes the race between agents that share the journal, not against the rest of the world.

What is real, what is emulated

Ids, for checking in the Stripe test dashboard

Re-run

python3 experiments/scenario_shared_cap.py                  # writes results/scenarios/shared_cap.json and .md
python3 experiments/scenario_shared_cap.py --from-json       # rewrite the .md from the JSON
python3 -m unittest tests.test_scenario_shared_cap          # offline logic