AP2 Mandates and Budget Constraints: 4 Gotchas From the GitHub Issues (2026)

Last updated 2026-09-29

The four confusions developers keep hitting when implementing AP2 (Agent Payments Protocol) mandate and budget-constraint semantics are: a budget.max field that is measured in different units than its sibling amount_range.max field; a constraint-evaluation design where an empty violations list does not mean a constraint was actually checked; no built-in protection against a single signed Payment Mandate being redeemed more than once; and a CheckoutMandateChain.verify() method that skips its own checkout-binding check unless the caller explicitly passes a hash it already has. All four are documented against the AP2 reference implementation at commit e1ea56d and filed as public GitHub issues; none has landed on main yet as of 2026-09-29.

Primer: Intent Mandate vs. Cart Mandate (and what the current spec calls them)

Google's original announcement described AP2 around three signed artifacts. Per the Google Cloud launch post:

"When you ask an agent, 'Find me new white running shoes,' your request is captured in an initial Intent Mandate."

"After the agent presents a cart with the shoes you want, your approval signs a Cart Mandate. This is a critical step that creates a secure, unchangeable record of the exact items and price, ensuring what you see is what you pay for."

The pinned v0.2 spec uses different names for the same two-artifact idea: the specification defines "Mandate types: Checkout Mandate and Payment Mandate," each split into an open mandate (the user's declared intent/constraints) and a closed mandate (the specific, cryptographically bound transaction the user actually signed). We found no line in the pinned spec mapping "Intent Mandate" → "Checkout Mandate," so treat "Intent/Cart" as the announcement-era vocabulary and "open/closed Checkout and Payment Mandate" as the current spec's vocabulary for the same authorization chain, not two different protocols.

Governance note: Google announced on 2026-04-28 that it was "donating the Agent Payments Protocol (AP2) to the FIDO Alliance, a renowned industry association focused on creating open standards" (blog.google), alongside Mastercard's Verifiable Intent framework. That post doesn't state a new GitHub location — as of 2026-09-29, the code and issue tracker are still at github.com/google-agentic-commerce/AP2, which we treat as canonical.

The 4 gotchas

1. budget.max and amount_range.max are in different units — and only one field says so

Symptom. Issue #339 ("Constraint evaluation is presence-driven... + budget.max unit is undocumented") and its near-duplicate Issue #362 ("Budget cap uses max * 100 for all currencies...") both report the same confusion: a developer sets budget.max expecting it to behave like amount_range.max, and the cap comes out 100x too permissive.

What the code and schema do. The schema for amount_range.max states its unit — open_payment_mandate.json:135-137:

"max": {
  "type": "integer",
  "description": "Maximum allowed amount in minor (cents) unit of currency."
}

The budget.max field gives no unit at all — open_payment_mandate.json:260-262:

"max": {
  "type": "number",
  "description": "Maximum amount for the budget."
}

BudgetEvaluator then treats the undocumented field as a major-unit amount and converts it to minor units itself — constraints.py:307-308:

budget_max_cents = int(self.constraint.max * 100)
if total_spend > budget_max_cents:

So amount_range.max expects minor units directly, while budget.max expects major units and multiplies by 100 internally — the reverse of what a developer copying the pattern from amount_range would assume. The issue author hit exactly this on first implementation: setting Budget(max=5000.0) read as 5,000 paise (INR 50, the minor-unit convention of amount_range.max), sending a charge of 47,500 paise (INR 475) expecting a violation, and getting none — the effective ceiling the code computed is 500,000 paise (INR 5,000), not 5,000 paise. (One correction to flag: a claim sometimes repeated is that "budget.max is in minor units, multiplied by 100 internally" — the code shows the opposite: budget.max is major-unit, and the evaluator converts it to minor units.)

Safe pattern. Populate budget.max in the major unit of the currency (e.g. 5000.0 for ₹5,000, not 500000), and don't assume it shares units with amount_range.max. A fix PR (#340, "docs(schema): state the currency unit for payment.budget.max") proposes clarifying the schema description; it was open, not merged, as of 2026-09-29.

Status (2026-09-29). Both issues open; no maintainer reply on either. PR #340 open, unmerged.

2. An empty violation list doesn't mean a constraint was checked — it can mean it was never presented

Symptom. The first half of Issue #339 reports that check_payment_constraints() only evaluates constraints that appear in the presented OpenPaymentMandate; a constraint the issuer marked selectively disclosable and the holder withheld produces no evaluator and therefore no violation, not a warning.

What the code actually does. The evaluator factory only runs over what's present in the array — constraints.py:

for constraint in open_mandate.constraints:
    evaluator = create_payment_evaluator(constraint, mandate_context)
    violations.extend(
        evaluator.evaluate(closed_payment, open_checkout_hash)
    )

There is one narrower presence-assertion already in the SDK, for AgentRecurrence — it requires amount_range and budget to also be present or it appends a violation (constraints.py:525-533). A commenter (Ectsang) pointed this out; the reporter reproduced against the pinned SHA and found that assertion itself lives in the same selectively-disclosable constraints array — withholding both Budget and the AgentRecurrence marker together still produces a clean pass. The reporter had already filed the same finding through Google's OSS Vulnerability Reward Program (VRP issues 551304805/551303152), closed as "Won't Fix (Intended Behavior)," with a reviewer note: "You've clearly identified a mechanism where a selectively withheld constraint could lead to a permissions bypass... We still encourage you to open an issue... to help the maintainers address this" — hence the public docs/API-safety issue rather than a vulnerability report.

Safe pattern. Don't infer "constraints were satisfied" from an empty violations list. If a verifier needs a specific constraint (e.g. payment.budget) to have actually been evaluated, it must track and assert its own expected constraint set independently — the SDK does not currently expose a way to require a constraint type be present before returning success. (The issue's own suggested fixes — a verifier-side "required constraint" API, or returning the set of constraints actually evaluated alongside violations — were offered to the maintainers but had not shipped as of the last recorded activity.)

Status (2026-09-29). Open, two comments, no maintainer reply, no linked PR.

3. Nothing stops the same signed Payment Mandate from being redeemed twice

Symptom. Issue #346 ("Verifiers never consume an accepted closed Payment Mandate; one user consent can be redeemed for multiple payments") reports that presenting a byte-identical, already-accepted closed Payment Mandate a second time produces a second valid payment token, a second completed checkout, and a second signed Payment Receipt — no signature forged, the mandate simply replayed. An earlier HN comment asked the same underlying question in different words: "Why would it be an issue to have at-most-one semantics on this?" (re: AP2 payment mandate redemption, 2025-09-23) — a sign the gap was noticed independently, though that comment alone doesn't confirm the mechanism the way the GitHub reproduction does.

What the spec and reference code actually do. The spec's threat model is explicit that agents themselves are adversaries — security_and_privacy_considerations.md:5-8:

"AP2 assumes that preventing prompt injection attacks is infeasible. Therefore, all LLMs and Agents MUST be considered potential attackers and are explicitly included in the threat model."

But the only mandatory double-spend control the spec places anywhere is on the Shopping Agent's own conduct — specification.md:237-239:

"Shopping Agents MUST NOT present any subsequent open Payment or Checkout Mandates without receiving a rejection receipt from the previous one."

— and the spec's own threat model says agents can't be trusted to follow that. The check on the receiving side is optional, not mandatory — security_and_privacy_considerations.md:113-114:

"Credential Provider, Networks or MPPs MAY reject multiple overlapping Mandates, or invalidate previously issued payment tokens."

The reference sample confirms the "MAY" is currently a "does not": issue_payment_credential in credentials_provider_mcp/server.py verifies the mandate chain and issues a token unconditionally on every call, and the reporter's reproduction (run in-process against the sample's own functions) showed two calls with identical arguments both succeeding, each producing a distinct token for the same mandate hash.

Safe pattern. Don't rely on the spec's Shopping-Agent-side "MUST NOT present a subsequent mandate" rule as your actual replay defense — the spec itself treats agents as untrusted. If you're implementing a Credential Provider, Network, or Merchant Payment Processor role, record the accepted mandate hash (or transaction_id) yourself with atomic single-writer semantics and reject a repeat before issuing a second credential or receipt — the "MAY" language means the reference implementation does not do this for you.

Status (2026-09-29). Open. A cross-reference (not a text reply) points to fix PR #347 ("fix(samples): consume accepted Payment Mandates atomically"), open and not merged. Also filed through Google's vulnerability-reporting channel (g.co/vulnz) on 2026-09-01, "classified... as intended behavior for program purposes," with the public issue invited from there.

4. CheckoutMandateChain.verify() doesn't check the checkout binding unless you remember to pass the hash yourself

Symptom. Issue #358 ("Closed Checkout Mandate binding not enforced by CheckoutMandateChain.verify") reports that calling verify(checkout_jwt=checkout_B) on a chain whose closed mandate is actually bound to a different checkout (checkout_A) returns [] — no violation — as long as the caller omits the optional expected_checkout_hash argument.

What the code actually does. checkout_mandate_chain.py:44-99 parses and evaluates the presented checkout_jwt against the open mandate's constraints, but the binding check against the closed mandate is gated behind the caller-supplied argument:

if (
    expected_checkout_hash is not None
    and expected_checkout_hash != self.closed_mandate.checkout_hash
):
    violations.append(
        'Checkout checkout_hash mismatch: expected'
        f' {expected_checkout_hash}, got'
        f' {self.closed_mandate.checkout_hash}'
    )

verify() never independently hashes the checkout_jwt it just parsed and compares that computed hash to closed_mandate.checkout_hash — it only compares two values the caller provides, so if the caller doesn't already know to supply expected_checkout_hash, nothing establishes the presented checkout is the one the mandate was signed against. The spec says this comparison should happen — specification.md:311 (Verification → Merchant): "Verify that the hash of the Checkout JWT sent for approval matches the value included for the checkout_hash claim," and checkout_mandate.md:26 defines checkout_hash as "the base64url-encoded hash of the value of checkout_jwt."

A second-party reproducer (robertolocatelli81-dev) independently confirmed the scenario against the pinned SHA and found a related case: a CheckoutMandate built with an internally inconsistent checkout_jwt/checkout_hash pair also verifies clean.

Safe pattern. Don't treat expected_checkout_hash as optional in your own call sites — always pass the checkout_hash you independently recorded from the closed mandate when calling verify(). Don't assume verify() derives the binding for you from the JWT it parses.

Status (2026-09-29). Open. Fix PR #359 ("fix(sdk): verify checkout mandate binding from presented checkout") adds the derivation and regression tests (6 passing on main → 14 passing after follow-up commit df49936, independently re-verified by the second-party reproducer). PR #359 was open, not yet merged into main, as of this check.

Where spend limits should live

Everything above is about whether an AP2 mandate correctly represents and verifies what the user actually authorized — the right amount, currency unit, checkout, consumed exactly once. That's distinct from operational spend control: how much an agent may attempt to spend in a day, which merchants it may pay, and when a human must approve a transaction before it goes out. The spec's own language reflects this split — double-spend and disclosure protections are described as things a Credential Provider, Network, or MPP "MAY" add on top of mandate verification, not something the mandate format itself guarantees. In practice, caps, allowlists, and approval thresholds are commonly enforced as a separate policy layer sitting in front of (or alongside) mandate verification, not derived from the mandate alone.

Pink Agentic AI Payment (early access) enforces per-agent spending caps, allowlists and approval thresholds at the MCP layer, before a payment executes.

FAQ

Is budget.max in AP2 in cents or in whole currency units? Per the reference implementation at e1ea56d, budget.max is major-unit (e.g. 5000.0 for ₹5,000); BudgetEvaluator multiplies it by 100 internally (constraints.py:307) — opposite the sibling amount_range.max, which is minor-unit (open_payment_mandate.json:137). Not stated in the budget.max schema description itself as of 2026-09-29; a docs fix (PR #340) is open, unmerged.

Can an AP2 Payment Mandate be replayed to pay twice? Per Issue #346, the reference sample's Credential Provider issues a new payment token on every call to issue_payment_credential without checking if the mandate hash was already accepted — presenting the same signed mandate twice can produce two credentials and two receipts. The spec makes replay prevention mandatory only on the (untrusted) Shopping Agent side; receiving-side rejection is optional ("MAY"). Fix PR #347 open, not merged, as of 2026-09-29.

Is AP2's GitHub repo still canonical now that it's gone to the FIDO Alliance? As of 2026-09-29, yes: github.com/google-agentic-commerce/AP2 is where all four issues and fix PRs above live. Google's 2026-04-28 announcement confirms the AP2 donation to FIDO but doesn't state a new repo location (blog.google).

Related reading