Interlock

Results: a Google ADK agent under an AP2 mandate, Stripe test mode, real crashes

Generated 2026-09-14 00:00 UTC by experiments/adk_live.py. Model anthropic/claude-haiku-4-5-20251001 through ADK’s LiteLlm, google-adk 2.9.0, AP2 SDK at commit e1ea56d, Stripe test mode.

Each cell is one support case: a new $100 test card payment by a new Stripe customer, and Finance’s approval of one $20 refund, issued as an AP2 open Payment Mandate signed with a Finance key (cap 2000 USD cents, payee that customer, instrument that card, closable only by the agent’s key). A real ADK LlmAgent with a SQLite session store and resumability on reads the payment (get_payment), closes and verifies the mandate for the amount it chose (authorize_refund), then sends (issue_refund). The agent process SIGKILLs itself inside the send. The harness acts during the outage, starts a new agent process, and that process resumes the ADK invocation; ADK replays the unanswered issue_refund call with the same function call id. Totals and refund counts are Stripe’s refund list for the PaymentIntent, re-read at the end. “Answer” is the last issue_refund response the agent got, checked against the refunds carrying this case’s metadata. A cell whose crash did not land where its scenario says (the first process got issue_refund’s answer, or the Interlock journal settled the effect before the crash) is marked CRASH WINDOW MISSED and is not valid evidence.

scenario ADK, tool sends with no idempotency key ADK, Idempotency-Key = invocation id / function call id ADK with that key plus a hand-written before_tool_callback re-check ADK with Interlock’s Guard as the before_tool_callback
crash_after_commit REFUNDED; $40 in 2 refunds (want $20 in 1); 7.6s crash to done; VIOLATED, $20 too much; answer CONTRADICTS Stripe REPLAYED_BY_STRIPE; $20 in 1 refund (want $20 in 1); 7.3s crash to done; held; answer matches Stripe FOUND_BY_LOOKUP; $20 in 1 refund (want $20 in 1); 7.9s crash to done; held; answer matches Stripe COMMITTED_BY_RETRY; $20 in 1 refund (want $20 in 1); 45.1s crash to done; held; answer matches Stripe
hand_refund_during_outage n/a REFUNDED; $40 in 2 refunds (want $20 in 1); 9.8s crash to done; VIOLATED, $20 too much; answer matches Stripe REFUSED:stale_premise; $20 in 1 refund (want $20 in 1); 9.7s crash to done; held; answer matches Stripe REFUSED:stale_premise_at_recovery; $20 in 1 refund (want $20 in 1); 45.7s crash to done; held; answer matches Stripe
mandate_revoked_during_outage n/a REFUNDED; $20 in 1 refund (want $0 in 0); 7.8s crash to done; VIOLATED, $20 too much; answer matches Stripe REFUSED:mandate; $0 in 0 refunds (want $0 in 0); 10.4s crash to done; held; answer matches Stripe REFUSED:lease_at_recovery; $0 in 0 refunds (want $0 in 0); 46.3s crash to done; held; answer matches Stripe
mandate_expired_during_outage n/a REFUNDED; $20 in 1 refund (want $0 in 0); 88.9s crash to done; VIOLATED, $20 too much; answer matches Stripe REFUSED:mandate; $0 in 0 refunds (want $0 in 0); 90.3s crash to done; held; answer matches Stripe REFUSED:lease; $0 in 0 refunds (want $0 in 0); 89.9s crash to done; held; answer matches Stripe
mandate_revoked_after_commit n/a REPLAYED_BY_STRIPE; $20 in 1 refund (want $20 in 1); 6.6s crash to done; held; answer matches Stripe FOUND_BY_LOOKUP; $20 in 1 refund (want $20 in 1); 6.9s crash to done; held; answer matches Stripe COMMITTED_ON_QUERY; $20 in 1 refund (want $20 in 1); 43.7s crash to done; held; answer matches Stripe

Scenarios

The columns

In every column the mandate is verified once when the model decides (authorize_refund), as AP2’s credential provider step does, and that answer is part of the ADK session, so the replay does not repeat it.

What AP2 verification covers here

Cryptographic, by the AP2 SDK against the Finance public key (looked up by kid, never supplied by the agent): the open mandate’s signature, the closed mandate’s signature by the agent key named in cnf, the sd_hash binding between them, aud and nonce on the closed hop, and exp/iat with zero clock skew. AP2’s constraint evaluator then checks the amount range, the payee and the instrument against the closed mandate.

Not cryptographic: revocation is a row in mandates.db that Finance writes (AP2 v0.2 has no revocation). That the Stripe refund matches the mandate is a comparison: with the effect’s fields, which the tool reads from Stripe (customer and payment method of the PaymentIntent), not from the model, and, on every check, with the PaymentIntent the refund is actually sent to, read from Stripe again by stripe_payment() (its id, customer, card and currency must be the mandate’s transaction, payee, instrument and currency). transaction_id is the PaymentIntent id here; in AP2 it is the hash of a merchant-signed checkout. AP2 v0.2 has no refund mandate: expressing Finance’s refund approval as a Payment Mandate from the merchant to the customer is this demo’s convention, and no AP2 party here authorizes a Stripe refund as such.

What is real

Not verified live

Limits of what this shows

Model decisions

Ids, for checking in the Stripe test dashboard

Re-run

ANTHROPIC_API_KEY=... uv run --no-project --python 3.13 --with google-adk==2.9.0 --with litellm \
    --with "ap2 @ git+https://github.com/google-agentic-commerce/AP2@e1ea56d" python experiments/adk_live.py