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.
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"](wasAction.policy_keys.host)Subject.dimensions["payment_currency"](wasAction.policy_keys.custom["currency"])Subject.dimensions["payment_protocol"](wasAction.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. Whencycles-serverships the v0.1.26 extension and operators want first-class policy routing, the wrapper can re-emitAction.policy_keysvia 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
TestBuildReceiptPolicyKeysclass, several tests reframed). 99.21% coverage. - 5 integration tests in
tests/integration/(skipped at collection time whenCYCLES_BASE_URLis unset; verified locally to pass against a freshcycles-serverv0.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 400s and could not have been depending on the old shape.
Author: post-release hygiene Scope: test surface only; no public API change, no wire change, no version bump
Added tests/integration/test_live_ap2_guard.py — five smoke tests (four sync + one async) exercising the AP2 wrapper end-to-end against a real Cycles server. Pattern mirrors cycles-client-python/tests/integration/test_live_server.py: module-level pytest.mark.skipif(not CYCLES_BASE_URL) so the whole file is skipped at collection time when env vars are unset. Default pytest runs and CI ignore it. Run locally with CYCLES_BASE_URL=... CYCLES_API_KEY=... CYCLES_TENANT=... pytest tests/integration -v.
Each test uses a fresh UUID-based transaction_id and a 0.00000001 USD amount so the suite is idempotent across runs and doesn't consume meaningful budget. Covers: sync clean commit, sync exception→release, sync dry-run, async clean commit with open_mandate_hash scope, sync idempotent replay.
This catches wire-shape regressions that the existing 147 mock-based tests can't (e.g., a server-side rename of a Subject.dimensions key, a field that flips from string to enum, etc.).
Author: v0.2.0 release Scope: new public API surface (async). No wire-shape, exception, or validation changes.
- Added
runcycles_ap2.AsyncGuardedPayment— async context manager (async with) that mirrorsGuardedPaymentexactly. - Added
runcycles_ap2.cycles_guard_payment_async(...)factory. - The async variant uses
runcycles.AsyncCyclesClientfor I/O; everything else (idempotency keying, mapping, validation, exceptions, receipt construction) is shared via the existing module-level helpers. - Behaviour parity with the sync variant on every documented contract: same
AP2GuardDeniedon DENY / failed reserve, sameAP2DryRunResulton dry-run, sameAP2GuardCommitUncertainon post-PSP unknown outcomes (terminal codes / transport / 5xx / uncaught), sameAP2GuardCommitFailedon 4xx unrecognized commit rejection, same auto-release on exception inside the body, same idempotency-key derivation including the open-mandate consume-once scope. - New example:
examples/ap2_human_not_present_async.py. - README quickstart now includes an "Async variant (v0.2+)" snippet.
Public API additions:
AsyncGuardedPaymentclasscycles_guard_payment_async(...)factory
Test posture after addition (including the audit follow-up commit and the CancelledError fix):
- 147 tests (up from 110), 99.20% coverage.
- 37 tests in
tests/test_async_guard.pymirror the sync surface across clean commit, dry-run, denial, release on exception, all five commit-uncertain branches (terminal codes, 5xx with/without body, transport error, uncaught exception, asyncio cancellation), commit-failed branches incl. release-failure recording, and an AP2 sample-type end-to-end through the async path.
Cancellation policy on async commit: an outer asyncio.CancelledError during the in-flight commit POST is treated as commit-uncertain. Since asyncio.CancelledError inherits from BaseException, the generic except Exception clause does not catch it; without an explicit handler, the cancellation would escape as raw CancelledError with no reservation_id or error_code, despite the post-PSP unknown-outcome contract. The async _handle_commit now explicitly converts it to AP2GuardCommitUncertain(error_code="COMMIT_CANCELLED") with the original on __cause__. No release is attempted (the commit may have reached and settled Cycles before the cancel landed). Sync code path is unaffected; cancellation only applies to async.
No protocol changes. No wire-shape changes. Existing v0.1.x callers see the sync API entirely unchanged.
Author: strategic/positioning review round 4 (PR #2 still in-flight) Scope: correctness polish + docstring sync
-
[P2] ASCII-only filter in idempotency-key suffix —
safe_suffixusedc.isalnum(), but Python'sstr.isalnumis Unicode-aware (e.g."É".isalnum() == True). A non-ASCII exception class name (or any non-ASCII suffix input) would have reached theIdempotency-KeyHTTP header — RFC 7230 requires ASCII tokens, so httpx would reject the request. Fix: tighten the predicate to(c.isascii() and c.isalnum()) or c in ("_", "-", "."). Regression test asserts the key isisascii()even when given a French-style suffix"Échec". -
[P3]
AP2Mandate.from_ap2preserved emptycheckout_hash— theorshort-circuit atgetattr(..., "hash", None) or getattr(..., "checkout_hash", None)masked upstreamhash=""asNone, bypassing the model'smin_length=1rejection. Fix: explicitNonecheck on the first alternative; fall back only when the first attr is genuinely absent. Two regression tests: emptyhash=""now raisesValidationErrorat construction; alternate naming (checkout_hashattr with nohashattr) still falls through correctly. -
[P3] Stale docstrings refreshed —
mapping.build_commit_body.__doc__now references the consume-once scope rather thantransaction_idexclusively.AP2GuardCommitFailed.__doc__rewritten: it covers only 4xx-explicit-rejection now; the previous paragraph that claimed it excludesRESERVATION_FINALIZED/RESERVATION_EXPIRED/IDEMPOTENCY_MISMATCHas "benign replay" was misleading — those raiseAP2GuardCommitUncertain(a different exception), not a quiet no-op. The new docstring points readers atAP2GuardCommitUncertainfor all unknown-outcome conditions.
Test posture after fixes:
- 110 tests (up from 107), 99.19% coverage.
No public API additions; only behavior tightening on existing paths.
Author: strategic/positioning review round 3 (PR #2 still in-flight) Scope: completing the commit-uncertain contract for all post-PSP failure modes
-
[P0] Transport errors and 5xx responses on commit are now uncertain (no release) — previously they fell into the "unrecognized rejection" branch, which called
_handle_releaseand raisedAP2GuardCommitFailedwith the message "reservation released" regardless. Wrong: the commit POST may have reached Cycles and mutated state before the failure was observed, so auto-release risks undoing a successful settle. New branch ordering in_handle_commit: success → transport/5xx → terminal codes → 4xx unrecognized. The first three all raiseAP2GuardCommitUncertainwith no release; only 4xx-with-unrecognized-code still releases. Syntheticerror_codevalues:TRANSPORT_ERRORfor transport-level failures,SERVER_ERRORfor 5xx without a parseable code (specific codes propagate when present). -
[P1] Bare exceptions during commit POST now surface as commit-uncertain — the
try / except Exception / raiseblock re-raised the raw exception, losing thereservation_idand bypassing the reconciliation contract. Now wrapped asAP2GuardCommitUncertain(error_code="COMMIT_RAISED", reservation_id=...) from excso the caller still gets the standard fields and the original exception is preserved via__cause__. -
Docstring updated —
AP2GuardCommitUncertain.__doc__enumerates all the new conditions and how to distinguish them viaerror_code.
Test posture after fixes:
- 107 tests (up from 103), 99.18% coverage.
Four new regression tests:
test_commit_5xx_raises_uncertain_no_releasetest_commit_5xx_without_body_uses_synthetic_codetest_commit_transport_error_raises_uncertain_no_releasetest_commit_raises_exception_surfaces_as_uncertain
No public API additions; only behavior shift inside _handle_commit and an expanded contract on the existing AP2GuardCommitUncertain.
Author: strategic/positioning review round 2 (PR #2 in-flight) Scope: documentation accuracy, additional regression coverage, defensive validation
-
Reworded "collapses onto one reservation" — earlier wording implied server-side merging when the actual semantics are: same key + same payload replays the original; same key + divergent payload returns
409 IDEMPOTENCY_MISMATCH(surfaced asAP2GuardDenied). Both prevent a second valid reservation, but the mechanism differs. README, AUDIT, and two test docstrings now describe the actual contract. The consume-once defense itself is unchanged. -
Stale
transaction_id-only retry wording —GuardedPayment.__doc__and the README lifecycle table still implied the retry/lock key is alwaystransaction_id. Generalized to "consume-once key (open_mandate_hashwhen present, otherwisetransaction_id)". -
New regression test for IDEMPOTENCY_MISMATCH on second reserve —
test_open_mandate_divergent_payload_reserve_mismatch_raises_deniedexercises the second half of the consume-once contract: same open mandate hash, distinct transaction_id, first reserve allows, second reserve gets 409 from server →AP2GuardDenied(reason_code="IDEMPOTENCY_MISMATCH"). Documents the actual server semantics for divergent payloads sharing the open-mandate scope. -
Reject empty
open_mandate_hash/checkout_hash— pydanticmin_length=1on both fields. Previously an empty string would pass model validation, thenconsume_once_input's truthy check would silently fall back to thetxscope — data corruption disguised as the default. Now fails fast at model construction. Two new tests cover the rejection paths.
Test posture after fixes:
- 103 tests (up from 100), 98.35% coverage.
No public API additions in this round.
Author: strategic/positioning review response (still v0.1.0; not yet on PyPI) Scope: consume-once correctness, post-PSP failure visibility, README accuracy
-
[P0-A] Open-mandate consume-once locking — earlier rounds keyed idempotency on
transaction_idonly. That breaks the AP2 spec's normative defense (specification §6) against an autonomous agent presenting subsequent open mandates without a rejection receipt: two distinct transactions derived from the same open mandate would produce different idempotency keys and BOTH would be authorized. Fix: whenAP2Mandate.open_mandate_hashis present, key the lock onopen_mandate_hashinstead oftransaction_id. Scope namespace embedded in the key so the two buckets never collide server-side:- human-not-present:
ap2:open_mandate:{sha256(open_mandate_hash)[:32]}:{phase}[:{suffix}] - default / human-present:
ap2:tx:{sha256(transaction_id)[:32]}:{phase}[:{suffix}]Newmapping.consume_once_input()returns the (scope, raw_value) pair. Theidempotency_key()function signature changed from(transaction_id: str, ...)to(mandate: AP2Mandate, ...)to enable automatic scope selection. Wire-shape change — pre-PyPI so no migration. New test classTestIdempotencyKeyOpenMandateScope+test_open_mandate_overuse_shares_idempotency_bucket_across_transactionscover the regression (the test was renamed in the follow-up wording pass to avoid the "collapse" overclaim).
- human-not-present:
-
[P0-B] Post-PSP commit failures now raise instead of silently logging —
RESERVATION_FINALIZED,RESERVATION_EXPIRED, andIDEMPOTENCY_MISMATCHpreviously returned silently with a warning log; callers only sawguard.committed == False. After the PSP body has run, any of these is a reconciliation event the caller MUST handle.RESERVATION_EXPIREDin particular is dangerous: the PSP may have charged while Cycles reclaimed the budget on TTL. Fix: newAP2GuardCommitUncertainexception (carrieserror_code+request_id+reservation_id); raised from_handle_commitfor all three terminal-status codes. Still no auto-release (a prior commit may already have settled). Existing tests that asserted silent behavior were flipped to expect the raise; newRESERVATION_EXPIREDtest added. -
README polish — added "Independent project — not affiliated with Google" disclaimer; added "What this does NOT do" section (no signature verification, no mandate creation, etc.); softened "any AP2-compatible SDK" wording to "AP2-style PaymentMandate objects via a small adapter layer"; tightened headline value-prop with a bulleted "prevent these failure modes" list; updated lifecycle table for commit-uncertain raise; updated idempotency key table to show the dual-scope behavior.
-
Real AP2-shaped adapter test — new
tests/test_ap2_shape_adapter.pyuses local dataclasses matching the upstream AP2 sample-type layout (PaymentMandate,CheckoutMandate,Payee,PaymentAmount) soAP2Mandate.from_ap2()is exercised against shape closer to real AP2 SDK objects, not just our own internal fakes. If upstream renames a field, this test fails first.
Test posture after fixes:
- 100 tests (up from 90), 98.35% coverage.
Public API additions:
AP2GuardCommitUncertainexception class (exported)
Wire-shape change (acceptable because pre-PyPI):
- idempotency keys now include a scope namespace:
ap2:tx:...orap2:open_mandate:...(wasap2:...)
Author: code-review response on PR #1 (round 6) Scope: correctness — exact-type validation on commit-amount inputs
[P2] set_actual_micros and build_commit_body accepted non-int types — the round-5 fix bounded numerical comparisons, but bool is an int subclass (isinstance(True, int) is True) and float < int compares numerically. set_actual_micros(True) would ship true as actual.amount; set_actual_micros(1.5) would ship 1.5 and only surface as a pydantic error during receipt construction — after the commit POST had already gone out. Fix: new private runcycles_ap2._validation.validate_micros(amount, *, field) helper using type(amount) is int to reject bool (and everything else non-int). Wired into both GuardedPayment.set_actual_micros AND mapping.build_commit_body so direct callers of the builder get the same protection. 14 new tests cover True/False/1.5/1.0/"100"/None/[100] across both entry points.
Test posture after fix:
- 90 tests (up from 75), 98.29% coverage.
New internal module:
runcycles_ap2/_validation.py(private; not exported)
Author: code-review response on PR #1 (round 5) Scope: correctness — int64 cap on the commit-path override
[P2] set_actual_micros() bypassed the int64 ceiling — 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. Passing 2**63 flowed through to build_commit_body and into the wire payload as actual.amount = 9223372036854775808. Fix: mirror the same 0 <= amount <= MAX_USD_MICROS validation; raise AP2MandateError (was plain ValueError) so all amount-validation errors are reachable via one exception type. Extracted MAX_USD_MICROS = 2**63 - 1 to _constants.py as the shared source of truth between models.py and guard.py. Three regression tests cover int64.max acceptance, int64.max + 1 rejection (commit not called, release called), and the existing negative-amount path now raising AP2MandateError.
Test posture after fix:
- 75 tests (up from 72), 98.23% coverage.
Public API change (very minor):
set_actual_micros(amount)now raisesAP2MandateError(aValueErrorsubclass) instead of plainValueError. Code catchingValueErrorstill works.
Author: code-review response on PR #1 (round 4) Scope: correctness at the int64 boundary
[P2] 19-digit cap permits values one over int64.max — the round-3 fix bounded len(digits) + max(0, exponent) to 19 to block the DoS allocation. That cap correctly rejects 20-digit inputs but lets values like 92233720368.54775808 (int64.max + 1) and 99999999999.99999999 (≈ 10^19 micros) slip through. The server would reject them, but client-side we'd already have shipped the wrong number. Fix: add a post-conversion check micros <= 2**63 - 1 (_MAX_USD_MICROS = 9_223_372_036_854_775_807). The 19-digit cap remains as the pre-allocation DoS guard; the post-conversion check is the exact protocol boundary. Three regression tests added: int64.max is accepted, int64.max + 1 is rejected, 19-digit-with-fractional 99999999999.99999999 is rejected.
Test posture after fix:
- 72 tests (up from 69), 97.92% coverage.
No public API additions.
Author: code-review response on PR #1 (round 3) Scope: denial-of-service vector, internal/external doc parity
-
[P2] Exponent-notation DoS in
amount_micros()— a short, finite, positive Decimal like1E+1000000000000(16 chars, fits the 64-char field) used to pass every validation:is_finite()is True, value is positive, and exponent ≥ -8. The code then tried to compute10 ** (10**12 + 8), allocating a trillion-digit integer and hanging the process. Even0E+1000000000000triggered the same allocation before the multiplication zeroed the result. Fix: pre-allocation digit-count cap.total_integer_digits = len(digits) + max(0, exponent)must be ≤ 19 (the digit count of int64.max, which is the protocol's USD_MICROCENTS ceiling). New regression tests cover1E+1000000000000,0E+1000000000000, and 20-digit "legitimate-shaped but out-of-range" amounts. Also updated the earliertest_large_value_converts_exactlytest which used a 29-digit value beyond int64 — now uses int64.max (92233720368.54775807) which still exercises the no-rounding path. -
[P3] Stale README mapping row +
GuardedPaymentclass docstring — README mapping table still claimedint(round(value * 1e8))(which is what the old default-context multiplication did) instead of the current exact-integer-tuple conversion.GuardedPayment.__doc__still showedap2:{tx}:commitraw-key shapes. Fix: rewrote both to match the implementation; docstring now referencesruncycles_ap2.mapping.idempotency_key, calls out the dry-run probe behavior andAP2GuardCommitFailed.released/release_errorfor callers reading source.
Test posture after fixes:
- 69 tests (up from 66), 97.89% coverage.
No public API additions.
Author: code-review response on PR #1 (round 2) Scope: payment-math correctness, exception fidelity, doc parity
-
[P2] Decimal default-context rounding —
amount_micros()previously computedvalue * 10**8as a Decimal multiplication, which uses the default 28-digit decimal context and silently rounded inputs larger than the protocol cap (a malformed mandate could carry such a value, e.g.123456789012345678901.12345678produced...680instead of...678). Fix: rewrote conversion to operate directly onDecimal.as_tuple()digits — exact integer math, no context dependence. Removed the now-unusedUSD_MICROCENTS_PER_DOLLARconstant. New regression test inTestAmountMicros::test_large_value_converts_exactly. -
[P2] Release-failure obscured by
AP2GuardCommitFailedmessage — when a commit was rejected with an unrecognized code, the wrapper attempted to release the reservation and raisedAP2GuardCommitFailedsaying "reservation released" regardless of whether the release actually succeeded. If release transport-failed or returned 5xx, budget was stranded and the caller had no signal. Fix:_handle_release()now returns(success: bool, error_detail: str | None).AP2GuardCommitFailedgained.releasedand.release_errorattributes; the exception message says either "reservation released" or "reservation release FAILED ... budget stranded until TTL" based on the actual outcome. Two regression tests cover the transport-failure and non-success-response paths. -
[P3] Stale README lifecycle table rows — the response-table rows still referenced raw
ap2:{transaction_id}:commit/:release:{ExcType}shapes, contradicting the hashed shape documented in the Deterministic idempotency keys section. Fix: rewrote the table rows to reference the keys section and to reflect the new "release + raise" semantics for unrecognized commit rejections.
Test posture after fixes:
- 66 tests (up from 62), 97.87% coverage.
Public API additions:
AP2GuardCommitFailed.released: boolAP2GuardCommitFailed.release_error: str | None
No protocol changes required.
Author: code-review response on PR #1 Scope: wire shape, public API surface, validation
Four findings from review addressed before v0.1.0 release:
-
[P1] Idempotency key collision —
idempotency_key()previously appended the phase suffix to the rawtransaction_idand sliced the whole string to 256 chars. Two 256-char transaction_ids sharing the first 252 chars produced the same reserve key; a single 256-char id produced identical reserve/commit keys (phase suffix stripped). The rawtransaction_idcould also include whitespace / control bytes that reached theIdempotency-Keyheader. Fix: key shape changed toap2:{sha256(transaction_id)[:32]}:{phase}[:{suffix}]. Hash is fixed-length (32 hex chars, 128-bit collision resistance), header-safe (hex only), and phase is always preserved. The rawtransaction_idis still attached toSubject.dimensions["ap2_transaction_id"]for debug. This is a wire-shape change — pre-release so no migration cost. -
[P2] Dry-run still executed body —
__enter__withdry_run=Truepreviously returned a guard normally; only__exit__skipped commit/release. If a caller ran a real PSP charge inside thewithbody, money would move with no Cycles record. Fix:__enter__now raisesAP2DryRunResult(new exception carrying decision payload) so thewithbody is unreachable in dry-run mode. Public API: addedAP2DryRunResultto exports. -
[P2] Decimal validation gaps —
AP2Mandate.amount_micros()acceptedNaNand+/-Infinity(Pydantic-validated throughDecimal()), then raised rawdecimal.InvalidOperationorOverflowErrorlater. Sub-micro precision (>8 decimal places) was silently rounded. Fix: explicitis_finite()check;as_tuple().exponent < -8rejection; all decimal failures (DecimalException,OverflowError) wrapped asAP2MandateError. -
[P2] Commit failures invisible to caller — unrecognized commit rejection (e.g., 400 INVALID_REQUEST after PSP charge) was logged + released and the context manager exited normally. Caller's only signal was
guard.committed == False, easy to miss → unreconciled payment state. Fix: addedAP2GuardCommitFailed(new exception carryingerror_code,request_id,reservation_id); raised from_handle_commitafter the release in the unrecognized-rejection branch.RESERVATION_FINALIZED/RESERVATION_EXPIRED/IDEMPOTENCY_MISMATCHstill return silently — those are benign replays of a prior attempt.
Test posture after fixes:
- 62 tests (up from 53), 97.45% coverage.
- New regression tests cover: long tx_id phase preservation, distinct-but-similar tx_id collision avoidance, header-unsafe char sanitization, NaN / +/-Infinity rejection, sub-micro precision rejection, dry-run body unreachability, commit-failure exception surfacing.
Public API additions:
AP2DryRunResultexceptionAP2GuardCommitFailedexception
No protocol changes required.
Author: initial commit Scope: new repo, no protocol changes
- Created
runcycles_ap2package:cycles_guard_paymentsync context manager wrapping a Cyclesreserve / commit / releaselifecycle around an AP2 Payment Mandate. - AP2 → Cycles wire mapping:
Action.kind = "payment.charge"(built-in high-risk kind fromcycles-action-kinds-v0.1.26.yaml:1562-1574; no custom kinds required, no protocol change).Amount.unit = USD_MICROCENTS; USD only in v0.1, non-USD raisesValueErrorclient-side.Subject.dimensionskeys:run_id,ap2_transaction_id,checkout_hash,open_mandate_hash(all under 256 chars, ≤ 16 dims).action.policy_keys.host = payee_website;policy_keys.custom = {payment_protocol: "ap2", currency: "USD"}. Attached at the reservation request body level (raw dict path throughCyclesClient.create_reservation) since the v0.4.1runcycles.models.Actiondoes not yet surfacepolicy_keysas a typed field.
- Idempotency policy: client computes deterministic keys per phase from
transaction_id—ap2:{txid}:reserve,ap2:{txid}:commit,ap2:{txid}:release:{ExcType}. This is the consume-once defense against mandate reuse across retries / concurrent attempts. - Runtime-authority receipt: client-side derivation only (schema
runtime_authority.ap2.payment.charge.v1). Not server-verifiable in protocol v0.1.26. Promoted to a signed protocol field in v0.3. - Test coverage: ≥ 95% enforced via
pyproject.toml: fail_under = 95. CI workflow mirrorscycles-client-python.
Risks acknowledged in this version:
- PSP failure after clean
__exit__will commit budget against a failed charge. Mitigation: README mandates raising inside thewithblock on PSP error. - TTL timeout is server-side expiry, not a client release.
- AP2 SDK is not yet on PyPI;
AP2Mandateis our adapter layer so upstream field renames touch onlymodels.py+mapping.py.