{"id":49835023,"url":"https://github.com/runcycles/cycles-ap2-python","last_synced_at":"2026-05-14T00:02:09.904Z","repository":{"id":357608968,"uuid":"1237675233","full_name":"runcycles/cycles-ap2-python","owner":"runcycles","description":"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.","archived":false,"fork":false,"pushed_at":"2026-05-13T15:12:13.000Z","size":238,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-13T15:12:49.035Z","etag":null,"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"],"latest_commit_sha":null,"homepage":"https://runcycles.io","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/runcycles.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":"AUDIT.md","citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":"MAINTAINERS.md","copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-13T12:00:17.000Z","updated_at":"2026-05-13T14:44:02.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/runcycles/cycles-ap2-python","commit_stats":null,"previous_names":["runcycles/cycles-ap2-python"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/runcycles/cycles-ap2-python","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/runcycles%2Fcycles-ap2-python","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/runcycles%2Fcycles-ap2-python/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/runcycles%2Fcycles-ap2-python/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/runcycles%2Fcycles-ap2-python/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/runcycles","download_url":"https://codeload.github.com/runcycles/cycles-ap2-python/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/runcycles%2Fcycles-ap2-python/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33004768,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-13T13:14:54.681Z","status":"ssl_error","status_checked_at":"2026-05-13T13:14:51.610Z","response_time":115,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["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"],"created_at":"2026-05-14T00:02:06.969Z","updated_at":"2026-05-14T00:02:09.897Z","avatar_url":"https://github.com/runcycles.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"[![PyPI](https://img.shields.io/pypi/v/runcycles-ap2)](https://pypi.org/project/runcycles-ap2/)\n[![PyPI Downloads](https://img.shields.io/pypi/dm/runcycles-ap2)](https://pypi.org/project/runcycles-ap2/)\n[![CI](https://github.com/runcycles/cycles-ap2-python/actions/workflows/ci.yml/badge.svg)](https://github.com/runcycles/cycles-ap2-python/actions)\n[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)\n[![Coverage](https://img.shields.io/badge/coverage-98%25-brightgreen)](https://github.com/runcycles/cycles-ap2-python/actions)\n\n# Cycles AP2 Guard — Runtime authority for AP2 agent payments\n\n**Cycles AP2 Guard adds runtime authority to [Google AP2](https://github.com/google-agentic-commerce/AP2) payment flows.**\n\n\u003e *Google AP2 proves that a payment mandate is valid.*\n\u003e *Cycles decides whether this agent, tenant, run, mandate, and merchant are still allowed to attempt the payment right now.*\n\nUse it to prevent:\n\n- duplicate payment attempts under retries\n- concurrent checkout races\n- open-mandate overuse in human-not-present flows\n- per-tenant or per-agent payment budget violations\n- missing runtime audit beside AP2 receipts\n\nInstall via `pip install runcycles-ap2`.\n\n\u003e **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.\n\n## The problem AP2 itself flags\n\nFrom 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:\n\n\u003e \"A shopping agent must avoid presenting subsequent open mandates without a rejection receipt to prevent multiple checkouts using the same open mandate.\"\n\nThat 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.\n\nWhen 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.\n\n## What this does NOT do\n\nBe explicit about the boundary:\n\n- **Does not verify AP2 signatures.** Signature checks belong to the AP2 SDK / credential provider.\n- **Does not create or sign mandates.** Callers pass already-signed `PaymentMandate` / `CheckoutMandate` objects.\n- **Does not replace merchant or credential-provider AP2 verification.** This guard runs *before* the PSP call as a runtime authority gate.\n- **Does not move money.** The PSP call lives inside the `with` block; this package only decides whether the agent may attempt it.\n\n## Installation\n\n```bash\npip install runcycles-ap2\n```\n\nNeeds a running Cycles server (see [`cycles-client-python`](https://github.com/runcycles/cycles-client-python) for setup) and a signed AP2 PaymentMandate.\n\n## Quickstart\n\n```python\nfrom runcycles import CyclesClient, CyclesConfig\nfrom runcycles_ap2 import AP2Mandate, cycles_guard_payment\n\nconfig = CyclesConfig.from_env()  # CYCLES_BASE_URL, CYCLES_API_KEY, CYCLES_TENANT\n\nwith CyclesClient(config) as client:\n    mandate = AP2Mandate(\n        transaction_id=\"ap2-tx-9f3c\",\n        amount_value=\"199.00\",\n        currency=\"USD\",\n        payee_website=\"merchant.example\",\n        checkout_hash=\"ch_b1a9...\",\n    )\n    with cycles_guard_payment(\n        client,\n        mandate=mandate,\n        run_id=\"run_abc123\",\n        tenant=\"acme\",\n        agent=\"checkout-bot\",\n    ) as guard:\n        # Real PSP call goes here — protected by reserve / commit / release.\n        psp_receipt = psp.charge(mandate)\n        guard.attach_receipt_fields(psp_ref=psp_receipt.id)\n\n    print(guard.receipt)  # client-side runtime-authority receipt\n```\n\n### Async variant (v0.2+)\n\nSame contract, asyncio I/O. Use this when your agent runtime is async (FastAPI, anyio, the OpenAI async SDK, etc.):\n\n```python\nfrom runcycles import AsyncCyclesClient, CyclesConfig\nfrom runcycles_ap2 import AP2Mandate, cycles_guard_payment_async\n\nasync def charge(mandate: AP2Mandate) -\u003e None:\n    config = CyclesConfig.from_env()\n    async with AsyncCyclesClient(config) as client:\n        async with cycles_guard_payment_async(\n            client, mandate=mandate, run_id=\"run_abc123\", tenant=\"acme\",\n        ) as guard:\n            psp_receipt = await psp.charge_async(mandate)\n            guard.attach_receipt_fields(psp_ref=psp_receipt.id)\n```\n\n`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__`.\n\n## From an existing AP2 SDK object\n\nIf 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.\n\n```python\nfrom runcycles_ap2 import AP2Mandate\n\nmandate = AP2Mandate.from_ap2(payment_mandate, checkout_mandate)\n```\n\nRequired 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.\n\n## How the guard responds\n\n| Scenario | Outcome | Detail |\n|---|---|---|\n| `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 |\n| `Decision.ALLOW`, body raises | **Release** | Reason `ap2_guard_failed:{ExcType}`, idempotency key includes the exception type |\n| `Decision.DENY` | **Neither** | `AP2GuardDenied` raised in `__enter__`; real money never moves |\n| 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 |\n| 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) |\n| 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 |\n| `guard.abort(reason)` called inside `with` | **Release** | Reason `ap2_guard_aborted:{reason}` |\n| `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 |\n\n`AP2GuardDenied` carries `reason_code` and `request_id` for upstream logging.\n\n## AP2 → Cycles wire mapping\n\n| AP2 source | Cycles destination | Notes |\n|---|---|---|\n| `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)) |\n| `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 |\n| `PaymentMandate.payment_amount.currency` | `Subject.dimensions[\"payment_currency\"]` | MVP enforces `\"USD\"` |\n| `PaymentMandate.payee.website` | `Subject.dimensions[\"payee_website\"]` | merchant identifier |\n| `CheckoutMandate.hash` | `Subject.dimensions[\"checkout_hash\"]` | optional |\n| `sha256(open_mandate_canonical)` | `Subject.dimensions[\"open_mandate_hash\"]` | optional, human-not-present |\n| caller `run_id` | `Subject.dimensions[\"run_id\"]` | required |\n| const `\"ap2\"` | `Subject.dimensions[\"payment_protocol\"]` | marker — tags every reservation made by this wrapper |\n| const `\"payment.charge\"` | `Action.kind` | built-in `high_risk` kind in `cycles-action-kinds-v0.1.26.yaml` |\n| const `USD_MICROCENTS` | `Amount.unit` | single-unit per reservation |\n\n\u003e **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.\n\nNo protocol changes required for v0.1 — `payment.charge` and `payment.refund` already exist as `high_risk` action kinds in the Cycles protocol registry.\n\n## Deterministic idempotency keys\n\nThe 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:\n\n| Mandate carries… | Key shape | Lock boundary |\n|---|---|---|\n| `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 |\n| only `transaction_id` (default / human-present) | `ap2:tx:{sha256(transaction_id)[:32]}:{phase}[:{suffix}]` | one `transaction_id` == one payment attempt |\n\n**What sharing a key actually gets you**, per Cycles idempotency semantics:\n\n- Same key + **identical payload** → server replays the original response (same `reservation_id`).\n- Same key + **divergent payload** (different `transaction_id`, `checkout_hash`, amount, etc.) → server rejects with `409 IDEMPOTENCY_MISMATCH`, surfaced as `AP2GuardDenied(reason_code=\"IDEMPOTENCY_MISMATCH\")`.\n\nEither 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.\n\nThe 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.\n\nRaw `transaction_id` and `open_mandate_hash` stay on `Subject.dimensions` for debug/audit; only the idempotency key uses the hash.\n\n## Runtime authority receipt\n\nAfter a successful commit, the guard exposes a client-side receipt that can be persisted alongside AP2 dispute evidence:\n\n```json\n{\n  \"schema\": \"runtime_authority.ap2.payment.charge.v1\",\n  \"decision\": \"ALLOW\",\n  \"reservation_id\": \"rsv_...\",\n  \"tenant\": \"acme\",\n  \"ap2_transaction_id\": \"ap2-tx-9f3c\",\n  \"checkout_hash\": \"ch_b1a9...\",\n  \"action_kind\": \"payment.charge\",\n  \"amount_unit\": \"USD_MICROCENTS\",\n  \"amount_micros\": 19900000000,\n  \"policy_keys\": {\"host\": \"merchant.example\", \"custom\": {\"payment_protocol\": \"ap2\", \"currency\": \"USD\"}},\n  \"issued_at_ms\": 1715600000000,\n  \"committed\": true,\n  \"psp_ref\": \"psp_abc\"\n}\n```\n\n\u003e **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.\n\nDisable with `emit_receipt=False` if you don't need it.\n\n## Error handling\n\n```python\nfrom runcycles_ap2 import AP2GuardDenied, AP2CurrencyError, AP2MandateError, cycles_guard_payment\n\ntry:\n    with cycles_guard_payment(client, mandate=mandate, run_id=\"r\", tenant=\"acme\") as guard:\n        psp.charge(mandate)\nexcept AP2GuardDenied as e:\n    # Cycles refused the attempt. Real money has NOT moved.\n    log.warning(\"denied\", reason_code=e.reason_code, request_id=e.request_id)\nexcept AP2CurrencyError:\n    # v0.1 supports USD only.\n    log.error(\"non-usd mandate\")\nexcept AP2MandateError:\n    # Adapter input is malformed (missing payee, non-decimal amount, etc.).\n    log.error(\"malformed mandate\")\n```\n\nException hierarchy:\n\n| Exception | When |\n|---|---|\n| `AP2GuardError` | Base for all AP2-guard errors |\n| `AP2GuardDenied` | Cycles returned `DENY` or the reserve POST failed |\n| `AP2DryRunResult` | Raised from `__enter__` when `dry_run=True` — carries the decision payload; the `with` body never executes |\n| `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 |\n| `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 |\n| `AP2CurrencyError` | Non-USD mandate in v0.1 (subclass of `ValueError`) |\n| `AP2MandateError` | Adapter input is malformed — NaN, infinity, sub-micro precision, missing payee, etc. (subclass of `ValueError`) |\n\n## Features\n\n- **One context manager** — `cycles_guard_payment` wraps a single AP2 payment moment in reserve → commit / release.\n- **Deterministic idempotency** — no caller-supplied keys; retries replay the same reservation.\n- **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`.\n- **Built-in `payment.charge` action** — no custom action-kind registration, no protocol PR required.\n- **Adapter layer** (`AP2Mandate`) insulates from upstream AP2 SDK churn.\n- **Pydantic v2 models** with strict validation.\n- **Client-side runtime-authority receipt** alongside AP2 dispute evidence (server-verifiable in v0.3).\n- **Typed** (`py.typed`) and mypy-strict clean.\n- **≥ 95% test coverage** enforced in CI.\n\n## Scope of v0.1\n\n| In scope | Out of scope (v0.2+) |\n|---|---|\n| Sync context manager | Async API (`AsyncGuardedPayment`) |\n| USD payments | Multi-currency |\n| `payment.charge`, with override for `payment.refund` | `payment.refund` convenience helper |\n| Caller-passed signed mandates | Mandate signing or signature verification |\n| Built-in action kinds | Custom action kinds requiring server registration |\n| Single-charge flows | Partial capture, multi-shipment, split-tender |\n\n## Example\n\nEnd-to-end runnable sample in [`examples/ap2_human_not_present.py`](examples/ap2_human_not_present.py). Set the env vars and run:\n\n```bash\nCYCLES_BASE_URL=http://localhost:7878 \\\nCYCLES_API_KEY=test-key \\\nCYCLES_TENANT=acme \\\npython examples/ap2_human_not_present.py\n```\n\nSet `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).\n\n## Related packages\n\n| Package | Purpose |\n|---|---|\n| [`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 |\n| [`cycles-protocol`](https://github.com/runcycles/cycles-protocol) | Authoritative YAML API specs |\n| [`AP2`](https://github.com/google-agentic-commerce/AP2) | Google's Agent Payments Protocol (upstream) |\n\n## Development\n\n```bash\npip install -e \".[dev]\"\n\n# Lint + format\nruff check .\nruff format --check .\n\n# Type check (strict mode)\nmypy runcycles_ap2\n\n# Run tests with coverage (95% threshold enforced in CI)\npytest --cov=runcycles_ap2 --cov-fail-under=95\n```\n\n### Live integration smoke (optional)\n\n`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.\n\nTo run locally against a dev Cycles server:\n\n```bash\nCYCLES_BASE_URL=http://localhost:7878 \\\nCYCLES_API_KEY=cyc_dev_xxx \\\nCYCLES_TENANT=ap2-integration \\\n    pytest tests/integration -v\n```\n\nThe 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.\n\nCI 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.\n\n## Background\n\n- [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.\n- [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.\n\n## Documentation\n\n- [AP2 Protocol Spec](https://ap2-protocol.org/) — Google's upstream specification\n- [AP2 Payment Mandate](https://ap2-protocol.org/ap2/payment_mandate/) — mandate constraints and field reference\n- [Cycles Documentation](https://runcycles.io) — Cycles platform docs\n- [Cycles Action Kinds Registry](https://github.com/runcycles/cycles-protocol) — authoritative list of built-in action kinds (`payment.charge`, `payment.refund`, etc.)\n\n## Requirements\n\n- Python 3.10+\n- `runcycles \u003e= 0.4.1`\n- `pydantic \u003e= 2.0`\n\n## License\n\nApache-2.0 — see [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fruncycles%2Fcycles-ap2-python","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fruncycles%2Fcycles-ap2-python","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fruncycles%2Fcycles-ap2-python/lists"}