Skip to content

Commit df32adf

Browse files
vvillait88claude
andauthored
feat: collapsed signer_match + per-adapter quota/fail-open helpers (python parity) (#4)
## Summary **Identity / signer matching:** - `verify_wallet_signer_match` / `averify_wallet_signer_match` collapse the prior 3-call gate fan-out into a single `/v1/assess` call carrying `resolve_signer`; the API resolves both wallets server-side and emits a `signer_match` verdict in the same response - Per-`(claimed, signer)` cache so repeat lookups skip the API - Fallback to the legacy 2-resolve path when the API response omits `signer_match` (canary rollout safety) **Fail-open + quota helpers across the 6 framework adapters (fastapi, flask, django, aiohttp, sanic, middleware/ASGI):** - `fail_open=True` flag on `AgentScoreGate(...)` / `agentscore_gate(app, ...)`; 429 / 5xx / network-timeout pass through to the handler with `degraded=True` + `infra_reason` on gate state. Compliance denials still deny. - `get_gate_degraded_state(request)` reads `{"degraded": bool, "infra_reason"?: str}` from framework state container (g, scope, ctx, request.state, etc.) - `get_gate_quota_info(request)` returns the assess quota envelope captured during evaluate - Dedicated 429 path with quota-specific recovery instructions **Brand + disclosure scrubs:** - "AgentScore Commerce" brand applied to examples README - Disclosure cleanup on quota / 429 paths **Tests:** - `tests/test_gate_quota_info.py` (NEW) — cross-adapter quota helper parity (12 tests across 5 adapters + middleware) - `tests/test_signer_match.py` — collapsed surface coverage - 6 adapter test files updated for fail-open + quota plumbing **Deps:** - `agentscore-py` 2.0.2 → 2.1.0 (signer_match types, typed errors) - `uv sync --upgrade` pulled the rest of the pinned-floor packages **Version:** 1.0.3 → 1.1.0 (additive minor; parity with node-commerce). Closes TEC-275, TEC-265. ## Test plan - [x] Unit tests pass locally (`uv run pytest` — 642 passing, 3 skipped) - [x] Coverage above tier-A threshold (95% — actual 95.20%) - [x] Lefthook pre-commit (ruff) + pre-push (ty + vulture) passes - [ ] CI green on this PR 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7dba503 commit df32adf

27 files changed

Lines changed: 2788 additions & 333 deletions

CLAUDE.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Every helper is extracted from a real consumer, not speculated.
1717

1818
## Architecture
1919

20-
Single Python package, hatchling-built, published to PyPI as `agentscore-commerce`. Per-framework identity adapters expose the same surface — `AgentScoreGate` (or `agentscore_gate(app, ...)` for Flask/Sanic), `capture_wallet`, `verify_wallet_signer_match`, `get_assess_data` — with network-aware address normalization (EVM lowercased, Solana base58 preserved verbatim).
20+
Single Python package, hatchling-built, published to PyPI as `agentscore-commerce`. Per-framework identity adapters expose the same surface — `AgentScoreGate` (or `agentscore_gate(app, ...)` for Flask/Sanic), `capture_wallet`, `verify_wallet_signer_match`, `get_assess_data`, `get_gate_degraded_state`, `get_gate_quota_info` — with network-aware address normalization (EVM lowercased, Solana base58 preserved verbatim).
2121

2222
| Directory | Contents |
2323
|---|---|
@@ -56,6 +56,12 @@ Two identity types: wallet (`X-Wallet-Address`) and operator-token (`X-Operator-
5656

5757
Captured wallets: `capture_wallet(...)` is fire-and-forget — reads `operator_token` stashed during gating and POSTs to `/v1/credentials/wallets`. No-ops for wallet-authenticated requests.
5858

59+
Wallet-signer-match: `verify_wallet_signer_match` / `averify_wallet_signer_match` makes a single `/v1/assess` call with `resolve_signer` set; the API resolves both wallets and emits a `signer_match` verdict in the same response — collapses the legacy 2 follow-up assess calls into one round trip. Repeat lookups for the same `(claimed, signer)` pair hit a per-cache-entry `signer_match_by_signer` sub-dict and skip the API entirely. Falls back to a 2-resolve path when the API doesn't emit `signer_match` (canary rollout safety).
60+
61+
### Fail-open (opt-in)
62+
63+
`fail_open=True` on `AgentScoreGate(...)` (or `agentscore_gate(app, ...)`) flips infra-failure handling: 429 / 5xx / network-timeout pass through to the handler with the gate state stamped `degraded=True` + `infra_reason="quota_exceeded" | "api_error" | "network_timeout"`. `get_gate_degraded_state(request)` (Flask: `get_gate_degraded_state()` — reads from `g`) returns `{"degraded": bool, "infra_reason"?: str}` for merchant logging/alerting. Default stays `fail_open=False` — regulated commerce should keep it. Compliance denials (sanctions, age, jurisdiction, signer-mismatch) still deny regardless of the flag. The gate's `try` wraps only the AgentScore call — never the downstream user handler.
64+
5965
### Mount posture: gate-first vs gate-conditional
6066

6167
`AgentScoreGate(...)` (or `agentscore_gate(app, ...)` on Flask/Sanic) is mounted directly when the route is AgentScore-only — every request runs identity + policy. To support **anonymous discovery by any spec-compliant x402 wallet** (Coinbase awal, Phantom, Solflare, …), wrap the gate so it fires only when a payment credential is attached:

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,32 @@ async def purchase(request: Request):
292292
return JSONResponse(result.body, status_code=result.status, headers=result.headers)
293293
```
294294

295+
## Fail-open behavior
296+
297+
By default AgentScore Gate fails closed: any AgentScore-side infrastructure failure (HTTP 429, 5xx, network timeout) returns 503 to the buyer. Set `fail_open=True` on `AgentScoreGate(...)` to opt in to graceful degradation:
298+
299+
```python
300+
from fastapi import Depends, FastAPI, Request
301+
from agentscore_commerce.identity.fastapi import AgentScoreGate, get_gate_degraded_state
302+
303+
app = FastAPI()
304+
gate = AgentScoreGate(api_key=os.environ["AGENTSCORE_API_KEY"], fail_open=True)
305+
306+
@app.post("/purchase", dependencies=[Depends(gate)])
307+
async def purchase(request: Request):
308+
state = get_gate_degraded_state(request)
309+
if state["degraded"]:
310+
# Compliance was NOT enforced this request — log/alert/refund-async/etc.
311+
logger.warning("gate degraded: %s", state["infra_reason"])
312+
# ...rest of handler
313+
```
314+
315+
When `fail_open=True` AND the failure is infra-shape, the gate state carries `degraded=True` + `infra_reason="quota_exceeded" | "api_error" | "network_timeout"` so merchants can log/alert without parsing console output. **Compliance denials (sanctions, age, jurisdiction, signer-mismatch) still deny regardless of `fail_open`**`fail_open` only covers "AgentScore couldn't tell us," never "AgentScore said no."
316+
317+
For regulated commerce (alcohol, age-gated, sanctioned-jurisdiction-relevant) keep the default `fail_open=False` — outage is the correct posture; bypassing compliance on infra failure is a compliance gap. For low-stakes commerce or high-uptime SLAs, opt in and use the `degraded` flag as the audit trail.
318+
319+
The `get_gate_degraded_state` helper is exported by every framework adapter (FastAPI, Flask, Django, AIOHTTP, Sanic, ASGI middleware) and reads from the framework-appropriate request state. The signature takes a request argument everywhere except Flask, which reads from `g` and takes no arguments.
320+
295321
## Examples
296322

297323
The [examples/](./examples) directory has 7 runnable single-file FastAPI apps covering common merchant scenarios. See [examples/README.md](./examples/README.md) for the full table.

agentscore_commerce/identity/_response.py

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -164,20 +164,19 @@
164164
"action": "contact_merchant",
165165
"steps": [
166166
(
167-
"The merchant's AgentScore tier does not include the assess feature, so "
168-
"agent identity cannot be evaluated. This is a merchant-side configuration "
169-
"gap — there is no agent-side recovery."
167+
"The merchant's AgentScore account does not have the assess endpoint "
168+
"enabled, so agent identity cannot be evaluated. This is a merchant-side "
169+
"configuration gap — there is no agent-side recovery."
170170
),
171171
(
172172
"Contact the merchant (their support channel — typically listed in "
173-
"/llms.txt or the OpenAPI servers metadata) and request they upgrade "
174-
"their AgentScore plan."
173+
"/llms.txt or the OpenAPI servers metadata) so they can resolve the "
174+
"configuration on their side."
175175
),
176176
],
177177
"user_message": (
178-
"This merchant's identity gate is misconfigured (AgentScore tier doesn't "
179-
"support assess). Contact the merchant — there's nothing to fix on the "
180-
"agent side."
178+
"This merchant's identity gate is misconfigured. Contact the merchant — "
179+
"there's nothing to fix on the agent side."
181180
),
182181
}
183182
)
@@ -224,10 +223,46 @@
224223
}
225224
)
226225

226+
_API_ERROR_INSTRUCTIONS = json.dumps(
227+
{
228+
"action": "retry_with_backoff",
229+
"steps": [
230+
"Verification is temporarily unavailable. Retry the request after 5-30 seconds with exponential backoff.",
231+
"This is NOT a compliance denial — the user does not need to re-verify their "
232+
"identity. Send the same identity headers (X-Wallet-Address or X-Operator-Token) "
233+
"on retry.",
234+
"If the request continues to fail after 3+ retries (~60 seconds total), surface the "
235+
"error to the user with the merchant's support contact.",
236+
],
237+
"user_message": (
238+
"Verification is temporarily unavailable. Please try again in a moment — this is a "
239+
"transient issue, not a problem with your account."
240+
),
241+
}
242+
)
243+
244+
QUOTA_EXCEEDED_INSTRUCTIONS = json.dumps(
245+
{
246+
"action": "contact_merchant",
247+
"steps": [
248+
"AgentScore identity verification is unavailable for this merchant. This is a "
249+
"merchant-side issue and is NOT recoverable via retry.",
250+
"Do not retry: the same 503 will be returned until the merchant resolves the issue on their side.",
251+
"Surface to the user with the merchant's support contact. The merchant (not the agent) needs to act.",
252+
],
253+
"user_message": (
254+
"This merchant's identity verification is temporarily unavailable. Try again later, "
255+
"or contact the merchant directly."
256+
),
257+
}
258+
)
259+
260+
227261
# Default agent_instructions per denial code. Adapters can override by passing
228262
# ``agent_instructions=`` on the DenialReason; otherwise the body emitter looks
229263
# up this map so every denial carries a machine-readable next step.
230264
_DEFAULT_AGENT_INSTRUCTIONS: dict[str, str] = {
265+
"api_error": _API_ERROR_INSTRUCTIONS,
231266
"missing_identity": _MISSING_IDENTITY_INSTRUCTIONS,
232267
"wallet_signer_mismatch": WALLET_SIGNER_MISMATCH_INSTRUCTIONS,
233268
"wallet_auth_requires_wallet_signing": WALLET_AUTH_REQUIRES_WALLET_SIGNING_INSTRUCTIONS,
@@ -260,7 +295,7 @@ def build_missing_identity_reason() -> DenialReason:
260295
),
261296
"wallet_not_trusted": "The wallet does not meet the merchant compliance policy.",
262297
"api_error": "AgentScore is unreachable. This is transient — retry in a few seconds.",
263-
"payment_required": "AgentScore tier does not support assess. Contact support.",
298+
"payment_required": "Assess endpoint not enabled for this merchant. Contact support.",
264299
"wallet_signer_mismatch": (
265300
"Payment signer does not match the wallet claimed via X-Wallet-Address. The signer and the "
266301
"claimed wallet must both resolve to the same AgentScore operator."
@@ -321,10 +356,6 @@ def denial_reason_to_body(reason: DenialReason) -> dict[str, Any]:
321356
body["actual_signer"] = reason.actual_signer
322357
if reason.linked_wallets:
323358
body["linked_wallets"] = reason.linked_wallets
324-
# api_error denials get a default retry hint so agents know it's transient. Vendors can
325-
# override by spreading their own next_steps into a custom on_denied body.
326-
if reason.code == "api_error" and not (reason.extra and reason.extra.get("next_steps")):
327-
body["next_steps"] = {"action": "retry", "retry_after_seconds": 5}
328359
# Merchant-supplied fields from on_before_session hook. Guard against collision
329360
# with reserved fields — the gate owns those and can't let a hook override them.
330361
if reason.extra:

agentscore_commerce/identity/aiohttp.py

Lines changed: 97 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from typing import TYPE_CHECKING, Any
66

7+
import httpx
8+
79
from agentscore_commerce.identity._denial import (
810
FIXABLE_DENIAL_REASONS,
911
build_contact_support_next_steps,
@@ -12,11 +14,16 @@
1214
is_fixable_denial,
1315
verification_agent_instructions,
1416
)
15-
from agentscore_commerce.identity._response import build_missing_identity_reason, denial_reason_to_body
17+
from agentscore_commerce.identity._response import (
18+
QUOTA_EXCEEDED_INSTRUCTIONS,
19+
build_missing_identity_reason,
20+
denial_reason_to_body,
21+
)
1622
from agentscore_commerce.identity.client import (
1723
GateClient,
1824
InvalidCredentialError,
1925
PaymentRequiredError,
26+
QuotaExceededError,
2027
TokenDeniedError,
2128
build_invalid_credential_reason,
2229
build_token_denied_reason,
@@ -25,9 +32,11 @@
2532
from agentscore_commerce.identity.types import (
2633
AgentIdentity,
2734
DenialReason,
35+
GateQuotaInfo,
2836
Network,
2937
VerifyWalletSignerMatchOptions,
3038
VerifyWalletSignerResult,
39+
apply_degraded,
3140
)
3241
from agentscore_commerce.payment.signer import (
3342
extract_payment_signer,
@@ -45,6 +54,12 @@
4554
GATE_STATE_KEY = "__agentscore_gate"
4655
ASSESS_STATE_KEY = "agentscore"
4756

57+
58+
def _mark_degraded_aiohttp(request: web.Request, infra_reason: str) -> None:
59+
"""Stamp the gate state on an aiohttp request as fail-open'd."""
60+
apply_degraded(request.get(GATE_STATE_KEY), infra_reason)
61+
62+
4863
__all__ = [
4964
"FIXABLE_DENIAL_REASONS",
5065
"CreateSessionOnMissing",
@@ -57,6 +72,8 @@
5772
"extract_payment_signer",
5873
"extract_payment_signer_address",
5974
"get_assess_data",
75+
"get_gate_degraded_state",
76+
"get_gate_quota_info",
6077
"is_fixable_denial",
6178
"read_x402_payment_header",
6279
"verification_agent_instructions",
@@ -73,6 +90,31 @@ def get_assess_data(request: web.Request) -> dict[str, Any] | None:
7390
return request.get(ASSESS_STATE_KEY)
7491

7592

93+
def get_gate_degraded_state(request: web.Request) -> dict[str, Any]:
94+
"""Return whether the gate fail-open'd due to AgentScore-side infra failure.
95+
96+
Returns ``{"degraded": False}`` for normal allows; ``{"degraded": True,
97+
"infra_reason": "quota_exceeded" | "api_error" | "network_timeout"}`` when bypassed.
98+
"""
99+
state = request.get(GATE_STATE_KEY)
100+
if isinstance(state, dict) and state.get("degraded"):
101+
return {"degraded": True, "infra_reason": state.get("infra_reason")}
102+
return {"degraded": False}
103+
104+
105+
def get_gate_quota_info(request: web.Request) -> GateQuotaInfo | None:
106+
"""Read AgentScore assess quota observability for this request.
107+
108+
Captured from ``X-Quota-*`` response headers on this request's gate evaluate.
109+
"""
110+
state = request.get(GATE_STATE_KEY)
111+
if isinstance(state, dict):
112+
quota = state.get("quota")
113+
if isinstance(quota, GateQuotaInfo):
114+
return quota
115+
return None
116+
117+
76118
def _default_extract_identity(request: web.Request) -> AgentIdentity | None:
77119
token = request.headers.get(DEFAULT_TOKEN_HEADER)
78120
addr = request.headers.get(DEFAULT_ADDRESS_HEADER)
@@ -172,39 +214,11 @@ async def _agentscore_middleware(
172214

173215
chain_override = _extract_chain(request)
174216

217+
# Only acheck_identity is wrapped — the downstream handler call must NOT be in the
218+
# try, otherwise an exception in the user's route would be misclassified as an
219+
# AgentScore infra failure and (under fail_open) re-invoke their handler.
175220
try:
176221
result = await client.acheck_identity(identity, chain_override)
177-
178-
if result.allow:
179-
request["agentscore"] = result.raw
180-
return await handler(request)
181-
182-
# Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the
183-
# same UX as missing_identity: the gate mints a fresh verification session,
184-
# the agent polls until status=verified, gets a fresh opc_..., and retries
185-
# with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient,
186-
# jurisdiction_restricted) keep the bare wallet_not_trusted denial.
187-
# `jurisdiction_restricted` is unfixable: the API only emits it after KYC is
188-
# verified (the user's KYC'd country is in the blocked list — re-doing KYC
189-
# won't change the country).
190-
if is_fixable_denial(result.reasons) and create_session_on_missing is not None:
191-
session_reason = await try_create_session_denial_reason(
192-
create_session_on_missing,
193-
client.user_agent,
194-
request,
195-
)
196-
if session_reason is not None:
197-
body, status = _on_denied(request, session_reason)
198-
return web.json_response(body, status=status)
199-
200-
reason = DenialReason(
201-
code="wallet_not_trusted",
202-
decision=result.decision,
203-
reasons=result.reasons,
204-
verify_url=result.verify_url,
205-
)
206-
body, status = _on_denied(request, reason)
207-
return web.json_response(body, status=status)
208222
except PaymentRequiredError:
209223
if client.fail_open:
210224
return await handler(request)
@@ -218,12 +232,63 @@ async def _agentscore_middleware(
218232
# Permanent — no auto-session, agent should switch tokens or restart.
219233
body, status = _on_denied(request, build_invalid_credential_reason())
220234
return web.json_response(body, status=status)
235+
except QuotaExceededError:
236+
if client.fail_open:
237+
_mark_degraded_aiohttp(request, "quota_exceeded")
238+
return await handler(request)
239+
body, status = _on_denied(
240+
request,
241+
DenialReason(code="api_error", agent_instructions=QUOTA_EXCEEDED_INSTRUCTIONS),
242+
)
243+
return web.json_response(body, status=status)
244+
except httpx.TimeoutException:
245+
if client.fail_open:
246+
_mark_degraded_aiohttp(request, "network_timeout")
247+
return await handler(request)
248+
body, status = _on_denied(request, DenialReason(code="api_error"))
249+
return web.json_response(body, status=status)
221250
except Exception:
222251
if client.fail_open:
252+
_mark_degraded_aiohttp(request, "api_error")
223253
return await handler(request)
224254
body, status = _on_denied(request, DenialReason(code="api_error"))
225255
return web.json_response(body, status=status)
226256

257+
if result.allow:
258+
request["agentscore"] = result.raw
259+
if result.quota is not None:
260+
state = request.get(GATE_STATE_KEY)
261+
if isinstance(state, dict):
262+
state["quota"] = result.quota
263+
return await handler(request)
264+
265+
# Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the
266+
# same UX as missing_identity: the gate mints a fresh verification session,
267+
# the agent polls until status=verified, gets a fresh opc_..., and retries
268+
# with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient,
269+
# jurisdiction_restricted) keep the bare wallet_not_trusted denial.
270+
# `jurisdiction_restricted` is unfixable: the API only emits it after KYC is
271+
# verified (the user's KYC'd country is in the blocked list — re-doing KYC
272+
# won't change the country).
273+
if is_fixable_denial(result.reasons) and create_session_on_missing is not None:
274+
session_reason = await try_create_session_denial_reason(
275+
create_session_on_missing,
276+
client.user_agent,
277+
request,
278+
)
279+
if session_reason is not None:
280+
body, status = _on_denied(request, session_reason)
281+
return web.json_response(body, status=status)
282+
283+
reason = DenialReason(
284+
code="wallet_not_trusted",
285+
decision=result.decision,
286+
reasons=result.reasons,
287+
verify_url=result.verify_url,
288+
)
289+
body, status = _on_denied(request, reason)
290+
return web.json_response(body, status=status)
291+
227292
return _agentscore_middleware
228293

229294

0 commit comments

Comments
 (0)