Skip to content

feat: initial v0.1.0 scaffold for AP2 runtime authority guard#1

Merged
amavashev merged 7 commits into
mainfrom
feat/v0.1.0-initial-scaffold
May 13, 2026
Merged

feat: initial v0.1.0 scaffold for AP2 runtime authority guard#1
amavashev merged 7 commits into
mainfrom
feat/v0.1.0-initial-scaffold

Conversation

@amavashev
Copy link
Copy Markdown
Contributor

@amavashev amavashev commented May 13, 2026

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 Cycles reserve / commit / release lifecycle.
  • 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.
  • Deterministic idempotency keys: ap2:{transaction_id}:reserve, ap2:{transaction_id}:commit, ap2:{transaction_id}:release:{ExcType} — the consume-once defense.
  • Uses built-in payment.charge action kind from cycles-action-kinds-v0.1.26.yamlno protocol PR required.

Parity with cycles-client-python

  • Tooling: hatchling, Python ≥ 3.10, ruff (E,F,I,UP; line 120), mypy --strict, pytest, ≥95% coverage gate.
  • CI: all 4 sibling workflows mirrored — ci.yml (reusable runcycles/.github/.github/workflows/ci-python.yml@v1), python-publish.yml, dependabot-auto-merge.yml, scorecard.yml.
  • Repo meta: README with badges + SEO-tuned H1; AUDIT.md, CHANGELOG.md, CLAUDE.md, MAINTAINERS.md; pyproject.toml with 31 keywords / 11 classifiers / 7 project URLs.
  • GitHub-side SEO: description, homepage, 20 topics (ap2, agent-payments-protocol, payment-mandate, consume-once, runtime-authority, etc.) applied.

Scope of v0.1 (out of scope: v0.2+)

In scope Out of scope
Sync context manager Async (AsyncGuardedPayment → v0.2)
USD payments Multi-currency → v0.2
Caller-passed signed mandates Mandate signing / verification (delegated to AP2 SDK)
Built-in payment.charge Custom action kinds requiring server registration
Single-charge flows Partial capture, multi-shipment, split-tender

Receipts are client-side in v0.1 (informational, not server-verifiable). Server-signed receipts land in v0.3 once cycles-protocol adds a signed-receipt field.

Test plan

  • ruff check . — clean (18 files)
  • ruff format --check . — clean
  • mypy --strict runcycles_ap2 — 0 errors (7 files)
  • pytest --cov=runcycles_ap2 --cov-fail-under=9553 passed, 97.89% coverage
  • pip install -e .[dev] — installs and imports cleanly
  • python -c "import runcycles_ap2; print(runcycles_ap2.__all__)" — public exports verified
  • Manual smoke test against a live Cycles server with examples/ap2_human_not_present.py (reviewer / merger)
  • Confirm CI green on this PR before merge

Notes for reviewers

  • Public surface kept minimal: 8 exports (cycles_guard_payment, GuardedPayment, AP2Mandate, RuntimeAuthorityReceipt, 4 exceptions, __version__).
  • mapping.py is pure and unit-tested without a client — wire-shape changes review there.
  • _handle_commit never auto-releases on RESERVATION_FINALIZED / RESERVATION_EXPIRED / IDEMPOTENCY_MISMATCH — a previous commit may already have charged.
  • extra_dimensions keys must conform to the protocol regex ^[a-z0-9_.-]+$ (caller's responsibility; not validated client-side in v0.1).

amavashev added 2 commits May 13, 2026 08:48
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.
@amavashev
Copy link
Copy Markdown
Contributor Author

Review fixes pushed (0685a75)

All four findings addressed. Quick evaluation, then commit summary:

Finding Verdict Fix
P1 idempotency-key collision on long tx_ids confirmed bug — broke consume-once ap2:{sha256(tx)[:32]}:{phase}[:{suffix}] — fixed-length, header-safe, phase always preserved. 128-bit collision resistance. Raw tx_id stays on Subject.dimensions["ap2_transaction_id"] for debug
P2 dry-run still ran the with body confirmed foot-gun — real PSP would move money off the books under DRY_RUN=1 __enter__ raises new AP2DryRunResult exception carrying the decision payload. The body is unreachable in dry-run mode. Example reworked to use try/except
P2 decimal validation gaps confirmed — NaN/Infinity parsed, then raised raw OverflowError/InvalidOperation later; sub-micro silently rounded Add is_finite() check, reject exponent < -8, wrap all decimal failures as AP2MandateError
P2 commit failures invisible confirmed — silent guard.committed == False after release was easy to miss → unreconciled state New AP2GuardCommitFailed exception raised after release in the unrecognized-rejection branch. RESERVATION_FINALIZED/EXPIRED/IDEMPOTENCY_MISMATCH still return silently (benign replays)

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:

  • AP2DryRunResult exception (carries decision, reason_code, caps, balances, affected_scopes)
  • AP2GuardCommitFailed exception (carries error_code, request_id, reservation_id)

Gates re-verified locally (all green):

  • ruff check . && ruff format --check . — clean
  • mypy --strict runcycles_ap2 — 0 errors (7 files)
  • pytest --cov=runcycles_ap2 --cov-fail-under=9562 passed, 97.45% coverage (up from 53/97.89%; the new regression tests for P1 + P2 added 9 tests, 4 of them direct regressions for the long-tx_id collision and header-unsafe-char paths)
  • python -m build — sdist + wheel build cleanly

AUDIT.md and CHANGELOG.md updated to record the four fixes and the API additions.

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.
@amavashev
Copy link
Copy Markdown
Contributor Author

Round-2 fixes pushed (8f0b4ee)

All three findings addressed:

# Finding Fix
P2 amount_micros() used Decimal multiplication under the default 28-digit context — silently rounded large inputs (e.g. 29-digit 123456789012345678901.12345678 produced ...680 instead of ...678) Rewrote conversion to operate on Decimal.as_tuple() digits directly. Exact integer math, no context dependence. New test test_large_value_converts_exactly covers the regression. Removed the now-unused USD_MICROCENTS_PER_DOLLAR constant
P2 AP2GuardCommitFailed always said "reservation released" in its message, even when _handle_release() swallowed a transport failure or non-success response. Caller couldn't tell budget was stranded _handle_release() now returns (success: bool, error_detail: str | None). AP2GuardCommitFailed gained .released and .release_error attributes; the message says either "reservation released" or "reservation release FAILED ... budget stranded until TTL". Two new tests cover the transport-fail and non-success-response paths
P3 README lifecycle table rows still referenced raw ap2:{transaction_id}:... keys (contradicting the keys section that already showed the hashed shape) Rewrote the rows; also updated the unrecognized-commit-rejection row to reflect the release+raise behavior and the exception's new fields

Gates re-verified locally (all green):

  • ruff check . && ruff format --check . — clean
  • mypy --strict runcycles_ap2 — 0 errors (7 files)
  • pytest --cov=runcycles_ap2 --cov-fail-under=9566 passed, 97.87% coverage (up from 62/97.45%; 4 new regression tests + 1 dropped from the constant removal)
  • python -m build — sdist + wheel build cleanly

Public API additions in this round:

  • AP2GuardCommitFailed.released: bool
  • AP2GuardCommitFailed.release_error: str | None

AUDIT.md and CHANGELOG.md updated to record both fixes and the new exception fields.

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.
@amavashev
Copy link
Copy Markdown
Contributor Author

Round-3 fixes pushed (c220f25)

Both findings addressed.

# Finding Fix
P2 Exponent-notation DoS: Decimal("1E+1000000000000") (16 chars, finite, positive) passed every validation, then tried to allocate a trillion-digit integer via 10**shift. Even 0E+1000000000000 triggered the allocation before zeroing Pre-allocation digit-count cap: len(digits) + max(0, exponent) <= 19 (int64 USD_MICROCENTS ceiling). Bound is checked before any 10**shift math, so the allocation never happens. Three regression tests cover the two DoS strings and a 20-digit legitimate-shaped overflow. Earlier test_large_value_converts_exactly was updated to use int64.max (92233720368.54775807) — still exercises the no-rounding path within the new cap
P3 README mapping row still claimed int(round(value * 1e8)); GuardedPayment docstring still showed ap2:{tx}:commit raw keys README row rewritten to "exact integer conversion, rejects NaN/Infinity/sub-micro/over-int64". GuardedPayment.__doc__ rewritten to reference runcycles_ap2.mapping.idempotency_key, dry-run probe behavior, and AP2GuardCommitFailed.released/release_error. Source readers and README readers now see the same picture

Gates re-verified locally (all green):

  • ruff check . && ruff format --check . — clean
  • mypy --strict runcycles_ap2 — 0 errors (7 files)
  • pytest --cov=runcycles_ap2 --cov-fail-under=9569 passed, 97.89% coverage (up from 66)
  • python -m build — sdist + wheel build cleanly

No public API additions in this round. AUDIT.md + CHANGELOG.md updated.

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.
@amavashev
Copy link
Copy Markdown
Contributor Author

Round-4 fix pushed (deca366)

Finding Fix
P2 the 19-digit pre-allocation cap permitted values one over int64.max (e.g. 92233720368.547758089_223_372_036_854_775_808; 99999999999.99999999 → ~10¹⁹ micros) Added post-conversion check micros <= _MAX_USD_MICROS (2**63 - 1). The digit cap stays as the pre-allocation DoS guard; this is the exact protocol boundary. Three regression tests: int64.max exact accepted, int64.max + 1 rejected, 19-all-9-digit value rejected

Gates re-verified locally (all green):

  • ruff check . && ruff format --check . — clean
  • mypy --strict runcycles_ap2 — 0 errors (7 files)
  • pytest --cov=runcycles_ap2 --cov-fail-under=9572 passed, 97.92% coverage (up from 69)
  • python -m build — sdist + wheel build cleanly

No public API additions. AUDIT.md + CHANGELOG.md updated.

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.
@amavashev
Copy link
Copy Markdown
Contributor Author

Round-5 fix pushed (05c2aaf)

Finding Fix
P2 set_actual_micros() only rejected negatives; a caller could pass 2**63 and bypass the int64 cap added to AP2Mandate.amount_micros() in round 4. Verified: the wire commit body would carry actual.amount = 9223372036854775808 Mirror the 0 <= amount <= MAX_USD_MICROS bound in set_actual_micros(). Extracted MAX_USD_MICROS = 2**63 - 1 to _constants.py so models.py and guard.py share one source of truth. Three regression tests: int64.max accepted, int64.max + 1 rejected (no commit, release fires), -1 rejected. Both bounds now raise AP2MandateError (a ValueError subclass) instead of plain ValueError so all amount-validation errors are catchable via one exception type

Gates re-verified locally (all green):

  • ruff check . && ruff format --check . — clean
  • mypy --strict runcycles_ap2 — 0 errors (7 files)
  • pytest --cov=runcycles_ap2 --cov-fail-under=9575 passed, 98.23% coverage (up from 72)
  • python -m build — sdist + wheel build cleanly

Minor public API change: set_actual_micros() now raises AP2MandateError rather than plain ValueError. Catchers of ValueError are unaffected (it's a subclass). AUDIT.md + CHANGELOG.md updated.

…_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.
@amavashev
Copy link
Copy Markdown
Contributor Author

Round-6 fix pushed (d04888f)

Finding Fix
P2 set_actual_micros(True) and set_actual_micros(1.5) bypassed the int64 bound (bool is an int subclass, float compares numerically). The 1.5 case only surfaced as a pydantic error during receipt construction — after the commit POST had already gone out New private runcycles_ap2._validation.validate_micros helper using type(amount) is int (intentionally NOT isinstance) to reject bool and everything non-int. Applied in both GuardedPayment.set_actual_micros AND mapping.build_commit_body(actual_micros=...) so direct callers of the builder get the same protection. 14 new parametrized tests cover True/False/1.5/1.0/"100"/None/[100] across both entry points

Gates re-verified locally (all green):

  • ruff check . && ruff format --check . — clean
  • mypy --strict runcycles_ap2 — 0 errors (8 files)
  • pytest --cov=runcycles_ap2 --cov-fail-under=9590 passed, 98.29% coverage (up from 75)
  • python -m build — sdist + wheel build cleanly

New internal module: runcycles_ap2/_validation.py (private, not exported). No public API additions. AUDIT.md + CHANGELOG.md updated.

@amavashev amavashev merged commit b2b14c9 into main May 13, 2026
3 checks passed
@amavashev amavashev deleted the feat/v0.1.0-initial-scaffold branch May 13, 2026 14:06
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