feat(wire): move AP2 routing context off Action.policy_keys onto Subject.dimensions (v0.3.0)#6
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 withAP2GuardDenied: AP2 reservation failed. Direct curl reproduced the cause: server returned400 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 insideAction.policy_keys— the shape defined in the v0.1.26 protocol extension (cycles-action-kinds-v0.1.26.yaml). Productioncycles-serverimplements only the basecycles-protocol-v0.yamlActionschema, which hasadditionalProperties: falseand lists only{kind, name, tags}. The unknownpolicy_keysfield 400'd.Fix (Option B from the post-mortem)
Move the three routing values from
Action.policy_keystoSubject.dimensions:Action.policy_keys.hostSubject.dimensions["payee_website"]Action.policy_keys.custom["currency"]Subject.dimensions["payment_currency"]Action.policy_keys.custom["payment_protocol"]Subject.dimensions["payment_protocol"]Subject.dimensionsis part of the base protocol (already used forap2_transaction_id,checkout_hash,open_mandate_hash,run_id).The client-side
RuntimeAuthorityReceipt.policy_keysfield still carries the canonical shape{host, custom: {payment_protocol, payment_currency}}— built from a new internal helpermapping.build_receipt_policy_keys(mandate). Dashboards, dispute evidence, and audit pipelines consuming the receipt are unchanged.What's NOT changed
cycles_guard_payment,cycles_guard_payment_async, all exception classes, all class attributes,AP2Mandate,RuntimeAuthorityReceipt.ap2:open_mandate:{...}/ap2:tx:{...}).End-to-end verification
After the change, brought up
cycles-server:0.1.25.18viaquickstart.sh, provisioned a$10tenant budget (using the admin key this time —quickstart.shuses the tenant key for budget creation, which silently fails and masked the wire-shape issue further), set env vars, ran the integration suite:Test plan
ruff check . && ruff format --check .— cleanmypy --strict runcycles_ap2— 0 errors (8 files)pytest --cov=runcycles_ap2 --cov-fail-under=95— 148 passed, 5 skipped (integration), 99.21% coveragepython -m build— sdist + wheel build cleanly asruncycles_ap2-0.3.0cycles-server:0.1.25.18(see above)Version + release
pyproject.tomland__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+)
AP2CurrencyErroron non-USD)payment.refundconvenience helperAction.policy_keysfor servers that implement the v0.1.26 extensioncycles-protocolsigned-receipt field)