Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@

Per `CLAUDE.md`: this file records material changes to the repo (server, admin, client). For a client package, that means public API, on-the-wire request shape, and protocol-conformance posture.

## 2026-05-13 — v0.2.0 — AsyncGuardedPayment

**Author:** v0.2.0 release
**Scope:** new public API surface (async). No wire-shape, exception, or validation changes.

- Added `runcycles_ap2.AsyncGuardedPayment` — async context manager (`async with`) that mirrors `GuardedPayment` exactly.
- Added `runcycles_ap2.cycles_guard_payment_async(...)` factory.
- The async variant uses `runcycles.AsyncCyclesClient` for I/O; everything else (idempotency keying, mapping, validation, exceptions, receipt construction) is shared via the existing module-level helpers.
- Behaviour parity with the sync variant on every documented contract: same `AP2GuardDenied` on DENY / failed reserve, same `AP2DryRunResult` on dry-run, same `AP2GuardCommitUncertain` on post-PSP unknown outcomes (terminal codes / transport / 5xx / uncaught), same `AP2GuardCommitFailed` on 4xx unrecognized commit rejection, same auto-release on exception inside the body, same idempotency-key derivation including the open-mandate consume-once scope.
- New example: `examples/ap2_human_not_present_async.py`.
- README quickstart now includes an "Async variant (v0.2+)" snippet.

**Public API additions:**
- `AsyncGuardedPayment` class
- `cycles_guard_payment_async(...)` factory

**Test posture after addition (including the audit follow-up commit and the CancelledError fix):**
- 147 tests (up from 110), 99.20% coverage.
- 37 tests in `tests/test_async_guard.py` mirror the sync surface across clean commit, dry-run, denial, release on exception, all five commit-uncertain branches (terminal codes, 5xx with/without body, transport error, uncaught exception, asyncio cancellation), commit-failed branches incl. release-failure recording, and an AP2 sample-type end-to-end through the async path.

**Cancellation policy on async commit:** an outer `asyncio.CancelledError` during the in-flight commit POST is treated as commit-uncertain. Since `asyncio.CancelledError` inherits from `BaseException`, the generic `except Exception` clause does not catch it; without an explicit handler, the cancellation would escape as raw `CancelledError` with no `reservation_id` or `error_code`, despite the post-PSP unknown-outcome contract. The async `_handle_commit` now explicitly converts it to `AP2GuardCommitUncertain(error_code="COMMIT_CANCELLED")` with the original on `__cause__`. No release is attempted (the commit may have reached and settled Cycles before the cancel landed). Sync code path is unaffected; cancellation only applies to async.

**No protocol changes. No wire-shape changes.** Existing v0.1.x callers see the sync API entirely unchanged.

## 2026-05-13 — positioning-review round 4 (ASCII suffix + empty-hash preservation + docstrings)

**Author:** strategic/positioning review round 4 (PR #2 still in-flight)
Expand Down
26 changes: 21 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] — 2026-05-13

### Added
- `AsyncGuardedPayment` and `cycles_guard_payment_async(...)` — async-context-manager variant for asyncio runtimes (FastAPI, anyio, OpenAI async SDK, etc.). Same exception classes as sync plus one async-only condition (cancellation mid-commit, see below). Same idempotency-key derivation (including the open-mandate consume-once scope) and same commit-uncertainty handling as the sync `GuardedPayment`.
- New example `examples/ap2_human_not_present_async.py`.
- README "Async variant (v0.2+)" quickstart snippet.
- 37 tests in `tests/test_async_guard.py` mirroring the sync test surface (clean commit, dry-run, denial, release on exception, commit-uncertain branches incl. cancellation, commit-failed branches incl. release-failure recording, AP2 sample-type adapter end-to-end). Total 147 tests, 99.20% coverage. ruff + mypy strict.

### Changed (async-only)
- `AP2GuardCommitUncertain` gains one new `error_code` value, `"COMMIT_CANCELLED"`, raised when an `asyncio.CancelledError` lands while the async commit POST is in flight. The exception class itself is unchanged; only the set of `error_code` discriminators grew. Sync callers see no change.

### Unchanged
- No wire-shape changes. No validation changes. No sync API changes — existing v0.1.x sync callers see the API entirely unchanged.

### Still planned for v0.3
- Multi-currency.
- `payment.refund` helper.
- Server-verifiable runtime-authority receipt (requires `cycles-protocol` signed-receipt field).

## [0.1.0] — 2026-05-13

### Added
Expand All @@ -14,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Deterministic idempotency keys with automatic consume-once scope selection — `ap2:open_mandate:{sha256(open_mandate_hash)[:32]}:{phase}` when the mandate carries an open mandate hash (AP2 spec §6 normative consume-once defense for human-not-present flows), `ap2:tx:{sha256(transaction_id)[:32]}:{phase}` otherwise. Hash is fixed-length (128-bit collision resistance), header-safe, and the phase suffix is always preserved.
- `AP2DryRunResult` exception — raised from `__enter__` when `dry_run=True` so the `with` body cannot execute (prevents real PSP calls under dry-run from moving money off the books).
- `AP2GuardCommitFailed` exception — raised after a release attempt when the server rejects a commit with an unrecognized code; carries `released: bool` and `release_error: str | None` so the caller can distinguish "budget recovered" from "budget stranded until TTL".
- `AP2GuardCommitUncertain` exception — raised whenever the commit outcome is unknown after the PSP body ran: terminal codes (`RESERVATION_FINALIZED` / `RESERVATION_EXPIRED` / `IDEMPOTENCY_MISMATCH`), transport-level failures (`error_code="TRANSPORT_ERROR"`), 5xx server errors (`SERVER_ERROR` or specific code), and uncaught exceptions during commit (`COMMIT_RAISED`, original chained via `__cause__`). No auto-release in any of these cases — the POST may have mutated Cycles before the failure.
- `AP2GuardCommitUncertain` exception — raised whenever the commit outcome is unknown after the PSP body ran: terminal codes (`RESERVATION_FINALIZED` / `RESERVATION_EXPIRED` / `IDEMPOTENCY_MISMATCH`), transport-level failures (`error_code="TRANSPORT_ERROR"`), 5xx server errors (`SERVER_ERROR` or specific code), uncaught exceptions during commit (`COMMIT_RAISED`, original chained via `__cause__`), and — async only — `asyncio.CancelledError` mid-flight (`COMMIT_CANCELLED`, original chained via `__cause__`). No auto-release in any of these cases — the POST may have mutated Cycles before the failure.
- USD-only enforcement; non-USD raises `AP2CurrencyError`. Rejects NaN, +/-Infinity, and amounts with more than 8 decimal places (sub-micro precision); wraps all `decimal` failures as `AP2MandateError`.
- Exact integer-tuple conversion in `amount_micros()` — does not depend on the default decimal context, so large valid inputs convert exactly instead of being silently rounded.
- Pre-allocation digit-count cap (≤ 19 integer digits, the int64 USD_MICROCENTS ceiling) blocks exponent-notation DoS like `Decimal("1E+1000000000000")` that would otherwise hang allocating a trillion-digit scaling factor.
Expand All @@ -26,10 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `AP2Mandate.from_ap2()` preserves an empty upstream `checkout_hash` so the model's `min_length=1` constraint can reject it (was previously masked to `None` by a falsy-`or` short-circuit).
- 110 tests, ≥ 95% coverage, ruff + mypy strict.

### Planned for v0.2
- `AsyncGuardedPayment` (asyncio).
### Planned for v0.3
- Multi-currency.
- `payment.refund` helper.

### Planned for v0.3
- Server-verifiable runtime-authority receipt (requires `cycles-protocol` signed-receipt field).
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,26 @@ with CyclesClient(config) as client:
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.
Expand All @@ -100,7 +120,7 @@ Required upstream attributes (duck-typed): `payment_mandate.transaction_id`, `pa
| `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 | **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`, or the specific code) |
| 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 |
Expand Down Expand Up @@ -196,7 +216,7 @@ Exception hierarchy:
| `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), and uncaught exceptions during commit (`error_code="COMMIT_RAISED"`, with the original chained via `__cause__`). **No auto-release** — the POST may have mutated Cycles before the failure. Reconcile with PSP |
| `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`) |
Expand Down
4 changes: 4 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ python examples/ap2_human_not_present.py
```

Set `DRY_RUN=1` to evaluate the policy decision without creating a reservation. Re-run with the same `transaction_id` to demonstrate that the server returns the original reservation (idempotent replay — the double-spend defense).

## ap2_human_not_present_async.py

Same demo, async. Use this when your agent runtime is asyncio-based (FastAPI, anyio, the OpenAI async SDK, etc.). Same environment variables and `DRY_RUN` toggle. The mandate in this variant also sets `open_mandate_hash`, so the idempotency lock is keyed on the open mandate (the AP2 §6 consume-once boundary) rather than `transaction_id`.
88 changes: 88 additions & 0 deletions examples/ap2_human_not_present_async.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""End-to-end async example: guard an AP2 human-not-present payment with Cycles.

Mirrors examples/ap2_human_not_present.py exactly except the I/O is awaited and
the client is AsyncCyclesClient. Useful when the agent runtime is asyncio-based
(FastAPI, anyio, the OpenAI async SDK, etc.).

Usage:
CYCLES_BASE_URL=http://localhost:7878 CYCLES_API_KEY=test CYCLES_TENANT=acme \\
python examples/ap2_human_not_present_async.py

# Probe the decision without creating a reservation or moving money:
DRY_RUN=1 python examples/ap2_human_not_present_async.py
"""

from __future__ import annotations

import asyncio
import json
import os

from runcycles import AsyncCyclesClient, CyclesConfig

from runcycles_ap2 import AP2DryRunResult, AP2Mandate, cycles_guard_payment_async


async def fake_psp_charge_async(mandate: AP2Mandate) -> dict[str, str]:
"""Stand-in for a real async payment-service-provider call."""
await asyncio.sleep(0) # simulate an awaitable I/O boundary
return {"id": f"psp_{mandate.transaction_id}", "status": "captured"}


async def run_dry_run(client: AsyncCyclesClient, mandate: AP2Mandate, tenant: str) -> None:
"""Dry-run is a policy probe — the ``async with`` body never runs."""
try:
async with cycles_guard_payment_async(
client,
mandate=mandate,
run_id="run_demo_async_001",
tenant=tenant,
agent="checkout-bot",
workflow="ap2-human-not-present",
dry_run=True,
):
raise AssertionError("dry-run body must not execute")
except AP2DryRunResult as result:
print(f"dry-run decision={result.decision}, reason_code={result.reason_code}")


async def run_real(client: AsyncCyclesClient, mandate: AP2Mandate, tenant: str) -> None:
async with cycles_guard_payment_async(
client,
mandate=mandate,
run_id="run_demo_async_001",
tenant=tenant,
agent="checkout-bot",
workflow="ap2-human-not-present",
) as guard:
print(f"decision={guard.decision}, reservation_id={guard.reservation_id}")
psp = await fake_psp_charge_async(mandate)
guard.attach_receipt_fields(psp_ref=psp["id"])

if guard.receipt is not None:
print(json.dumps(guard.receipt.model_dump(by_alias=True), indent=2))


async def main() -> None:
config = CyclesConfig(
base_url=os.environ.get("CYCLES_BASE_URL", "http://localhost:7878"),
api_key=os.environ.get("CYCLES_API_KEY", "test-key"),
tenant=os.environ.get("CYCLES_TENANT", "acme"),
)
mandate = AP2Mandate(
transaction_id="ap2-tx-demo-async-001",
amount_value="199.00",
currency="USD",
payee_website="merchant.example",
checkout_hash="ch_demo_async_001",
open_mandate_hash="omh_demo_async_001",
)
async with AsyncCyclesClient(config) as client:
if os.environ.get("DRY_RUN") == "1":
await run_dry_run(client, mandate, config.tenant or "acme")
else:
await run_real(client, mandate, config.tenant or "acme")


if __name__ == "__main__":
asyncio.run(main())
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "runcycles-ap2"
version = "0.1.0"
version = "0.2.0"
description = "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."
readme = "README.md"
license = "Apache-2.0"
Expand Down
11 changes: 9 additions & 2 deletions runcycles_ap2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@
AP2GuardError,
AP2MandateError,
)
from runcycles_ap2.guard import GuardedPayment, cycles_guard_payment
from runcycles_ap2.guard import (
AsyncGuardedPayment,
GuardedPayment,
cycles_guard_payment,
cycles_guard_payment_async,
)
from runcycles_ap2.models import AP2Mandate, RuntimeAuthorityReceipt

__version__ = "0.1.0"
__version__ = "0.2.0"

__all__ = [
"AP2CurrencyError",
Expand All @@ -25,8 +30,10 @@
"AP2GuardError",
"AP2Mandate",
"AP2MandateError",
"AsyncGuardedPayment",
"GuardedPayment",
"RuntimeAuthorityReceipt",
"cycles_guard_payment",
"cycles_guard_payment_async",
"__version__",
]
11 changes: 9 additions & 2 deletions runcycles_ap2/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ class AP2GuardCommitUncertain(AP2GuardError):
- **Uncaught exception during commit** (``error_code="COMMIT_RAISED"``) — the
client code raised before a response was processed; the chained ``__cause__``
is the original exception. May or may not have reached the server.
- **Cancellation mid-commit** (``error_code="COMMIT_CANCELLED"``, async only) —
an outer ``asyncio.CancelledError`` landed while the commit POST was in
flight. Because ``asyncio.CancelledError`` is a ``BaseException``, it must
be handled separately from the ``COMMIT_RAISED`` path; semantics are
otherwise identical. The chained ``__cause__`` is the original
``CancelledError``.

The caller MUST handle this exception — silently returning would let
unreconciled payment state propagate. Use ``error_code`` to distinguish the
Expand Down Expand Up @@ -111,8 +117,9 @@ class AP2GuardCommitFailed(AP2GuardError):
This is NOT the right exception for unknown-outcome failures. Anything where the
commit POST might have reached and mutated Cycles before the failure — transport
errors, 5xx, terminal reservation statuses (``RESERVATION_FINALIZED`` /
``RESERVATION_EXPIRED`` / ``IDEMPOTENCY_MISMATCH``), and uncaught exceptions —
is raised as :class:`AP2GuardCommitUncertain` with **no auto-release**.
``RESERVATION_EXPIRED`` / ``IDEMPOTENCY_MISMATCH``), uncaught exceptions, and
(async only) ``asyncio.CancelledError`` mid-flight — is raised as
:class:`AP2GuardCommitUncertain` with **no auto-release**.
"""

def __init__(
Expand Down
Loading