feat: initial v0.1.0 scaffold for AP2 runtime authority guard#1
Conversation
Add `cycles_guard_payment` sync context manager wrapping a Cycles
reserve / commit / release lifecycle around a Google AP2 PaymentMandate.
AP2 proves intent; Cycles enforces runtime exposure before that intent
is exercised — preventing mandate reuse, double-spend, and concurrent
checkout attempts under retries and fan-out.
What this lands:
- `runcycles_ap2` package: `cycles_guard_payment`, `AP2Mandate` adapter,
`RuntimeAuthorityReceipt`, deterministic idempotency keys
(`ap2:{transaction_id}:reserve|commit|release:{ExcType}`).
- Built-in `payment.charge` action kind — no protocol PR required.
- USD-only in v0.1; raises `AP2CurrencyError` on non-USD.
- Mirrors sibling `cycles-client-python` conventions: hatchling,
ruff (E,F,I,UP; line 120), mypy --strict, pytest, ≥95% coverage gate.
- CI parity: ci.yml, python-publish.yml, dependabot-auto-merge.yml,
scorecard.yml.
- SEO-tuned pyproject.toml (31 keywords, 11 classifiers, 7 project URLs).
- End-to-end example: examples/ap2_human_not_present.py.
Test posture:
- 53 tests, 97.89% coverage.
- mypy --strict clean (7 files).
- ruff check + ruff format --check clean (18 files).
Receipt note:
- `RuntimeAuthorityReceipt` is client-side derived from the ALLOW + COMMIT
responses and is NOT signed by the Cycles server in protocol v0.1.26.
Server-verifiable variant lands in v0.3 once cycles-protocol adds a
signed-receipt field.
P1 — idempotency_key() used to slice phase suffix off long transaction_ids
and let unsanitized chars reach the Idempotency-Key header, breaking the
consume-once defense. Two 256-char tx_ids sharing the first 252 chars
collided on reserve. Switch to ap2:{sha256(tx)[:32]}:{phase}[:{suffix}] —
fixed-length, header-safe, phase always preserved (128-bit collision
resistance). Raw tx_id stays on Subject.dimensions for debug.
P2 — dry_run=True returned a guard normally and let the `with` body run;
only __exit__ skipped commit/release. A real PSP call inside the block
would move money off the books. __enter__ now raises AP2DryRunResult
(new exception carrying decision payload) so the body is unreachable.
Example reworked to use try/except for dry-run.
P2 — amount_micros() accepted NaN, +/-Infinity, and silently rounded
sub-micro precision, then later raised raw decimal.InvalidOperation or
OverflowError. Add is_finite() check, reject exponent < -8, wrap all
decimal failures as AP2MandateError.
P2 — unrecognized commit rejection used to release silently and exit
normally; caller's only signal was guard.committed == False, easy to
miss → unreconciled payment state. New AP2GuardCommitFailed exception
is raised after release in that branch. RESERVATION_FINALIZED /
RESERVATION_EXPIRED / IDEMPOTENCY_MISMATCH remain silent (benign
replays of a prior attempt).
Public API additions:
- AP2DryRunResult exception
- AP2GuardCommitFailed exception
Wire shape change: idempotency keys use a SHA-256 prefix of the
transaction_id instead of the raw value. Pre-release, so no migration.
Test posture: 62 tests (up from 53), 97.45% coverage. ruff + mypy
strict clean. `python -m build` produces sdist + wheel cleanly.
AUDIT.md and CHANGELOG.md updated.
Review fixes pushed (0685a75)All four findings addressed. Quick evaluation, then commit summary:
Wire shape change: idempotency keys now use a SHA-256 prefix of the transaction_id rather than the raw value. Pre-release so no migration cost — but flagging it. Public API additions:
Gates re-verified locally (all green):
|
P2 — amount_micros() used `value * 10**8` as a Decimal multiplication,
which uses the default 28-digit decimal context and silently rounded
inputs larger than the protocol cap. Rewrote conversion to operate
directly on Decimal.as_tuple() digits: exact integer math, no context
dependence, no possibility of silent rounding. Removed the now-unused
USD_MICROCENTS_PER_DOLLAR constant. New regression test confirms a
29-digit input converts exactly.
P2 — AP2GuardCommitFailed previously said "reservation released"
regardless of whether the release attempt succeeded; if release
transport-failed or returned 5xx, budget was stranded and the caller
had no signal. _handle_release() now returns (success, error_detail);
AP2GuardCommitFailed gained .released (bool) and .release_error
(str | None) attributes; the message reflects the actual outcome
("released" vs "release FAILED ... budget stranded until TTL").
Two regression tests cover the transport-failure and non-success-
response paths.
P3 — README lifecycle table still referenced raw
ap2:{transaction_id}:commit / :release:{ExcType} shapes, contradicting
the hashed shape documented elsewhere. Rewrote the table rows to
match and to reflect the new release+raise behavior.
Test posture: 66 tests (up from 62), 97.87% coverage. ruff + mypy
strict clean. `python -m build` produces sdist + wheel cleanly.
AUDIT.md and CHANGELOG.md updated.
Round-2 fixes pushed (8f0b4ee)All three findings addressed:
Gates re-verified locally (all green):
Public API additions in this round:
|
P2 — amount_micros() had an exponent-notation DoS: a short, finite,
positive Decimal like `Decimal("1E+1000000000000")` (16 chars, well
inside the 64-char field) passed every validation, then tried to
compute `10 ** (10**12 + 8)` and allocate a trillion-digit integer.
Even `Decimal("0E+1000000000000")` triggered the same allocation
before the multiplication zeroed the result. Add a pre-allocation
digit-count cap: `len(digits) + max(0, exponent) <= 19` (digit count
of int64.max, the protocol USD_MICROCENTS ceiling). Three regression
tests cover the DoS strings and a 20-digit legitimate-shaped overflow.
Also updated the prior `test_large_value_converts_exactly` which used
a 29-digit value beyond int64 — now uses int64.max exactly, still
exercises the no-rounding path.
P3 — two doc rows still pointed at the old behavior:
- README mapping row claimed `int(round(value * 1e8))`, contradicting
the current exact-integer-tuple conversion that rejects sub-micro
precision and out-of-range amounts.
- `GuardedPayment` class docstring still showed `ap2:{tx}:commit`
raw idempotency keys, contradicting the hashed shape that has
been in use since round 1.
Both updated to match the implementation; the docstring now also
mentions the dry-run probe behavior and the `AP2GuardCommitFailed`
released/release_error fields so source readers get the same picture
as the README readers.
Test posture: 69 tests (up from 66), 97.89% coverage. ruff + mypy
strict clean. `python -m build` produces sdist + wheel cleanly.
No public API additions.
AUDIT.md and CHANGELOG.md updated.
Round-3 fixes pushed (c220f25)Both findings addressed.
Gates re-verified locally (all green):
No public API additions in this round. |
The round-3 fix bounded `len(digits) + max(0, exponent) <= 19` to block the DoS allocation. The cap correctly rejects 20-digit inputs but permits values one over int64.max — e.g. `92233720368.54775808` yields micros == int64.max + 1 (9_223_372_036_854_775_808), and `99999999999.99999999` yields ~1e19 micros, both still within the 19-digit cap. The server would reject them but client-side we'd already have shipped the wrong number. Add a post-conversion check `micros <= _MAX_USD_MICROS` (2**63 - 1) right before the return. The 19-digit cap remains as the pre-allocation DoS guard; this is the exact protocol boundary. Three regression tests: - int64.max exact (92233720368.54775807) — accepted - int64.max + 1 (92233720368.54775808) — rejected - 19 all-9 digits (99999999999.99999999) — rejected Test posture: 72 tests (up from 69), 97.92% coverage. ruff + mypy strict clean. `python -m build` produces sdist + wheel cleanly. No public API additions. AUDIT.md and CHANGELOG.md updated.
Round-4 fix pushed (deca366)
Gates re-verified locally (all green):
No public API additions. |
Round 4 added the int64 cap to AP2Mandate.amount_micros(), but the caller-supplied commit override on GuardedPayment.set_actual_micros() only rejected negative values. `guard.set_actual_micros(2**63)` would flow through unchecked and ship `actual.amount = 9223372036854775808` in the commit body. The server would reject it, but client-side we'd already have sent the wrong number. Mirror the same `0 <= amount <= MAX_USD_MICROS` validation as the mandate-derived path. Extracted `MAX_USD_MICROS = 2**63 - 1` to `_constants.py` so models.py and guard.py share the source of truth. Switched set_actual_micros() to raise `AP2MandateError` (a ValueError subclass) for both bounds, so all amount-validation errors are catchable via one exception type. Three regression tests: - int64.max accepted as a legal override - int64.max + 1 rejected; commit not called, release called - -1 rejected; commit not called, release called Test posture: 75 tests (up from 72), 98.23% coverage. ruff + mypy strict clean. `python -m build` produces sdist + wheel cleanly. Minor public API shift: set_actual_micros() now raises AP2MandateError instead of plain ValueError. Code catching ValueError still works. AUDIT.md and CHANGELOG.md updated.
Round-5 fix pushed (05c2aaf)
Gates re-verified locally (all green):
Minor public API change: |
…_body (P2 round 6) bool is a subclass of int in Python (isinstance(True, int) is True), and float < int compares numerically. So set_actual_micros(True) and set_actual_micros(1.5) both slipped past the round-5 numerical bounds and would ship `true` / `1.5` as `actual.amount` on the wire. The 1.5 case only surfaced as a pydantic error during receipt construction — *after* the commit POST had already gone out, which is a much worse failure mode than a clean client-side reject. Add a private `runcycles_ap2._validation.validate_micros` helper that uses `type(amount) is int` (intentionally NOT isinstance) to reject bool and everything else non-int. Apply it in two places that share the failure mode: - GuardedPayment.set_actual_micros() — caller-facing override - mapping.build_commit_body(actual_micros=...) — direct-builder path 14 new tests cover True / False / 1.5 / 1.0 / "100" / None / [100] across both entry points, plus the existing negative + over-int64 paths via the shared helper. Test posture: 90 tests (up from 75), 98.29% coverage. ruff + mypy strict clean. `python -m build` produces sdist + wheel cleanly. New internal module: runcycles_ap2/_validation.py (private, not exported). No public API additions. AUDIT.md and CHANGELOG.md updated.
Round-6 fix pushed (d04888f)
Gates re-verified locally (all green):
New internal module: |
Summary
Lands the v0.1.0 scaffold of
runcycles-ap2— a Cycles runtime authority guard for Google AP2 (Agent Payments Protocol). AP2 proves user intent through signed mandates; Cycles enforces runtime exposure (reservation, idempotency, quota, consume-once) before that intent is exercised, so a valid mandate cannot be over-exercised under retries, fan-out, or concurrent checkout attempts.Thesis: AP2 proves intent. Cycles enforces runtime exposure before that intent is exercised.
What ships
runcycles_ap2.cycles_guard_payment(...)— sync context manager wrapping a single AP2 payment moment in a Cyclesreserve / commit / releaselifecycle.AP2Mandate— adapter type that insulates the wrapper from upstream AP2 SDK schema churn.RuntimeAuthorityReceipt(schema = runtime_authority.ap2.payment.charge.v1) — client-side derived receipt to persist alongside AP2 dispute evidence.ap2:{transaction_id}:reserve,ap2:{transaction_id}:commit,ap2:{transaction_id}:release:{ExcType}— the consume-once defense.payment.chargeaction kind fromcycles-action-kinds-v0.1.26.yaml— no protocol PR required.Parity with
cycles-client-python--strict, pytest, ≥95% coverage gate.ci.yml(reusableruncycles/.github/.github/workflows/ci-python.yml@v1),python-publish.yml,dependabot-auto-merge.yml,scorecard.yml.AUDIT.md,CHANGELOG.md,CLAUDE.md,MAINTAINERS.md;pyproject.tomlwith 31 keywords / 11 classifiers / 7 project URLs.ap2,agent-payments-protocol,payment-mandate,consume-once,runtime-authority, etc.) applied.Scope of v0.1 (out of scope: v0.2+)
AsyncGuardedPayment→ v0.2)payment.chargeReceipts are client-side in v0.1 (informational, not server-verifiable). Server-signed receipts land in v0.3 once
cycles-protocoladds a signed-receipt field.Test plan
ruff check .— clean (18 files)ruff format --check .— cleanmypy --strict runcycles_ap2— 0 errors (7 files)pytest --cov=runcycles_ap2 --cov-fail-under=95— 53 passed, 97.89% coveragepip install -e .[dev]— installs and imports cleanlypython -c "import runcycles_ap2; print(runcycles_ap2.__all__)"— public exports verifiedexamples/ap2_human_not_present.py(reviewer / merger)Notes for reviewers
cycles_guard_payment,GuardedPayment,AP2Mandate,RuntimeAuthorityReceipt, 4 exceptions,__version__).mapping.pyis pure and unit-tested without a client — wire-shape changes review there._handle_commitnever auto-releases onRESERVATION_FINALIZED/RESERVATION_EXPIRED/IDEMPOTENCY_MISMATCH— a previous commit may already have charged.extra_dimensionskeys must conform to the protocol regex^[a-z0-9_.-]+$(caller's responsibility; not validated client-side in v0.1).