Skip to content

Commit 5616b3a

Browse files
authored
Release 2.11.0: pre-payment verification path on conditional gates (#143)
## Summary Release 2.11.0, parity with node-commerce 2.14.0: the pre-payment verification path now works on the conditional gate adapters too, not only on `Checkout`. Worked with Varun. 2.10.0 let an identity-gated `Checkout` answer `X-Verification-Session: create` with the session 403. A merchant mounting the gate itself with a conditional adapter variant still ran the gate only on a payment credential. This release makes the rule one shared predicate: - `should_run_conditional_gate(request_or_headers)`: a payment credential, or `requests_verification_session(...)` (the header set to `create`, case-insensitive, with no identity and no payment). Every conditional variant uses it: aiohttp, Django, FastAPI, Flask, the ASGI middleware and Sanic. - `has_identity_header(...)`: operator token, wallet address, or a non-empty `Agent-Identity`. - `build_identity_bootstrap()`: the `identity_bootstrap` 402 block. `Checkout` now builds its block with it, and a merchant building its own 402 passes it in `build_402_body`'s `extra`. - `VERIFICATION_SESSION_HEADER` / `VERIFICATION_SESSION_VALUE` move to `agentscore_commerce.payment.payment_header`, still exported from the top level. `Checkout` drops its private copies of these checks. The README's hand-rolled conditional example and `CLAUDE.md` describe the shared predicate. ## Type of change - [ ] Bug fix (no breaking change) - [x] New feature (no breaking change) - [ ] Breaking change (existing callers must update) - [ ] Docs, tests, or internal maintenance only ## Public API Additive: `should_run_conditional_gate`, `requests_verification_session`, `has_identity_header`, `VERIFICATION_SESSION_VALUE` (top level and `agentscore_commerce.payment`), and `build_identity_bootstrap` (top level and `agentscore_commerce.challenge`). `VERIFICATION_SESSION_HEADER` keeps its top-level export. Behavior change on conditional variants: a request carrying `X-Verification-Session: create` and no identity or payment now runs the gate; every other request behaves as before. ## Test plan - `tests/test_verification_session.py`: the same cases as the Node suite (header handling, suppression by each identity and payment header, empty `Agent-Identity`, the predicate's truth table, the builder). - `tests/test_fastapi.py`: the header with no identity runs the gate and returns the session 403 without calling assess; the header with an operator token flows through with neither assess nor session calls. With the adapter pointed back at `has_payment_header`, the first fails and the second passes. - Parity with node-commerce 2.14.0 checked at runtime: 15 header combinations give identical results, and the bootstrap block and constants are identical. - Ran locally, as CI runs them: ruff check, ruff format --check, ty (package and examples), vulture, pytest (1886 passed, 4 skipped, 95.40% coverage). - Review loop: 2 rounds, both clean. ## Checklist - [x] Tests cover the new behavior, and the suite passes locally - [x] Lint, format, and type checks pass - [x] Docs and README examples updated if the public surface changed - [x] No secrets, credentials, or personal data in the diff or the tests
1 parent 69d8eb4 commit 5616b3a

18 files changed

Lines changed: 264 additions & 71 deletions

‎CLAUDE.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Every helper is extracted from a real consumer, not speculated.
88

99
| Submodule | What it is |
1010
|---|---|
11-
| `agentscore_commerce` (top-level) | `Checkout` orchestrator (the 2.0 high-level surface): one config object + hooks (pre_validate, compute_pricing, mint_recipients, compose_mppx, on_settled, gate), auto-derived x402+pympp servers, per-framework adapters `handle_fastapi`/`handle_flask`/`handle_django`/`handle_aiohttp`/`handle_sanic`, signed UCP routes via `mount_ucp_routes_{fastapi,flask,django,aiohttp,sanic}`, optional `discovery_probe` config for x402-crawler auto-routing. Plus `compute_first_checkout` — variable-cost pay-per-result helper (compute-first + exact-x402). Scope is exact-mode rails only (x402-exact Base, tempo/charge, solana/charge, Stripe SPT); does NOT use x402-upto (Permit2) or Settlement-Overrides — variable cost is captured by running the work pre-settle and emitting a 402 at the exact computed price. `create_quote_cache` — content-hash quote cache used by the compute-first helper (in-memory by default; pass `redis_url` for distributed deployments). `create_result_cache` — the neutral primitive under it: keyed JSON-value cache with the same body-hash key builder; use it to cache any probe-leg result a Checkout-class merchant replays on the settle leg (e.g. a paid upstream call made in `pre_validate`). `create_default_on_denied` — canonical `on_denied(reason)` factory matching `Checkout`'s gate hook (handles `wallet_signer_mismatch`, `wallet_not_trusted` unfixable fallback, `payment_required`, `token_expired`/`invalid_credential`/`api_error`); merchants pass `merchant_name` + `support_email` and override `wallet_not_trusted_message` / `payment_required_message` / `support_context` for vendor-specific copy. `has_payment_header` — discriminator that splits discovery legs (no payment credential → 402) from settle legs (`payment-signature` / `x-payment` / `Authorization: Payment <jwt>`); `has_x402_header` / `has_mppx_header` — granular dispatch helpers (x402 vs MPP credential present) for routes that branch on rail. `malformed_payment_credential` — wire-shape gate for payment credentials (not base64 JSON, not token-shaped → reject); `Checkout` runs it before any merchant hook by default (`credential_pre_check=False` opts out). `default_read_only_on_denied(reason)` — canonical `on_denied` for read-only resource gates (`GET /orders/:id`): collapses every denial to 401 `unauthorized` + `Cache-Control: no-store` while still spreading `denial_reason_to_body` so `agent_instructions` / `verify_url` ride through. Returns a `DefaultOnDeniedResult(body, status, headers)` dataclass; FastAPI / Flask / aiohttp / Sanic `on_denied` callbacks accept an optional 3-tuple `(body, status, headers)` — convert with `lambda req, reason: (r := default_read_only_on_denied(reason), (r.body, r.status, r.headers or {}))[1]` or a named wrapper. Django + ASGI middleware adapters return Response objects directly; construct `JsonResponse(r.body, status=r.status, headers=r.headers)` / `JSONResponse(content=r.body, status_code=r.status, headers=r.headers)`. `extract_owner_scope(headers) -> OwnerScope` — pull canonical owner identity from `X-Wallet-Address` / `X-Operator-Token` with safe token hashing; pair with a wallet-or-token-scoped resource query so plaintext tokens never leave the request. Plus factories: `pricing_result` (cents → typed `PricingResult`), `validation_response_{fastapi,flask,django,aiohttp,sanic}` (4xx envelope per framework) |
11+
| `agentscore_commerce` (top-level) | `Checkout` orchestrator (the 2.0 high-level surface): one config object + hooks (pre_validate, compute_pricing, mint_recipients, compose_mppx, on_settled, gate), auto-derived x402+pympp servers, per-framework adapters `handle_fastapi`/`handle_flask`/`handle_django`/`handle_aiohttp`/`handle_sanic`, signed UCP routes via `mount_ucp_routes_{fastapi,flask,django,aiohttp,sanic}`, optional `discovery_probe` config for x402-crawler auto-routing. Plus `compute_first_checkout` — variable-cost pay-per-result helper (compute-first + exact-x402). Scope is exact-mode rails only (x402-exact Base, tempo/charge, solana/charge, Stripe SPT); does NOT use x402-upto (Permit2) or Settlement-Overrides — variable cost is captured by running the work pre-settle and emitting a 402 at the exact computed price. `create_quote_cache` — content-hash quote cache used by the compute-first helper (in-memory by default; pass `redis_url` for distributed deployments). `create_result_cache` — the neutral primitive under it: keyed JSON-value cache with the same body-hash key builder; use it to cache any probe-leg result a Checkout-class merchant replays on the settle leg (e.g. a paid upstream call made in `pre_validate`). `create_default_on_denied` — canonical `on_denied(reason)` factory matching `Checkout`'s gate hook (handles `wallet_signer_mismatch`, `wallet_not_trusted` unfixable fallback, `payment_required`, `token_expired`/`invalid_credential`/`api_error`); merchants pass `merchant_name` + `support_email` and override `wallet_not_trusted_message` / `payment_required_message` / `support_context` for vendor-specific copy. `should_run_conditional_gate` / `requests_verification_session` / `has_identity_header` / `VERIFICATION_SESSION_HEADER`: the conditional-gate predicate (payment credential or an opt-in pre-payment verification-session request) and its parts; `build_identity_bootstrap`: the matching `identity_bootstrap` 402 block. `has_payment_header` — discriminator that splits discovery legs (no payment credential → 402) from settle legs (`payment-signature` / `x-payment` / `Authorization: Payment <jwt>`); `has_x402_header` / `has_mppx_header` — granular dispatch helpers (x402 vs MPP credential present) for routes that branch on rail. `malformed_payment_credential` — wire-shape gate for payment credentials (not base64 JSON, not token-shaped → reject); `Checkout` runs it before any merchant hook by default (`credential_pre_check=False` opts out). `default_read_only_on_denied(reason)` — canonical `on_denied` for read-only resource gates (`GET /orders/:id`): collapses every denial to 401 `unauthorized` + `Cache-Control: no-store` while still spreading `denial_reason_to_body` so `agent_instructions` / `verify_url` ride through. Returns a `DefaultOnDeniedResult(body, status, headers)` dataclass; FastAPI / Flask / aiohttp / Sanic `on_denied` callbacks accept an optional 3-tuple `(body, status, headers)` — convert with `lambda req, reason: (r := default_read_only_on_denied(reason), (r.body, r.status, r.headers or {}))[1]` or a named wrapper. Django + ASGI middleware adapters return Response objects directly; construct `JsonResponse(r.body, status=r.status, headers=r.headers)` / `JSONResponse(content=r.body, status_code=r.status, headers=r.headers)`. `extract_owner_scope(headers) -> OwnerScope` — pull canonical owner identity from `X-Wallet-Address` / `X-Operator-Token` with safe token hashing; pair with a wallet-or-token-scoped resource query so plaintext tokens never leave the request. Plus factories: `pricing_result` (cents → typed `PricingResult`), `validation_response_{fastapi,flask,django,aiohttp,sanic}` (4xx envelope per framework) |
1212
| `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware (KYC, age, sanctions on both account name and signer wallet, jurisdiction). Each adapter exports a conditional variant that wraps the gate so it fires only on settle legs (anonymous discovery flows through and gets a 402 with all rails): FastAPI / ASGI expose `ConditionalAgentScoreGate`, Django exposes `ConditionalAgentScoreMiddleware`, Flask + Sanic expose `conditional_agentscore_gate(app, ...)`, aiohttp exposes `conditional_agentscore_gate_middleware(...)`. Adapters export ONLY framework-specific surface (gate classes / fns, accessors, `capture_wallet`); shared helpers like `has_payment_header` / `denial_reason_to_body` import from their canonical home (`agentscore_commerce.payment` and `agentscore_commerce.identity` respectively). The Flask / Sanic `agentscore_gate(app, ...)` function accepts an optional `condition=` callable for inline gating (`AgentScoreGate.__init__` does not). |
1313
| `agentscore_commerce.identity.policy` | Per-product compliance helpers: `PolicyBlock`, `build_gate_from_policy`, `run_gate_with_enforcement`, `shipping_country_allowed`, `shipping_state_allowed`, `validate_shipping_against_policy` (one-call country+state validator that raises `CheckoutValidationError` with the canonical envelope on miss) |
1414
| `agentscore_commerce.payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `create_x402_server` (wraps `x402[evm]>=2.9` + `cdp-sdk` for `facilitator="coinbase"`; install via the `coinbase` extra), `build_x402_accepts_for_402` (build the 402's `accepts[]` from the registered scheme; derives the right `extra.name` per network), `build_default_checkout_rails(tempo=, x402_base=, solana_mpp=, stripe=)` (canonical 4-rail `rails` dict factory: merchants pass per-rail overrides instead of redeclaring the recipient sentinel + network/chain_id/token boilerplate. When a caller flips `network` without pinning `token` / `chain_id`, the underlying dataclass derives them from the network: Base Sepolia → Sepolia USDC + chain_id 84532, Solana devnet → devnet USDC mint. Explicit overrides always win. Solana's `network` field accepts both CAIP-2 (`solana:5eykt4UsFv8…` / `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) AND the raw `@solana/mpp` form (`mainnet-beta` / `devnet` / `localnet`)), `build_mppx_compose_rails(amount_usd=, tempo_recipient=, solana_recipient=, ...)` (per-call intent factory replacing the hand-rolled `[("tempo/charge", {...}), ("solana/charge", {...}), ("stripe/charge", {...})]` list; auto-handles USD→atomic conversion for Solana; auto-drops the `stripe/charge` rail with a one-time `logging.warning` when `amount_usd < 0.50` since Stripe's fixed ~$0.30 fee makes sub-50-cent charges unprofitable — many Stripe accounts also reject PI creation below the floor with `amount_too_small`; sub-50-cent APIs pass `include_stripe=False` explicitly to silence the warning), `process_x402_settle` (verify+settle in one call), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6`), `is_evm_network`/`is_solana_network` (CAIP-2 discriminators that hide the `startswith("eip155:")` / `startswith("solana:")` prefix matching), `has_payment_header` (settle-leg vs discovery-leg discriminator), `parse_did_pkh_address` (parses ``did:pkh:<family>:<chain>:<addr>`` into a `PaymentSigner`), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header |
@@ -83,7 +83,7 @@ Two identity types: wallet (`X-Wallet-Address`) and operator-token (`X-Operator-
8383

8484
`create_session_on_missing` auto-mints a verification session when no identity is present AND when `wallet_not_trusted` carries fixable reasons (`kyc_required` / `kyc_pending` / `kyc_failed`) — both paths rewrite the denial to `identity_verification_required` before reaching `on_denied`. When the merchant omits `create_session_on_missing` from `CheckoutGateConfig`, `Checkout` auto-defaults it from `gate.api_key` + `gate.base_url` + `gate.context` + `gate.merchant_name`. Merchants that need `on_before_session` side effects (e.g. pre-minting an order_id) supply their own config to override.
8585

86-
**Getting a verify_url before paying.** On an identity-gated `Checkout`, the gate runs only on a settle leg, so a buyer with no identity reached the session 403 only by sending a payment credential first (an SPT buyer had to mint one). The discovery 402 carries an `identity_bootstrap` block naming `X-Verification-Session: create` whenever the request has no identity header, and a request carrying that header, no identity and no payment credential runs the gate, which answers with the same session-bearing 403. It is opt-in on purpose: scanners replay the Bazaar example body on a schedule, so minting on every identity-less 402 would create a session and (on goods stores) a pending order per probe. Gateless merchants neither advertise nor honor it. Parity with node-commerce.
86+
**Getting a verify_url before paying.** On an identity-gated `Checkout`, the gate runs only on a settle leg, so a buyer with no identity reached the session 403 only by sending a payment credential first (an SPT buyer had to mint one). The discovery 402 carries an `identity_bootstrap` block (`build_identity_bootstrap()`) naming `X-Verification-Session: create` whenever the request has no identity header (`has_identity_header`), and a request carrying that header, no identity and no payment credential runs the gate, which answers with the same session-bearing 403. It is opt-in on purpose: scanners replay the Bazaar example body on a schedule, so minting on every identity-less 402 would create a session and (on goods stores) a pending order per probe. Gateless merchants neither advertise nor honor it. Parity with node-commerce.
8787

8888
`build_verification_required_body(reason, message=?, agent_instructions=?, extra=?)` — canonical body builder for the `identity_verification_required` denial. Spreads `verify_url` / `session_id` / `poll_secret` / `poll_url` / `agent_instructions` from the gate-minted reason into a 4xx envelope with merchant-specific message + optional extras. Saves the per-merchant mapping boilerplate.
8989

@@ -131,7 +131,7 @@ async def gate_on_settle(request: Request) -> None:
131131
async def purchase(...): ...
132132
```
133133

134-
Anonymous POST flows through to the handler unauthenticated and gets a 402 with all rails + per-order pricing. Identity is verified at settle time on the retry leg (when the agent submits `X-Payment` / `Authorization: Payment`); `create_session_on_missing` still auto-mints a verification session there. The same wrap pattern works identically across all 6 framework adapters (fastapi, flask, django, aiohttp, sanic, middleware/ASGI). See `examples/multi_rail_merchant.py` and `examples/compliance_merchant.py`.
134+
Anonymous POST flows through to the handler unauthenticated and gets a 402 with all rails + per-order pricing. Identity is verified at settle time on the retry leg (when the agent submits `X-Payment` / `Authorization: Payment`); `create_session_on_missing` still auto-mints a verification session there. The shared predicate is `should_run_conditional_gate` (from `agentscore_commerce.payment`): a payment credential OR `requests_verification_session` (`X-Verification-Session: create` with no identity and no payment), so a buyer can get the session 403 before paying. Every conditional adapter variant uses it; a hand-rolled wrap should too, and a merchant building its own 402 advertises the path by passing `build_identity_bootstrap()` into `build_402_body`'s `extra` as `identity_bootstrap`. Parity with node-commerce. The same wrap pattern works identically across all 6 framework adapters (fastapi, flask, django, aiohttp, sanic, middleware/ASGI). See `examples/multi_rail_merchant.py` and `examples/compliance_merchant.py`.
135135

136136
### `compatible_clients` field on emitted 402s
137137

‎README.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,17 @@ _gate = AgentScoreGate(
7070
)
7171

7272

73-
# Run the gate CONDITIONALLY: only when a payment credential is already attached.
74-
# Anonymous discovery (no payment header) flows through to the handler so any spec-
75-
# compliant x402 wallet can read the 402 challenge with rails + pricing without first
76-
# proving identity. Identity is verified at settle time on the retry leg.
77-
from agentscore_commerce.payment import has_payment_header
73+
# Run the gate CONDITIONALLY: when a payment credential is attached, or when the buyer asks
74+
# for a verification session with `X-Verification-Session: create` and no identity. Anonymous
75+
# discovery flows through to the handler so any spec-compliant x402 wallet can read the 402
76+
# challenge with rails + pricing without first proving identity; identity is verified at settle
77+
# time, or earlier on request. Advertise the early path by passing `build_identity_bootstrap()`
78+
# into your 402 body as `identity_bootstrap` (Checkout does this for you).
79+
from agentscore_commerce.payment import should_run_conditional_gate
7880

7981

8082
async def gate_on_settle(request: Request) -> None:
81-
if not has_payment_header(request):
83+
if not should_run_conditional_gate(request):
8284
return None
8385
return await _gate(request)
8486

‎agentscore_commerce/__init__.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
from importlib.metadata import PackageNotFoundError
1414
from importlib.metadata import version as _pkg_version
1515

16+
from agentscore_commerce.challenge.identity import build_identity_bootstrap
1617
from agentscore_commerce.checkout import (
17-
VERIFICATION_SESSION_HEADER,
1818
Checkout,
1919
CheckoutContext,
2020
CheckoutGateConfig,
@@ -126,6 +126,8 @@
126126
from agentscore_commerce.identity.sessions import CreateSessionOnMissing
127127
from agentscore_commerce.identity.types import PolicyCheck, PolicyResult
128128
from agentscore_commerce.payment import (
129+
VERIFICATION_SESSION_HEADER,
130+
VERIFICATION_SESSION_VALUE,
129131
PaymentSigner,
130132
SignerNetwork,
131133
SolanaMppRailSpec,
@@ -138,13 +140,16 @@
138140
extract_payment_signer,
139141
extract_signer_for_precheck,
140142
format_usd_cents,
143+
has_identity_header,
141144
has_mppx_header,
142145
has_payment_header,
143146
has_x402_header,
144147
is_evm_network,
145148
is_solana_network,
146149
load_solana_fee_payer,
147150
read_x402_payment_header,
151+
requests_verification_session,
152+
should_run_conditional_gate,
148153
)
149154
from agentscore_commerce.quote_cache import (
150155
CachedQuote,
@@ -170,6 +175,7 @@
170175
"FIXABLE_DENIAL_REASONS",
171176
"UCP_A2A_EXTENSION_URI",
172177
"VERIFICATION_SESSION_HEADER",
178+
"VERIFICATION_SESSION_VALUE",
173179
"A2AAgentCard",
174180
"A2AAgentCardCapabilities",
175181
"A2AAgentCardExtension",
@@ -243,6 +249,7 @@
243249
"build_contact_support_next_steps",
244250
"build_default_checkout_rails",
245251
"build_gate_from_policy",
252+
"build_identity_bootstrap",
246253
"build_jwks_response",
247254
"build_mppx_compose_rails",
248255
"build_signer_mismatch_body",
@@ -262,6 +269,7 @@
262269
"format_usd_cents",
263270
"generate_ucp_signing_key",
264271
"get_identity_status",
272+
"has_identity_header",
265273
"has_mppx_header",
266274
"has_payment_header",
267275
"has_x402_header",
@@ -275,9 +283,11 @@
275283
"mpp_payment_handler",
276284
"pricing_result",
277285
"read_x402_payment_header",
286+
"requests_verification_session",
278287
"run_gate_with_enforcement",
279288
"shipping_country_allowed",
280289
"shipping_state_allowed",
290+
"should_run_conditional_gate",
281291
"sign_ucp_profile",
282292
"stripe_spt_payment_handler",
283293
"ucp_a2a_extension",

‎agentscore_commerce/challenge/__init__.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
)
1010
from agentscore_commerce.challenge.body import X402PaymentRequired, X402ResourceInfo, build_402_body
1111
from agentscore_commerce.challenge.how_to_pay import build_how_to_pay
12-
from agentscore_commerce.challenge.identity import IdentityMode, SignerMatchResult, build_identity_metadata
12+
from agentscore_commerce.challenge.identity import (
13+
IdentityMode,
14+
SignerMatchResult,
15+
build_identity_bootstrap,
16+
build_identity_metadata,
17+
)
1318
from agentscore_commerce.challenge.pricing import PricingBlock, build_pricing_block
1419
from agentscore_commerce.challenge.receipt import (
1520
ProductInfo,
@@ -37,6 +42,7 @@
3742
"build_agent_instructions",
3843
"build_agent_memory_hint",
3944
"build_how_to_pay",
45+
"build_identity_bootstrap",
4046
"build_identity_metadata",
4147
"build_pricing_block",
4248
"build_validation_error",

0 commit comments

Comments
 (0)