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
30 changes: 30 additions & 0 deletions AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@

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.3.0 — wire-shape change: AP2 routing moves to Subject.dimensions

**Author:** v0.3.0 release, prompted by a live integration smoke test against `cycles-server:0.1.25.18`
**Scope:** wire payload; public Python API unchanged; exception contract unchanged

**Trigger.** The v0.2.0 integration suite ran against a real `cycles-server` for the first time. All five tests failed with `AP2GuardDenied: AP2 reservation failed for transaction ...`. Direct curl reproduced the cause: the server rejected the reserve body with `400 INVALID_REQUEST: Malformed request body`.

**Root cause.** The wrapper sent AP2 routing context (`host`, `currency`, `payment_protocol`) nested inside `Action.policy_keys` — the shape defined in the v0.1.26 protocol extension (`cycles-action-kinds-v0.1.26.yaml`). Production `cycles-server` (`0.1.25.18` at time of audit) implements only the base `cycles-protocol-v0.yaml` `Action` schema, which has `additionalProperties: false` and lists only `kind` / `name` / `tags`. The `policy_keys` field was rejected as unknown. The unit-test suite (147 tests, all `MagicMock`-based) could not catch this — every mock accepts any shape we hand it.

**Fix.** v0.3 moves the three routing values from `Action.policy_keys` to `Subject.dimensions`:

- `Subject.dimensions["payee_website"]` (was `Action.policy_keys.host`)
- `Subject.dimensions["payment_currency"]` (was `Action.policy_keys.custom["currency"]`)
- `Subject.dimensions["payment_protocol"]` (was `Action.policy_keys.custom["payment_protocol"]`; constant `"ap2"`)

`Subject.dimensions` is part of the base protocol (already used by v0.1/0.2 for `ap2_transaction_id`, `checkout_hash`, `open_mandate_hash`, `run_id`), so the new fields ship over the wire on every server version the wrapper supports.

The client-side `RuntimeAuthorityReceipt.policy_keys` field still carries the canonical shape (`{host, custom: {payment_protocol, payment_currency}}`) — dashboards, dispute evidence, and any audit pipelines that consumed the receipt are unchanged. The receipt builder constructs it from `runcycles_ap2.mapping.build_receipt_policy_keys(mandate)` (a new internal helper) rather than reading it off the action body.

**What this is NOT:**
- Not a public Python API change. `cycles_guard_payment(...)`, `cycles_guard_payment_async(...)`, all exception classes, all class attributes are unchanged.
- Not a protocol change. We removed a v0.1.26-extension dependency from the wire payload; the base protocol is sufficient.
- Not a permanent ban on `Action.policy_keys`. When `cycles-server` ships the v0.1.26 extension and operators want first-class policy routing, the wrapper can re-emit `Action.policy_keys` via a future opt-in flag without breaking the dimensions-based path.

**Test posture after change:**
- 148 unit tests (was 147; +1 net for the new `TestBuildReceiptPolicyKeys` class, several tests reframed). 99.21% coverage.
- 5 integration tests in `tests/integration/` (skipped at collection time when `CYCLES_BASE_URL` is unset; verified locally to pass against a fresh `cycles-server` v0.1.25.18 quickstart stack after this change).

**Wire-shape change** is the headline. Existing wire callers that were running against a hypothetical v0.1.26-extension server would see their reservations land in the same effective state — same `Subject` scope, same idempotency key, same commit / release semantics — just with the routing fields under a different field. Anyone running against a real production server was hitting `400`s and could not have been depending on the old shape.

## 2026-05-13 — live integration smoke tests (post-v0.2.0)

**Author:** post-release hygiene
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ 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.3.0] — 2026-05-13

### Changed (wire shape)
- AP2 routing context (`host`, `currency`, `payment_protocol`) moved from `Action.policy_keys` to `Subject.dimensions["payee_website" / "payment_currency" / "payment_protocol"]`. The v0.1/0.2 placement on `Action.policy_keys` was per the `cycles-action-kinds-v0.1.26.yaml` extension; production `cycles-server` v0.1.25.x doesn't yet implement that extension and rejected the field with `400 Malformed request body`. v0.3 ships these values on `Subject.dimensions` (part of the base protocol), so the wrapper works against current production servers. The client-side `RuntimeAuthorityReceipt.policy_keys` still carries the canonical shape — dashboards / dispute evidence / audit pipelines that consumed the receipt are unchanged.

### Unchanged
- Public Python API (`cycles_guard_payment`, `cycles_guard_payment_async`, all exception classes, all class attributes, `AP2Mandate`, `RuntimeAuthorityReceipt`).
- Exception contract.
- Idempotency-key derivation (still `ap2:open_mandate:{...}` or `ap2:tx:{...}`).
- All v0.2.0 features (async, commit-uncertainty handling, cancellation handling).

### Still planned for v0.4+
- Multi-currency (still raises `AP2CurrencyError` on non-USD).
- `payment.refund` convenience helper.
- Opt-in flag to re-emit `Action.policy_keys` for servers that implement the v0.1.26 extension.
- Server-verifiable runtime-authority receipt (requires `cycles-protocol` signed-receipt field).

## [0.2.0] — 2026-05-13

### Added
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,15 +133,17 @@ Required upstream attributes (duck-typed): `payment_mandate.transaction_id`, `pa
|---|---|---|
| `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` | `Action.policy_keys.custom["currency"]` | MVP enforces `"USD"` |
| `PaymentMandate.payee.website` | `Action.policy_keys.host` | required for policy routing |
| `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"` | `Action.policy_keys.custom["payment_protocol"]` | marker |
| 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
Expand Down
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.2.0"
version = "0.3.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
2 changes: 1 addition & 1 deletion runcycles_ap2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
)
from runcycles_ap2.models import AP2Mandate, RuntimeAuthorityReceipt

__version__ = "0.2.0"
__version__ = "0.3.0"

__all__ = [
"AP2CurrencyError",
Expand Down
19 changes: 15 additions & 4 deletions runcycles_ap2/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,26 @@
DEFAULT_ACTION_NAME: Final[str] = "ap2.payment_mandate.present"

# Subject.dimensions keys (lower-case, [a-z0-9_.-], <=256 chars per value, max 16 keys).
#
# The first four are AP2-specific transaction/correlation identifiers.
# The last three (payee_website / payment_currency / payment_protocol) used to ride
# on `Action.policy_keys` in v0.1.x and v0.2.x but moved here in v0.3 — see the
# "Wire shape" section of AUDIT.md. We surface them as dimensions so they reach
# the server in a shape the protocol's base Action schema accepts; the same values
# still flow into the client-side RuntimeAuthorityReceipt.policy_keys field, so
# the audit record is unchanged.
DIM_RUN_ID: Final[str] = "run_id"
DIM_AP2_TRANSACTION_ID: Final[str] = "ap2_transaction_id"
DIM_CHECKOUT_HASH: Final[str] = "checkout_hash"
DIM_OPEN_MANDATE_HASH: Final[str] = "open_mandate_hash"
DIM_PAYEE_WEBSITE: Final[str] = "payee_website"
DIM_PAYMENT_CURRENCY: Final[str] = "payment_currency"
DIM_PAYMENT_PROTOCOL: Final[str] = "payment_protocol"

# Action.policy_keys.custom keys.
CUSTOM_PAYMENT_PROTOCOL: Final[str] = "payment_protocol"
CUSTOM_CURRENCY: Final[str] = "currency"
CUSTOM_PAYMENT_PROTOCOL_VALUE: Final[str] = "ap2"
# Constant value pinned in the dimensions to mark all reservations made by this
# package as part of the AP2 payment-mandate flow (for downstream filtering /
# auditing in dashboards).
PAYMENT_PROTOCOL_VALUE: Final[str] = "ap2"

# Lifecycle defaults — payments must NOT overspend, so DENY (not ALLOW_IF_AVAILABLE).
DEFAULT_TTL_MS: Final[int] = 60_000
Expand Down
6 changes: 3 additions & 3 deletions runcycles_ap2/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
from runcycles_ap2._validation import validate_micros
from runcycles_ap2.exceptions import AP2DryRunResult, AP2GuardCommitFailed, AP2GuardCommitUncertain, AP2GuardDenied
from runcycles_ap2.mapping import (
build_action,
build_commit_body,
build_receipt_policy_keys,
build_release_body,
build_reservation_body,
)
Expand Down Expand Up @@ -387,7 +387,7 @@ def _handle_release(self, *, reason: str, exc_name: str) -> tuple[bool, str | No
def _build_receipt(self) -> None:
assert self._reservation_id is not None
assert self._decision is not None
policy_keys = build_action(self._mandate, action_kind=self._action_kind)["policy_keys"]
policy_keys = build_receipt_policy_keys(self._mandate)
committed_micros = self._actual_micros if self._actual_micros is not None else self._mandate.amount_micros()
raw_psp_ref = self._commit_metadata.get("psp_ref")
psp_ref = raw_psp_ref if isinstance(raw_psp_ref, str) else None
Expand Down Expand Up @@ -824,7 +824,7 @@ def _build_receipt(self) -> None:
# function) so attribute access stays clean and the two classes look symmetric.
assert self._reservation_id is not None
assert self._decision is not None
policy_keys = build_action(self._mandate, action_kind=self._action_kind)["policy_keys"]
policy_keys = build_receipt_policy_keys(self._mandate)
committed_micros = self._actual_micros if self._actual_micros is not None else self._mandate.amount_micros()
raw_psp_ref = self._commit_metadata.get("psp_ref")
psp_ref = raw_psp_ref if isinstance(raw_psp_ref, str) else None
Expand Down
52 changes: 42 additions & 10 deletions runcycles_ap2/mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,18 @@
from typing import Any

from runcycles_ap2._constants import (
CUSTOM_CURRENCY,
CUSTOM_PAYMENT_PROTOCOL,
CUSTOM_PAYMENT_PROTOCOL_VALUE,
DEFAULT_ACTION_NAME,
DIM_AP2_TRANSACTION_ID,
DIM_CHECKOUT_HASH,
DIM_OPEN_MANDATE_HASH,
DIM_PAYEE_WEBSITE,
DIM_PAYMENT_CURRENCY,
DIM_PAYMENT_PROTOCOL,
DIM_RUN_ID,
IDEMPOTENCY_PREFIX,
IDEMPOTENCY_SCOPE_OPEN_MANDATE,
IDEMPOTENCY_SCOPE_TRANSACTION,
PAYMENT_PROTOCOL_VALUE,
TRANSACTION_ID_HASH_LEN,
)
from runcycles_ap2._validation import validate_micros
Expand Down Expand Up @@ -122,6 +123,14 @@ def build_subject(
dimensions: dict[str, str] = {
DIM_RUN_ID: run_id,
DIM_AP2_TRANSACTION_ID: mandate.transaction_id,
# AP2 routing context — used to live on `Action.policy_keys` in v0.1/0.2;
# moved here in v0.3 for compatibility with `cycles-server` v0.1.25.x's
# base `Action` schema (which has `additionalProperties: false` and does
# not yet implement the v0.1.26 `policy_keys` extension). The same values
# are still attached to the client-side RuntimeAuthorityReceipt.
DIM_PAYEE_WEBSITE: mandate.payee_website,
DIM_PAYMENT_CURRENCY: mandate.currency.upper(),
DIM_PAYMENT_PROTOCOL: PAYMENT_PROTOCOL_VALUE,
}
if mandate.checkout_hash:
dimensions[DIM_CHECKOUT_HASH] = mandate.checkout_hash
Expand All @@ -138,16 +147,39 @@ def build_subject(


def build_action(mandate: AP2Mandate, *, action_kind: str) -> dict[str, Any]:
"""Construct the Action portion (including ``policy_keys``) of a reservation create body."""
"""Construct the Action portion of a reservation create body.

v0.3 wire-shape change: AP2 routing context (host / currency / payment_protocol)
no longer rides on ``Action.policy_keys`` — it ships on ``Subject.dimensions``
instead. See ``build_subject()`` and AUDIT.md. The same values are still
surfaced on the client-side ``RuntimeAuthorityReceipt.policy_keys`` field via
:func:`build_receipt_policy_keys` so dashboards and dispute evidence are
unchanged.

Why: ``cycles-server`` v0.1.25.x's base ``Action`` schema has
``additionalProperties: false`` and does not yet implement the v0.1.26
``policy_keys`` extension; sending the field there triggers a
``Malformed request body`` 400. Moving the values to dimensions lets the
wrapper talk to current production servers; when the extension lands
server-side, callers needing first-class policy routing can re-emit
``Action.policy_keys`` via a future opt-in flag.
"""
return {
"kind": action_kind,
"name": DEFAULT_ACTION_NAME,
"policy_keys": {
"host": mandate.payee_website,
"custom": {
CUSTOM_PAYMENT_PROTOCOL: CUSTOM_PAYMENT_PROTOCOL_VALUE,
CUSTOM_CURRENCY: mandate.currency.upper(),
},
}


def build_receipt_policy_keys(mandate: AP2Mandate) -> dict[str, Any]:
"""The same shape that used to ride on ``Action.policy_keys``, kept for the
client-side runtime-authority receipt only. Not part of the wire payload
as of v0.3.
"""
return {
"host": mandate.payee_website,
"custom": {
DIM_PAYMENT_PROTOCOL: PAYMENT_PROTOCOL_VALUE,
DIM_PAYMENT_CURRENCY: mandate.currency.upper(),
},
}

Expand Down
10 changes: 8 additions & 2 deletions tests/test_async_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ async def test_emit_receipt_false_skips_receipt(self, async_mock_client, mandate
assert guard.receipt is None
assert guard.committed is True

async def test_reserve_body_carries_policy_keys_and_consume_once_scope(self, async_mock_client) -> None:
async def test_reserve_body_carries_ap2_routing_dimensions_and_consume_once_scope(self, async_mock_client) -> None:
# v0.3 wire-shape: routing context on dimensions, open_mandate_hash drives
# the consume-once idempotency scope.
from tests.conftest import make_mandate

async_mock_client.create_reservation.return_value = allow_response()
Expand All @@ -104,7 +106,11 @@ async def test_reserve_body_carries_policy_keys_and_consume_once_scope(self, asy
body = async_mock_client.create_reservation.call_args[0][0]
assert body["idempotency_key"] == idempotency_key(m, "reserve")
assert ":open_mandate:" in body["idempotency_key"]
assert body["action"]["policy_keys"]["host"] == "merchant.example"
# Routing context lives on dimensions, not on action.policy_keys:
assert body["subject"]["dimensions"]["payee_website"] == "merchant.example"
assert body["subject"]["dimensions"]["payment_currency"] == "USD"
assert body["subject"]["dimensions"]["payment_protocol"] == "ap2"
assert "policy_keys" not in body["action"]

async def test_set_actual_micros_above_int64_rejected(self, async_mock_client, mandate) -> None:
async_mock_client.create_reservation.return_value = allow_response()
Expand Down
14 changes: 11 additions & 3 deletions tests/test_guard_clean_commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,11 @@ def test_set_actual_micros_rejects_non_int_types(self, mock_client, mandate, bad

mock_client.commit_reservation.assert_not_called()

def test_reserve_body_carries_policy_keys(self, mock_client, mandate) -> None:
def test_reserve_body_carries_ap2_routing_dimensions(self, mock_client, mandate) -> None:
# v0.3 wire-shape: AP2 routing context (payee_website / payment_currency /
# payment_protocol) ships on Subject.dimensions, NOT on Action.policy_keys.
# The v0.1.25.x cycles-server base Action schema has additionalProperties:
# false, so the old shape triggered a 400 against production servers.
mock_client.create_reservation.return_value = allow_response()
mock_client.commit_reservation.return_value = commit_success_response()

Expand All @@ -133,7 +137,11 @@ def test_reserve_body_carries_policy_keys(self, mock_client, mandate) -> None:

body = mock_client.create_reservation.call_args[0][0]
assert body["idempotency_key"] == idempotency_key(mandate, "reserve")
assert body["action"]["policy_keys"]["host"] == "merchant.example"
assert body["action"]["policy_keys"]["custom"]["payment_protocol"] == "ap2"
# Routing context moved to dimensions:
assert body["subject"]["dimensions"]["payee_website"] == "merchant.example"
assert body["subject"]["dimensions"]["payment_currency"] == "USD"
assert body["subject"]["dimensions"]["payment_protocol"] == "ap2"
assert body["subject"]["dimensions"]["ap2_transaction_id"] == "ap2-tx-001"
# Regression: Action must NOT carry policy_keys on the wire any more.
assert "policy_keys" not in body["action"]
assert body["overage_policy"] == "REJECT"
Loading