https://github.com/runcycles/cycles-ap2-python
Cycles runtime authority guard for Google AP2 (Agent Payments Protocol) — reserve, commit, release around AP2 payment mandates to prevent mandate reuse, double-spend, and concurrent checkout attempts.
https://github.com/runcycles/cycles-ap2-python
a2a agent-governance agent-payments agent-payments-protocol agentic-commerce agentic-payments ai-payments ap2 checkout-mandate consume-once cycles double-spend-prevention idempotent-payments mcp payment-authorization payment-guard payment-mandate python-sdk runcycles runtime-authority
Last synced: 20 days ago
JSON representation
Cycles runtime authority guard for Google AP2 (Agent Payments Protocol) — reserve, commit, release around AP2 payment mandates to prevent mandate reuse, double-spend, and concurrent checkout attempts.
- Host: GitHub
- URL: https://github.com/runcycles/cycles-ap2-python
- Owner: runcycles
- License: apache-2.0
- Created: 2026-05-13T12:00:17.000Z (21 days ago)
- Default Branch: main
- Last Pushed: 2026-05-13T15:12:13.000Z (21 days ago)
- Last Synced: 2026-05-13T15:12:49.035Z (21 days ago)
- Topics: a2a, agent-governance, agent-payments, agent-payments-protocol, agentic-commerce, agentic-payments, ai-payments, ap2, checkout-mandate, consume-once, cycles, double-spend-prevention, idempotent-payments, mcp, payment-authorization, payment-guard, payment-mandate, python-sdk, runcycles, runtime-authority
- Language: Python
- Homepage: https://runcycles.io
- Size: 232 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
- Audit: AUDIT.md
- Maintainers: MAINTAINERS.md
Awesome Lists containing this project
README
[](https://pypi.org/project/runcycles-ap2/)
[](https://pypi.org/project/runcycles-ap2/)
[](https://github.com/runcycles/cycles-ap2-python/actions)
[](LICENSE)
[](https://github.com/runcycles/cycles-ap2-python/actions)
# Cycles AP2 Guard — Runtime authority for AP2 agent payments
**Cycles AP2 Guard adds runtime authority to [Google AP2](https://github.com/google-agentic-commerce/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](#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
```bash
pip install runcycles-ap2
```
Needs a running Cycles server (see [`cycles-client-python`](https://github.com/runcycles/cycles-client-python) for setup) and a signed AP2 PaymentMandate.
## Quickstart
```python
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.):
```python
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.
```python
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](#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` | `Subject.dimensions["payment_currency"]` | MVP enforces `"USD"` |
| `PaymentMandate.payee.website` | `Subject.dimensions["payee_website"]` | merchant identifier |
| `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"` | `Subject.dimensions["payment_protocol"]` | marker — tags every reservation made by this wrapper |
| 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 |
> **Wire-shape note (v0.3+).** Earlier versions of this wrapper sent the AP2 routing context (`host`, `currency`, `payment_protocol`) on `Action.policy_keys` per the `cycles-action-kinds-v0.1.26.yaml` extension. Production `cycles-server` v0.1.25.x doesn't yet implement that extension, and its base `Action` schema has `additionalProperties: false`, so the old shape triggered a 400 *Malformed request body*. v0.3 surfaces the same values as `Subject.dimensions` instead so the wrapper works against current production servers. The client-side `RuntimeAuthorityReceipt.policy_keys` field is unchanged — dashboards and dispute evidence still see the canonical shape.
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:
```json
{
"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
```python
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 only** — `asyncio.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 manager** — `cycles_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`](examples/ap2_human_not_present.py). Set the env vars and run:
```bash
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`](https://github.com/runcycles/cycles-client-python) (PyPI: [`runcycles`](https://pypi.org/project/runcycles/)) | Underlying Cycles SDK — programmatic client, `@cycles` decorator, streaming context manager |
| [`cycles-protocol`](https://github.com/runcycles/cycles-protocol) | Authoritative YAML API specs |
| [`AP2`](https://github.com/google-agentic-commerce/AP2) | Google's Agent Payments Protocol (upstream) |
## Development
```bash
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
```
### Live integration smoke (optional)
`tests/integration/test_live_ap2_guard.py` exercises the sync and async wrappers end-to-end against a real Cycles server — useful for catching wire-shape regressions that mock-based unit tests can't see. The whole file is skipped at collection time when `CYCLES_BASE_URL` is unset, so default `pytest` runs (and CI) ignore it.
To run locally against a dev Cycles server:
```bash
CYCLES_BASE_URL=http://localhost:7878 \
CYCLES_API_KEY=cyc_dev_xxx \
CYCLES_TENANT=ap2-integration \
pytest tests/integration -v
```
The tenant needs a budget with `payment.charge` permitted. Each test uses a fresh UUID-based `transaction_id` and a tiny `0.00000001` USD amount, so running the suite repeatedly doesn't consume meaningful budget.
CI runs all three checks on Python 3.10 and 3.12 for every push and pull request. See [`AUDIT.md`](AUDIT.md) for the protocol-conformance posture, [`CHANGELOG.md`](CHANGELOG.md) for the release log.
## Background
- [Preventing AP2 Open-Mandate Overuse with Runtime Idempotency](https://runcycles.io/blog/ap2-open-mandate-consume-once-runtime-idempotency) — engineering write-up of the keying decision (`open_mandate_hash` vs `transaction_id`), post-PSP commit uncertainty, and the AP2 §6 consume-once defense.
- [AP2 GitHub Discussion #262](https://github.com/google-agentic-commerce/AP2/discussions/262) — context and a couple of spec-level questions (hash canonicalization, adapter shape) posted on the upstream AP2 repo.
## Documentation
- [AP2 Protocol Spec](https://ap2-protocol.org/) — Google's upstream specification
- [AP2 Payment Mandate](https://ap2-protocol.org/ap2/payment_mandate/) — mandate constraints and field reference
- [Cycles Documentation](https://runcycles.io) — Cycles platform docs
- [Cycles Action Kinds Registry](https://github.com/runcycles/cycles-protocol) — authoritative list of built-in action kinds (`payment.charge`, `payment.refund`, etc.)
## Requirements
- Python 3.10+
- `runcycles >= 0.4.1`
- `pydantic >= 2.0`
## License
Apache-2.0 — see [LICENSE](LICENSE).