Skip to content

feat(wire): move AP2 routing context off Action.policy_keys onto Subject.dimensions (v0.3.0)#6

Merged
amavashev merged 1 commit into
mainfrom
feat/v0.3.0-policy-keys-off-wire
May 13, 2026
Merged

feat(wire): move AP2 routing context off Action.policy_keys onto Subject.dimensions (v0.3.0)#6
amavashev merged 1 commit into
mainfrom
feat/v0.3.0-policy-keys-off-wire

Conversation

@amavashev
Copy link
Copy Markdown
Contributor

@amavashev amavashev commented May 13, 2026

Trigger

The v0.2.0 integration smoke tests ran against a real cycles-server (v0.1.25.18) for the first time. All 5 tests failed with AP2GuardDenied: AP2 reservation failed. Direct curl reproduced the cause: server returned 400 INVALID_REQUEST: Malformed request body.

The unit-test suite (147 MagicMock-based tests) could not catch this — every mock accepts any wire shape. The integration suite caught it on its first real run. This PR is exactly the kind of defect those tests exist to find.

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 implements only the base cycles-protocol-v0.yaml Action schema, which has additionalProperties: false and lists only {kind, name, tags}. The unknown policy_keys field 400'd.

Fix (Option B from the post-mortem)

Move the three routing values from Action.policy_keys to Subject.dimensions:

v0.1/0.2 (rejected by production server) v0.3 (accepted)
Action.policy_keys.host Subject.dimensions["payee_website"]
Action.policy_keys.custom["currency"] Subject.dimensions["payment_currency"]
Action.policy_keys.custom["payment_protocol"] Subject.dimensions["payment_protocol"]

Subject.dimensions is part of the base protocol (already used for ap2_transaction_id, checkout_hash, open_mandate_hash, run_id).

The client-side RuntimeAuthorityReceipt.policy_keys field still carries the canonical shape {host, custom: {payment_protocol, payment_currency}} — built from a new internal helper mapping.build_receipt_policy_keys(mandate). Dashboards, dispute evidence, and audit pipelines consuming the receipt are unchanged.

What's NOT changed

  • 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:{...} / ap2:tx:{...}).
  • All v0.2.0 features (async, commit-uncertainty, cancellation).

End-to-end verification

After the change, brought up cycles-server:0.1.25.18 via quickstart.sh, provisioned a $10 tenant budget (using the admin key this time — quickstart.sh uses the tenant key for budget creation, which silently fails and masked the wire-shape issue further), set env vars, ran the integration suite:

tests/integration/test_live_ap2_guard.py
  TestLiveSyncGuard::test_clean_commit_against_live_server          PASSED
  TestLiveSyncGuard::test_exception_inside_with_releases            PASSED
  TestLiveSyncGuard::test_dry_run_raises_result_no_reservation...   PASSED
  TestLiveSyncGuard::test_idempotent_replay_returns_same_res...     PASSED
  TestLiveAsyncGuard::test_async_clean_commit_against_live_server   PASSED

5 passed in 2.02s

Test plan

  • ruff check . && ruff format --check . — clean
  • mypy --strict runcycles_ap2 — 0 errors (8 files)
  • pytest --cov=runcycles_ap2 --cov-fail-under=95148 passed, 5 skipped (integration), 99.21% coverage
  • python -m build — sdist + wheel build cleanly as runcycles_ap2-0.3.0
  • Integration tests pass against cycles-server:0.1.25.18 (see above)
  • CI green on this PR (will skip integration suite as expected)

Version + release

pyproject.toml and __version__ bumped to 0.3.0. Semver minor because the public Python API is unchanged and the wire shape was rejected in production anyway — nobody could have been depending on it.

Future work (still 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)

…ect.dimensions (v0.3.0)

Trigger
=======
The v0.2.0 integration smoke tests ran against a real cycles-server
(0.1.25.18, current ghcr image) for the first time. All 5 tests
failed with AP2GuardDenied — direct curl reproduced the cause: the
server rejected the reserve body with 400 INVALID_REQUEST "Malformed
request body".

Root cause
==========
The wrapper sent the 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 implements only the base cycles-protocol-v0
Action schema which has `additionalProperties: false` and lists only
{kind, name, tags}. The unknown policy_keys field 400'd.

The unit test suite (147 mock-based tests) couldn't catch this — every
mock accepts any shape we hand it. The integration suite (5 tests
gated behind CYCLES_BASE_URL) caught it the first time it ran.

Fix
===
Move the three routing values from Action.policy_keys to
Subject.dimensions:

  Action.policy_keys.host                 → Subject.dimensions["payee_website"]
  Action.policy_keys.custom["currency"]   → Subject.dimensions["payment_currency"]
  Action.policy_keys.custom["payment_protocol"] → Subject.dimensions["payment_protocol"]

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 cleanly on every server version we
support.

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 uses a new internal helper
mapping.build_receipt_policy_keys(mandate) rather than reading the
shape off the action body.

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

Verified end-to-end
===================
After the change, brought up cycles-server v0.1.25.18 via
quickstart.sh, provisioned a $10 tenant budget (using the admin key
this time — quickstart's tenant-key budget creation silently failed,
which masked the wire-shape issue further), set env vars, ran the
integration suite:

  tests/integration/test_live_ap2_guard.py::TestLiveSyncGuard
    test_clean_commit_against_live_server                  PASSED
    test_exception_inside_with_releases                    PASSED
    test_dry_run_raises_result_no_reservation_created      PASSED
    test_idempotent_replay_returns_same_reservation_id     PASSED
  tests/integration/test_live_ap2_guard.py::TestLiveAsyncGuard
    test_async_clean_commit_against_live_server            PASSED

All 5 passed against a real cycles-server.

Test posture
============
- 148 unit tests (was 147; net +1 for the new TestBuildReceiptPolicyKeys
  class; several tests reframed to assert on Subject.dimensions instead
  of action.policy_keys).
- 99.21% coverage.
- ruff + ruff format clean.
- mypy --strict 0 errors.
- python -m build builds 0.3.0 sdist + wheel cleanly.

Version bumped to 0.3.0 (wire-shape change, semver minor since the
PUBLIC API is unchanged; private wire callers were 400'ing anyway and
couldn't have been depending on the old shape in production).

Not a permanent ban on Action.policy_keys: when cycles-server ships
the v0.1.26 extension, callers needing first-class policy routing can
re-emit Action.policy_keys via a future opt-in flag without breaking
the dimensions-based path.

AUDIT.md, CHANGELOG.md, README.md (AP2 → Cycles wire mapping table)
all updated.
@amavashev amavashev merged commit 00cd892 into main May 13, 2026
6 checks passed
@amavashev amavashev deleted the feat/v0.3.0-policy-keys-off-wire branch May 13, 2026 22:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant