Skip to main content

Runtime authority guard for AP2 (Agent Payments Protocol) — reserve, commit, release around agent payment mandates to prevent mandate reuse, double-spend, and concurrent checkout attempts. Works with Google's AP2 spec and any AP2-compatible SDK.

Project description

PyPI PyPI Downloads CI License Coverage

Cycles AP2 Guard — Runtime authority for AP2 agent payments

Cycles AP2 Guard adds runtime authority to Google AP2 payment flows.

Google AP2 proves that a payment mandate is valid. Cycles decides whether this agent, tenant, run, mandate, and merchant are still allowed to attempt the payment right now.

Use it to prevent:

  • duplicate payment attempts under retries
  • concurrent checkout races
  • open-mandate overuse in human-not-present flows
  • per-tenant or per-agent payment budget violations
  • missing runtime audit beside AP2 receipts

Install via pip install runcycles-ap2.

Independent project. This is not affiliated with, endorsed by, or maintained by Google. It is an independent Cycles integration for AP2-style payment mandate flows, built against the public AP2 specification and sample shapes.

The problem AP2 itself flags

From the AP2 spec, human-not-present flows let the agent act autonomously using an open mandate and sign a closed mandate on the user's behalf. AP2 warns:

"A shopping agent must avoid presenting subsequent open mandates without a rejection receipt to prevent multiple checkouts using the same open mandate."

That is a runtime-state problem: concurrency, retries, in-flight attempts, quota counters, consume-once. AP2 mandates are cryptographic authorization. Cycles adds the missing runtime enforcement.

When an AP2Mandate carries an open_mandate_hash, this package keys the consume-once lock on the open mandate (not the transaction id) — so every checkout derived from the same open mandate shares one idempotency bucket, even when their transaction ids differ. Identical replays return the original reservation; divergent attempts hit IDEMPOTENCY_MISMATCH server-side. Either way the second attempt cannot create a second valid reservation. See Deterministic idempotency keys below.

What this does NOT do

Be explicit about the boundary:

  • Does not verify AP2 signatures. Signature checks belong to the AP2 SDK / credential provider.
  • Does not create or sign mandates. Callers pass already-signed PaymentMandate / CheckoutMandate objects.
  • Does not replace merchant or credential-provider AP2 verification. This guard runs before the PSP call as a runtime authority gate.
  • Does not move money. The PSP call lives inside the with block; this package only decides whether the agent may attempt it.

Installation

pip install runcycles-ap2

Needs a running Cycles server (see cycles-client-python for setup) and a signed AP2 PaymentMandate.

Quickstart

from runcycles import CyclesClient, CyclesConfig
from runcycles_ap2 import AP2Mandate, cycles_guard_payment

config = CyclesConfig.from_env()  # CYCLES_BASE_URL, CYCLES_API_KEY, CYCLES_TENANT

with CyclesClient(config) as client:
    mandate = AP2Mandate(
        transaction_id="ap2-tx-9f3c",
        amount_value="199.00",
        currency="USD",
        payee_website="merchant.example",
        checkout_hash="ch_b1a9...",
    )
    with cycles_guard_payment(
        client,
        mandate=mandate,
        run_id="run_abc123",
        tenant="acme",
        agent="checkout-bot",
    ) as guard:
        # Real PSP call goes here — protected by reserve / commit / release.
        psp_receipt = psp.charge(mandate)
        guard.attach_receipt_fields(psp_ref=psp_receipt.id)

    print(guard.receipt)  # client-side runtime-authority receipt

Async variant (v0.2+)

Same contract, asyncio I/O. Use this when your agent runtime is async (FastAPI, anyio, the OpenAI async SDK, etc.):

from runcycles import AsyncCyclesClient, CyclesConfig
from runcycles_ap2 import AP2Mandate, cycles_guard_payment_async

async def charge(mandate: AP2Mandate) -> None:
    config = CyclesConfig.from_env()
    async with AsyncCyclesClient(config) as client:
        async with cycles_guard_payment_async(
            client, mandate=mandate, run_id="run_abc123", tenant="acme",
        ) as guard:
            psp_receipt = await psp.charge_async(mandate)
            guard.attach_receipt_fields(psp_ref=psp_receipt.id)

AsyncGuardedPayment raises the same exceptions (AP2GuardDenied, AP2DryRunResult, AP2GuardCommitUncertain, AP2GuardCommitFailed) under the same conditions as the sync variant, plus one async-only condition: an asyncio.CancelledError landing while the commit POST is in flight is wrapped as AP2GuardCommitUncertain(error_code="COMMIT_CANCELLED") with the original cancellation chained via __cause__.

From an existing AP2 SDK object

If you already hold a PaymentMandate (and optional CheckoutMandate) shaped per the AP2 public examples, build an AP2Mandate adapter in one line. Schema renames in upstream AP2 only touch this adapter — your guard code stays stable.

from runcycles_ap2 import AP2Mandate

mandate = AP2Mandate.from_ap2(payment_mandate, checkout_mandate)

Required upstream attributes (duck-typed): payment_mandate.transaction_id, payment_mandate.payment_amount.value, payment_mandate.payment_amount.currency, payment_mandate.payee.website (or .identifier). Optional: checkout_mandate.hash. Tested against the AP2-style field shapes used in the current public examples; not bound to any specific AP2 SDK release.

How the guard responds

Scenario Outcome Detail
Decision.ALLOW, body completes Commit Server idempotency key derived from the consume-once scope (open_mandate_hash when present, otherwise transaction_id) — see Deterministic idempotency keys below
Decision.ALLOW, body raises Release Reason ap2_guard_failed:{ExcType}, idempotency key includes the exception type
Decision.DENY Neither AP2GuardDenied raised in __enter__; real money never moves
HTTP / transport error on reserve Neither AP2GuardDenied raised; caller can retry — same consume-once scope (open_mandate_hash when present, otherwise transaction_id) ⇒ same reserve key
Commit transport error / 5xx / RESERVATION_FINALIZED / RESERVATION_EXPIRED / IDEMPOTENCY_MISMATCH / uncaught exception / asyncio.CancelledError (async only) Raise, no release AP2GuardCommitUncertain raised. The commit POST may have reached and mutated Cycles before the failure, so auto-release could undo a successful settle. error_code distinguishes the flavor (TRANSPORT_ERROR, SERVER_ERROR, COMMIT_RAISED, COMMIT_CANCELLED (async only), or the specific code)
Commit returns 4xx with unrecognized code Release + raise Server explicitly rejected the request (malformed, forbidden, etc.) — release is safe. AP2GuardCommitFailed raised with released + release_error so the caller can still see the reconciliation context
guard.abort(reason) called inside with Release Reason ap2_guard_aborted:{reason}
dry_run=True Neither __enter__ raises AP2DryRunResult carrying the decision payload — the with body never runs, so a real PSP call cannot leak under a dry-run probe

AP2GuardDenied carries reason_code and request_id for upstream logging.

AP2 → Cycles wire mapping

AP2 source Cycles destination Notes
PaymentMandate.transaction_id Subject.dimensions["ap2_transaction_id"] feeds the idempotency key only when open_mandate_hash is absent (otherwise the open mandate is the consume-once scope — see Deterministic idempotency keys)
PaymentMandate.payment_amount.value Amount.amount Exact integer conversion to USD micro-cents (10⁻⁸ USD). Rejects NaN, ±Infinity, negative values, more than 8 decimal places, or amounts beyond int64 micro-cents
PaymentMandate.payment_amount.currency Action.policy_keys.custom["currency"] MVP enforces "USD"
PaymentMandate.payee.website Action.policy_keys.host required for policy routing
CheckoutMandate.hash Subject.dimensions["checkout_hash"] optional
sha256(open_mandate_canonical) Subject.dimensions["open_mandate_hash"] optional, human-not-present
caller run_id Subject.dimensions["run_id"] required
const "ap2" Action.policy_keys.custom["payment_protocol"] marker
const "payment.charge" Action.kind built-in high_risk kind in cycles-action-kinds-v0.1.26.yaml
const USD_MICROCENTS Amount.unit single-unit per reservation

No protocol changes required for v0.1 — payment.charge and payment.refund already exist as high_risk action kinds in the Cycles protocol registry.

Deterministic idempotency keys

The wrapper computes idempotency keys from the mandate; callers MUST NOT pass their own. The lock scope shifts automatically based on what the mandate carries — this is the AP2-spec consume-once defense:

Mandate carries… Key shape Lock boundary
open_mandate_hash (human-not-present) ap2:open_mandate:{sha256(open_mandate_hash)[:32]}:{phase}[:{suffix}] every checkout derived from one open mandate uses the same reserve idempotency key
only transaction_id (default / human-present) ap2:tx:{sha256(transaction_id)[:32]}:{phase}[:{suffix}] one transaction_id == one payment attempt

What sharing a key actually gets you, per Cycles idempotency semantics:

  • Same key + identical payload → server replays the original response (same reservation_id).
  • Same key + divergent payload (different transaction_id, checkout_hash, amount, etc.) → server rejects with 409 IDEMPOTENCY_MISMATCH, surfaced as AP2GuardDenied(reason_code="IDEMPOTENCY_MISMATCH").

Either way, the second attempt cannot create a second valid reservation — that's the consume-once defense. Multiple distinct checkouts from one open mandate are forced into the same idempotency bucket, so the server sees the conflict.

The scope namespace (open_mandate or tx) is embedded in the key so the two buckets never collide server-side. The hash is fixed-length (SHA-256 truncated to 32 hex chars, 128-bit collision resistance), header-safe, and the phase suffix (reserve / commit / release:{ExcType}) is always preserved.

Raw transaction_id and open_mandate_hash stay on Subject.dimensions for debug/audit; only the idempotency key uses the hash.

Runtime authority receipt

After a successful commit, the guard exposes a client-side receipt that can be persisted alongside AP2 dispute evidence:

{
  "schema": "runtime_authority.ap2.payment.charge.v1",
  "decision": "ALLOW",
  "reservation_id": "rsv_...",
  "tenant": "acme",
  "ap2_transaction_id": "ap2-tx-9f3c",
  "checkout_hash": "ch_b1a9...",
  "action_kind": "payment.charge",
  "amount_unit": "USD_MICROCENTS",
  "amount_micros": 19900000000,
  "policy_keys": {"host": "merchant.example", "custom": {"payment_protocol": "ap2", "currency": "USD"}},
  "issued_at_ms": 1715600000000,
  "committed": true,
  "psp_ref": "psp_abc"
}

Important. The receipt is built client-side from the Cycles ALLOW + COMMIT responses. It is not signed by the Cycles server in protocol v0.1.26 and must not be relied on as cryptographic evidence by third parties. A server-verifiable variant lands in v0.3 once cycles-protocol adds a signed-receipt field.

Disable with emit_receipt=False if you don't need it.

Error handling

from runcycles_ap2 import AP2GuardDenied, AP2CurrencyError, AP2MandateError, cycles_guard_payment

try:
    with cycles_guard_payment(client, mandate=mandate, run_id="r", tenant="acme") as guard:
        psp.charge(mandate)
except AP2GuardDenied as e:
    # Cycles refused the attempt. Real money has NOT moved.
    log.warning("denied", reason_code=e.reason_code, request_id=e.request_id)
except AP2CurrencyError:
    # v0.1 supports USD only.
    log.error("non-usd mandate")
except AP2MandateError:
    # Adapter input is malformed (missing payee, non-decimal amount, etc.).
    log.error("malformed mandate")

Exception hierarchy:

Exception When
AP2GuardError Base for all AP2-guard errors
AP2GuardDenied Cycles returned DENY or the reserve POST failed
AP2DryRunResult Raised from __enter__ when dry_run=True — carries the decision payload; the with body never executes
AP2GuardCommitUncertain Commit outcome is unknown after the body ran. Covers terminal status codes (RESERVATION_FINALIZED, RESERVATION_EXPIRED, IDEMPOTENCY_MISMATCH), transport-level failures (error_code="TRANSPORT_ERROR"), 5xx server errors (error_code="SERVER_ERROR" or specific code), uncaught exceptions during commit (error_code="COMMIT_RAISED", original chained via __cause__), and — async onlyasyncio.CancelledError mid-flight (error_code="COMMIT_CANCELLED", original chained via __cause__). No auto-release — the POST may have mutated Cycles before the failure. Reconcile with PSP
AP2GuardCommitFailed Commit was rejected with an unrecognized code after the body ran. Check .released (bool) and .release_error (string | None) on the exception — released=False means budget is stranded until TTL; reconcile with PSP either way
AP2CurrencyError Non-USD mandate in v0.1 (subclass of ValueError)
AP2MandateError Adapter input is malformed — NaN, infinity, sub-micro precision, missing payee, etc. (subclass of ValueError)

Features

  • One context managercycles_guard_payment wraps a single AP2 payment moment in reserve → commit / release.
  • Deterministic idempotency — no caller-supplied keys; retries replay the same reservation.
  • Consume-once defense — duplicate workers on the same mandate share one idempotency bucket server-side; identical replays return the original reservation, divergent attempts are rejected with IDEMPOTENCY_MISMATCH.
  • Built-in payment.charge action — no custom action-kind registration, no protocol PR required.
  • Adapter layer (AP2Mandate) insulates from upstream AP2 SDK churn.
  • Pydantic v2 models with strict validation.
  • Client-side runtime-authority receipt alongside AP2 dispute evidence (server-verifiable in v0.3).
  • Typed (py.typed) and mypy-strict clean.
  • ≥ 95% test coverage enforced in CI.

Scope of v0.1

In scope Out of scope (v0.2+)
Sync context manager Async API (AsyncGuardedPayment)
USD payments Multi-currency
payment.charge, with override for payment.refund payment.refund convenience helper
Caller-passed signed mandates Mandate signing or signature verification
Built-in action kinds Custom action kinds requiring server registration
Single-charge flows Partial capture, multi-shipment, split-tender

Example

End-to-end runnable sample in examples/ap2_human_not_present.py. Set the env vars and run:

CYCLES_BASE_URL=http://localhost:7878 \
CYCLES_API_KEY=test-key \
CYCLES_TENANT=acme \
python examples/ap2_human_not_present.py

Set DRY_RUN=1 to evaluate the policy decision without creating a reservation. Run twice with the same transaction_id to see the idempotent replay (server returns the original reservation — the double-spend defense).

Related packages

Package Purpose
runcycles (PyPI: runcycles) Underlying Cycles SDK — programmatic client, @cycles decorator, streaming context manager
cycles-protocol Authoritative YAML API specs
AP2 Google's Agent Payments Protocol (upstream)

Development

pip install -e ".[dev]"

# Lint + format
ruff check .
ruff format --check .

# Type check (strict mode)
mypy runcycles_ap2

# Run tests with coverage (95% threshold enforced in CI)
pytest --cov=runcycles_ap2 --cov-fail-under=95

CI runs all three checks on Python 3.10 and 3.12 for every push and pull request. See AUDIT.md for the protocol-conformance posture, CHANGELOG.md for the release log.

Background

Documentation

Requirements

  • Python 3.10+
  • runcycles >= 0.4.1
  • pydantic >= 2.0

License

Apache-2.0 — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

runcycles_ap2-0.2.0.tar.gz (62.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

runcycles_ap2-0.2.0-py3-none-any.whl (32.9 kB view details)

Uploaded Python 3

File details

Details for the file runcycles_ap2-0.2.0.tar.gz.

File metadata

  • Download URL: runcycles_ap2-0.2.0.tar.gz
  • Upload date:
  • Size: 62.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for runcycles_ap2-0.2.0.tar.gz
Algorithm Hash digest
SHA256 84eb6fc824dcc790e1db797a38041f9847c89a4d3a0386faeb7d6da9cf48ea09
MD5 1a42b9d5a99487a59fc8a163cacc1d8d
BLAKE2b-256 1d3c11a1d96d2d2908a15e42740e3df9614b8fc472da3ae67833cf8a61bec4f4

See more details on using hashes here.

Provenance

The following attestation bundles were made for runcycles_ap2-0.2.0.tar.gz:

Publisher: python-publish.yml on runcycles/cycles-ap2-python

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file runcycles_ap2-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: runcycles_ap2-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 32.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for runcycles_ap2-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 77225a513d95cfe221c3bbadcdb764a6bd516c1b92c219def41ac5656320ab20
MD5 d6ad0be1b7c0ab00af0ac4223cd12a74
BLAKE2b-256 5f2c5021fc3bf95024ee308b9d4f88ddfe90b87b35b6c205b9457073e5040f38

See more details on using hashes here.

Provenance

The following attestation bundles were made for runcycles_ap2-0.2.0-py3-none-any.whl:

Publisher: python-publish.yml on runcycles/cycles-ap2-python

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page