Interlock

Scenario: double booking on Google Calendar

Generated 2026-09-13 22:58 UTC by experiments/scenario_calendar.py. Model claude-haiku-4-5-20251001, Google Calendar API v3, the sandbox service account’s own primary calendar (interlock-sandbox-cal@gen-lang-client-0277439345.iam.gserviceaccount.com), no attendees, no delegation.

Each cell is one booking request: “a 30-minute call at ". A worker process (its own OS process) reads the request, asks the model to book it (the model calls `book_call`; its start and duration are checked against the request; the decision is persisted and a restarted job reuses it, so a job is decided once), and sends the insert with one of the systems. The worker SIGKILLs itself right before the insert (`before_send`) or right after Calendar's response arrives (`after_commit`). During the outage the harness does nothing, puts a real customer event on the slot (`slot_busy`), or cancels the booking request (`request_cancelled`). In `pre_busy` the customer event is on the slot before the job is decided; the crash is armed at `before_send`, but a system that refuses never gets there, so its first worker exits 0 instead (marked "no crash" below). A new worker process then runs the same job. Ground truth is `events.list` on the slot, read after the second worker exits.

Invariant: the request’s agent events on the calendar equal “want”, and no agent event was put on top of an event that was already there. Want is 1 after a commit (the booking landed while the slot was free and the request live; what to do about a later conflict or cancellation is a separate step), 1 before a send with no outage change, and 0 when the slot was taken (before the decision, or during the outage before the send) or the request cancelled.

fault no_check hand_check interlock interlock_change_only
after_commit/none ALREADY_EXISTS_409; 1 agent event (want 1); held; answer matches; 0.6s; proof: none FOUND_BY_LOOKUP; 1 agent event (want 1); held; answer matches; 0.4s; proof: none COMMITTED_BY_RETRY; 1 agent event (want 1); held; answer matches; 31.5s; proof: receipt n/a
after_commit/slot_busy ALREADY_EXISTS_409; 1 agent event (want 1); held; answer matches; 1.2s; proof: none FOUND_BY_LOOKUP; 1 agent event (want 1); held; answer matches; 1.0s; proof: none COMMITTED_ON_QUERY; 1 agent event (want 1); held; answer matches; 30.7s; proof: receipt n/a
after_commit/request_cancelled ALREADY_EXISTS_409; 1 agent event (want 1); held; answer matches; 0.6s; proof: none FOUND_BY_LOOKUP; 1 agent event (want 1); held; answer matches; 0.3s; proof: none COMMITTED_ON_QUERY; 1 agent event (want 1); held; answer matches; 31.1s; proof: receipt n/a
before_send/none BOOKED; 1 agent event (want 1); held; answer matches; 0.5s; proof: none BOOKED; 1 agent event (want 1); held; answer matches; 0.8s; proof: none COMMITTED_BY_RETRY; 1 agent event (want 1); held; answer matches; 31.2s; proof: receipt n/a
before_send/slot_busy BOOKED; 1 agent event (want 0); VIOLATED, double booked; answer matches; 1.0s; proof: none REFUSED:slot_busy; 0 agent events (want 0); held; answer matches; 1.2s; proof: none REFUSED:stale_premise_at_recovery; 0 agent events (want 0); held; answer matches; 32.1s; proof: receipt n/a
before_send/request_cancelled BOOKED; 1 agent event (want 0); VIOLATED; answer matches; 0.5s; proof: none REFUSED:request_cancelled; 0 agent events (want 0); held; answer matches; 0.4s; proof: none REFUSED:lease_at_recovery; 0 agent events (want 0); held; answer matches; 31.2s; proof: receipt n/a
before_send/pre_busy REFUSED:slot_busy; 0 agent events (want 0); held; answer matches; 0.3s; proof: none; no crash (refused before the send) REFUSED:slot_busy; 0 agent events (want 0); held; answer matches; 0.6s; proof: none; no crash (refused before the send) REFUSED:stale_premise; 0 agent events (want 0); held; answer matches; 0.8s; proof: receipt; no crash (refused before the send) COMMITTED_BY_RETRY; 1 agent event (want 0); VIOLATED, double booked; answer matches; 31.5s; proof: receipt

Verdict. hand_check and interlock differ on no row in outcome or answer. hand_check TIES interlock on every calendar outcome here, pre_busy included. That tie depends on configuring interlock’s slot premise to require an empty slot, which interlock.easy cannot express (see “The systems”). Configured as easy.py ships it, where a premise only has to stay unchanged, interlock (interlock_change_only) went COMMITTED_BY_RETRY on before_send/pre_busy with the invariant VIOLATED, double booked: WORSE than hand_check and no_check there, both of which refused. no_check violated the invariant on before_send/slot_busy, before_send/request_cancelled: it checked the request and freebusy when the job was enqueued, and the queued send replayed after the crash without looking again. Beyond the calendar, interlock leaves a hash-chained receipt recording the request status and slot contents it checked before each send and again at recovery; hand_check records nothing about its checks (the event itself shows the creator and, through extendedProperties, the request and system, which is all no_check and hand_check can show afterwards). When its booking had already landed and the world then changed (after_commit/slot_busy, after_commit/request_cancelled), interlock reports booked and its receipt also records the failed re-check at recovery (slot now taken, or request cancelled), which tells whoever handles it next; hand_check reports booked and notes nothing. What the receipt proves is that the booking committed once, not which HTTP attempt created it: in after_commit/none the outcome is COMMITTED_BY_RETRY because recovery settled it by retrying, and the retry got Calendar’s 409, recorded in the COMMITTED result as {‘status’: ‘confirmed’, ‘event’: ‘6a91e74b2c24’, ‘created’: ‘2026-09-13T22:53:45.000Z’, ‘already_existed’: True}; the event was created by the SIGKILLed first worker. Interlock is slower to settle after a SIGKILL: a dead sender’s claim blocks recovery for CLAIM_TTL = 30s.

The systems

Proposed core change (not made): let gate.effect declare an expected value for a premise (for example expect={"other_events_in_slot": []}) that validate_premises enforces at dispatch and recovery alongside the unchanged-since-decided check, and have _FunctionTarget.apply and query record the function’s return value.

What is real

What is emulated

Evidence kept, and what was deleted

After the run the harness deleted every event it saw (25 of 25 deleted; each read back afterwards with status [‘cancelled’]), and the sandbox service account, with its calendar, is deleted after that (gcloud iam service-accounts delete). The events can no longer be read from Calendar. Ground truth for each cell therefore rests on what calendar.json stores, read during the run: the full events.list resources on the slot, the full hash-chained journal bundle for interlock cells (re-verify with python3 -m interlock.receipts on a cell’s evidence.receipt_bundle), the worker log, the crash marker, the result and request files, and the cleanup read-back under cleanup.

Limits

Timing

Seconds are from the harness seeing the first worker exit to the second worker exiting, including the outage action. Interlock adds the claim wait: the SIGKILLed sender cannot release its claim, so recover() retries until CLAIM_TTL passes.

LLM decisions

Ids

Re-run

env $(grep ^ANTHROPIC_API_KEY= /path/to/.env) python3 experiments/scenario_calendar.py
python3 -m unittest tests.test_scenario_calendar      # offline logic