Preventing AP2 Open-Mandate Overuse with Runtime Idempotency
An autonomous shopping agent holds a signed open mandate. The first checkout succeeds. Then the orchestrator restarts mid-flow, the user clicks "try again," or a parallel worker re-runs the flow against the same mandate.
Both attempts can produce a fresh transaction_id. Both can carry a valid mandate. Both can reach the PSP as valid payment attempts.
The result is two pending authorizations against one user intent.
The signature does what it is supposed to do — proves the mandate is valid. It does not prove the mandate has not already been exercised. Consumption is runtime state, and runtime state needs a separate gate.
Google's Agent Payments Protocol (AP2) calls this out directly in specification §6:
"A shopping agent must avoid presenting subsequent open mandates without a rejection receipt to prevent multiple checkouts using the same open mandate."
To be clear about scope: I am not arguing this belongs inside AP2 verification itself. I am arguing AP2 builders need a runtime gate beside it. This post is about what "must avoid" looks like as actual infrastructure — and why the answer is an idempotency key, keyed correctly.
Why open-mandate overuse is a runtime problem
A signed open mandate is evidence that the user intended a payment. It is silent on whether the intent has already been exercised. The same retry-amplification dynamics that drive retry storms in agent budget systems apply unchanged here — SDK retries, framework retries, durable-execution replays, user-initiated retries — except the failure mode shifts from "agent burned $4 of tokens" to "agent ran the customer's card twice."
The defense has the same shape as any other runtime authority decision: a single atomic gate that runs before the side effect, sees every concurrent attempt, and decides which one is allowed to proceed. The question is what to key that gate on.
Keying the lock on open_mandate_hash, not transaction_id
For this AP2 §6 risk, the wrong default is transaction_id. Two checkouts derived from one open mandate can naturally produce two distinct transaction IDs. That is the identity of a payment attempt, not the boundary of the mandate's use. Keying idempotency on transaction_id lets the §6 risk through unchanged.
The right default is open_mandate_hash when one is present. Both attempts then collide on the same (tenant, endpoint, idempotency_key) tuple. The server's reserve dedup sees them as the same bucket. Cycles' idempotency contract follows the broad shape familiar from payment-system idempotency:
- Same key, identical payload → server replays the original reservation.
- Same key, divergent payload (different
transaction_id, differentcheckout_hash, different amount) → server rejects with409 IDEMPOTENCY_MISMATCH.
Either way, no second valid reservation. That is the §6 consume-once defense, expressed in one rule.
The scope is embedded in the key so the two buckets never collide on a pathological input:
| Mandate carries | Key shape |
|---|---|
open_mandate_hash (human-not-present) | ap2:open_mandate:{sha256(open_mandate_hash)[:32]}:reserve |
only transaction_id (human-present / no open mandate) | ap2:tx:{sha256(transaction_id)[:32]}:reserve |
runcycles-ap2 picks the scope automatically. The raw transaction_id and open_mandate_hash still ride on Subject.dimensions for audit; only the idempotency key uses the hash.
In code, the integration is one context manager:
with cycles_guard_payment(
client,
mandate=mandate,
run_id=run_id,
tenant="acme",
agent="checkout-bot",
) as guard:
psp_receipt = psp.charge(mandate)
guard.attach_receipt_fields(psp_ref=psp_receipt.id)The reserve happens before the PSP call; clean exit commits; exceptions release; uncertain post-PSP commit outcomes raise for reconciliation.
A note on hash canonicalization
In v0.1, open_mandate_hash is caller-supplied. The important property is stability: every checkout derived from the same open mandate must produce the same value, or the bucket fragments and the defense fails. We have an open question on the AP2 discussion about whether the spec recommends a canonicalization — JSON Canonicalization Scheme (JCS, RFC 8785) over the open mandate, the same form the mandate signature is computed over, or something else. An AP2-native convention here would be cleaner than every implementer choosing a different hashing convention.
Edge cases harder than the AP2 idempotency keying decision
Most of the v0.1 work was not the keying decision itself. It was the failure modes around it.
Post-PSP commit uncertainty. Once the PSP call inside the with block has run, any failure on the Cycles commit POST is a reconciliation event. Transport errors, 5xx responses, terminal reservation statuses (RESERVATION_FINALIZED / RESERVATION_EXPIRED / IDEMPOTENCY_MISMATCH), and uncaught exceptions all share one property: the commit may have reached the server and settled the budget before the failure was observed. Auto-releasing could undo a real charge. The guard raises AP2GuardCommitUncertain with no release; the caller has to reconcile. This is the same principle behind silent-success failure modes in agent systems — recovery logic that hides uncertainty is dangerous in payment paths.
Amount conversion. USD amounts arrive as decimal strings. Converting to int64 micro-cents through Decimal arithmetic uses the default 28-digit context and silently rounds large values. We compute exactly from Decimal.as_tuple() digits, reject NaN/±Infinity/sub-micro/over-int64, and bound the input digit count before any 10**shift math — otherwise a string like "1E+1000000000000" survives validation and tries to allocate a trillion-digit scaling factor.
Idempotency header safety. Python's str.isalnum() is Unicode-aware ("É".isalnum() returns True), so a non-ASCII exception class name could have reached the Idempotency-Key HTTP header. RFC 7230 restricts header tokens to ASCII. The suffix sanitizer is now isascii() and isalnum().
What the AP2 runtime guard does not do
runcycles-ap2 does not verify AP2 signatures, does not create or sign mandates, and does not replace credential-provider verification. Those stay with the AP2 SDK and the merchant flow. It is a runtime authority gate that runs before the PSP call, keyed on the right boundary for the AP2 §6 risk. That is the entire scope.
Resource links
runcycles-ap2on PyPI —pip install runcycles-ap2cycles-ap2-pythonon GitHub — Apache-2.0 source- AP2 specification — Google Agentic Commerce Group
- AP2 GitHub Discussion #262 — context and a couple of spec-level questions
- Retry Storms & Idempotency in Agent Budget Systems — the idempotency primitive in detail
- What Is Runtime Authority for AI Agents — the broader pattern this post is one application of