Skip to content

Commit de9811a

Browse files
authored
Bump to 2.6.0: surface the pairwise operator handle through the Gate (#111)
## Summary Adds `get_operator_handle(request)` across all six identity adapters, plus `ctx.operator_handle` inside `Checkout` hooks. It returns the stable pairwise `oph_...` handle for the account behind the request's operator token. This is the Python half of the same capability landing in the node library, kept level with it. **What it is for.** Merchants keying durable state on identity, prepaid balances above all, need a value that outlives a credential. An `opc_` lives 24h and rotates silently off a 90-day refresh, so state keyed on the token instance is stranded daily, and revoking a leaked token would forfeit the balance it was holding. The handle derives from the account, so rotation, expiry and revocation are free, and it is pairwise per merchant so handles never correlate across stores. **It costs nothing extra.** The handle rides the `/v1/assess` response the gate already fetches, so the accessor is a synchronous read exactly like `get_signer_verdict`: no second round trip on a merchant's hot path, and nothing additional metered against their quota. **One derivation, not six.** `project_operator_handle(raw)` in `identity/core.py` is what every adapter and `Checkout` calls. Written twice it would drift, and that failure is silent: one spelling's handle simply misses the other's rows, so a buyer quietly grows a second balance while every uniqueness check still passes. **Stashed before the allow/deny branch**, so it is available on denials too. This is identity rather than a verdict, and a merchant recording a denial against the buyer needs it on exactly the path where its handler never runs. Anything that is not a well-formed `oph_` string reads as absent rather than being passed through, so a half-configured API can never hand a merchant a value it would write balance rows against. ## 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 only; no migration for existing callers. - `get_operator_handle(request)` exported from `identity.{fastapi,middleware,django,aiohttp,sanic}`; the Flask variant takes no argument and reads `g`, matching its sibling accessors. - `ctx.operator_handle` on `CheckoutContext`. - `project_operator_handle` exported from `identity.core`. Requires an API that returns `operator_handle` on `/v1/assess`. Against an older API the field is simply absent and every accessor returns `None`, so upgrading this package alone changes no behavior. ## Test plan New `tests/test_operator_handle.py` (17 cases): the projection accepts a well-formed handle and refuses every non-usable shape (absent, empty, wrong prefix, non-string, `None`, wallet-path response), and each of the six adapters is exercised on its own state mechanism, including Flask both inside and outside an application context. Full local gates: `ruff check` and `ruff format --check` clean, `ty check agentscore_commerce/` clean, `pytest` 1861 passed / 4 skipped with coverage 95.35% against the 95% floor. ## 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 f3cc5bd commit de9811a

12 files changed

Lines changed: 374 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,18 @@ Two identity types: wallet (`X-Wallet-Address`) and operator-token (`X-Operator-
8787

8888
`get_signer_verdict(request)` (per-adapter) returns the cached `signer_match` + `signer_sanctions` verdicts the gate composed on its primary `/v1/assess` call (single round trip; merchants build a 403 with `build_signer_mismatch_body(result=verdict.signer_match)` when `kind != "pass"`).
8989

90+
### Operator handle: what durable merchant state keys on
91+
92+
`get_operator_handle(request)` (per-adapter; Flask takes no argument and reads `g`; `ctx.operator_handle` inside `Checkout` hooks) returns the stable pairwise `oph_...` handle for the ACCOUNT behind the request's operator token.
93+
94+
**Key state on this, never on the token.** An `opc_` lives 24h and rotates silently off a 90-day refresh, so anything keyed on the token instance is stranded daily, and revoking a leaked token would forfeit a prepaid balance. The handle derives from the account, so rotation, expiry and revocation are all free. It is pairwise per consuming merchant, so the same buyer presents an unrelated handle at every store and handles never correlate across them.
95+
96+
It rides the gate's existing `/v1/assess` response, so reading it costs **no extra round trip and nothing extra against the merchant's quota**. That is why the accessor is synchronous like `get_signer_verdict` rather than doing a lookup of its own.
97+
98+
Returns `None` when the gate did not run, on wallet-authenticated paths (there is no operator token to resolve), or when the API has no handle salt configured. Available on **denied** requests too, so a merchant recording a denial against a buyer can still key it. It carries no compliance meaning: a registration-only (`sign_in`) credential resolves exactly like a KYC-backed one, so read the decision fields for policy.
99+
100+
Anything that is not a well-formed `oph_` string reads as absent rather than being passed through, so a half-configured API can never hand a merchant a value it would write balance rows against. `project_operator_handle(raw)` in `identity/core.py` is the single derivation both the adapters and `Checkout` call.
101+
90102
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.
91103

92104
Wallet-signer-match + signer-sanctions: the gate adapter calls `extract_payment_signer(x402_header)` pre-evaluate and passes `signer={address, network}` to the SDK's `assess`. The API returns both `signer_match` (wallet-binding) and `signer_sanctions` (OFAC SDN wallet-address) on the same response; commerce caches the raw body alongside the projected verdicts so `get_signer_verdict` is a pure cache read. **Wallet-OFAC SDN enforcement on the `signer` block is unconditional** whenever a signer is present — no `policy.require_sanctions_clear` opt-in required. An SDN hit (or `sanctions_check_unavailable`) flips `decision -> deny` and the gate returns 403 before the handler runs.

agentscore_commerce/checkout.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,17 @@ class CheckoutContext:
339339
"""Merchant-supplied per-request state, populated by :attr:`Checkout.pre_validate`.
340340
Other hooks read from here (e.g. ``ctx.state["product"]`` after pre_validate
341341
resolved it). Stays empty when no pre_validate is configured."""
342+
operator_handle: str | None = None
343+
"""Stable pairwise handle for the ACCOUNT behind this request's operator token.
344+
345+
Set by Checkout's internal gate from the same ``/v1/assess`` response it already
346+
fetched, so it costs no extra round trip and nothing extra against quota.
347+
348+
This is what durable merchant state should key on, prepaid balances above all: it
349+
survives the token rotating, expiring or being revoked, whereas state keyed on the token
350+
instance is stranded every time one rotates. ``None`` when no gate is configured, on
351+
wallet or AIT paths, on anonymous discovery legs, or when the API has no handle salt.
352+
"""
342353
capture_wallet: Callable[..., Any] | None = None
343354
"""Capture the signer wallet under the operator credential the gate resolved
344355
for this request. Set by Checkout's internal gate after a successful allow when
@@ -2188,6 +2199,11 @@ async def _run_gate(self, ctx: CheckoutContext) -> CheckoutResult | None:
21882199
if signer_denial is not None:
21892200
return signer_denial
21902201

2202+
# The pairwise account handle rides the same assess response the gate just used.
2203+
from agentscore_commerce.identity.core import project_operator_handle
2204+
2205+
ctx.operator_handle = project_operator_handle(ctx.request.assess)
2206+
21912207
# Stash ctx.capture_wallet so on_settled can bind the signer wallet to
21922208
# the operator credential without needing a framework-specific context.
21932209
# No-op when the request was wallet-authenticated (no operator_token).

agentscore_commerce/identity/aiohttp.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,13 @@ async def _agentscore_middleware(
266266
return await handler(request)
267267
return _deny_response(request, DenialReason(code="api_error"))
268268

269+
# The pairwise account handle rides this same assess response. Stash it BEFORE the
270+
# allow/deny branch: it is identity rather than a verdict, so a merchant recording a
271+
# denial against the buyer needs it on the path where its handler never runs.
272+
_handle_state = request.get(GATE_STATE_KEY)
273+
if isinstance(_handle_state, dict):
274+
_handle_state["operator_handle"] = client.project_operator_handle(result.raw)
275+
269276
if result.allow:
270277
request["agentscore"] = result.raw
271278
state = request.get(GATE_STATE_KEY)
@@ -462,3 +469,23 @@ def conditional_aip_gate_middleware(**kwargs: Any) -> Any:
462469

463470
kwargs["condition"] = has_agent_identity_header
464471
return aip_gate_middleware(**kwargs)
472+
473+
474+
def get_operator_handle(request: web.Request) -> str | None:
475+
"""Read the stable pairwise operator handle for the account behind this request's token.
476+
477+
This is what durable merchant state (prepaid balances first) should key on: it survives
478+
the token rotating, expiring or being revoked, whereas anything keyed on the token
479+
instance is stranded every time one rotates.
480+
481+
Synchronous and free. The handle rides the gate's existing ``/v1/assess`` call, so
482+
reading it costs no extra round trip and nothing extra against the merchant's quota.
483+
484+
Returns ``None`` when the gate did not run, when no operator token was presented (wallet
485+
or AIT paths), or when the API has no handle salt configured. Available on denied
486+
requests too, so a merchant recording a denial against a buyer can still key it.
487+
"""
488+
state = request.get(GATE_STATE_KEY)
489+
if not isinstance(state, dict):
490+
return None
491+
return state.get("operator_handle")

agentscore_commerce/identity/core.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,26 @@
5757
DEFAULT_CACHE_SECONDS = 300
5858

5959

60+
def project_operator_handle(raw: dict[str, Any] | None) -> str | None:
61+
"""Project the stable pairwise operator handle from a raw ``/v1/assess`` response.
62+
63+
Pure, and the single derivation both the gate adapters and ``Checkout`` call: written
64+
twice it would drift, and the failure is silent, since one spelling's handle simply
65+
misses the other's rows and a buyer quietly grows a second balance.
66+
67+
Narrowed rather than cast. The field is absent whenever the request carried no operator
68+
token, and absent is also what the API emits when its handle salt is unconfigured, so
69+
anything that is not a usable ``oph_`` string must read as "no handle" instead of
70+
becoming a state key.
71+
"""
72+
if not isinstance(raw, dict):
73+
return None
74+
value = raw.get("operator_handle")
75+
if isinstance(value, str) and value.startswith("oph_"):
76+
return value
77+
return None
78+
79+
6080
class AgentScoreCore:
6181
"""Shared client for calling the AgentScore assess API.
6282
@@ -525,6 +545,24 @@ def _stash_signer_raw(
525545
):
526546
self._last_signer_raw[normalize_address(address)] = raw
527547

548+
def project_operator_handle(self, raw: dict[str, Any] | None) -> str | None:
549+
"""Project the stable pairwise operator handle from a raw ``/v1/assess`` response.
550+
551+
The handle is the identity durable merchant state (prepaid balances first) should
552+
key on, because it survives the token rotating, expiring or being revoked: an
553+
``opc_`` lives 24h and rotates silently off a 90-day refresh, so anything keyed on
554+
the token instance is stranded daily.
555+
556+
It rides the assess response the gate already fetches, so reading it costs no extra
557+
round trip and nothing extra against the merchant's quota.
558+
559+
Narrowed rather than cast: the field is absent whenever the request carried no
560+
operator token, and absent is also what the API emits when its handle salt is
561+
unconfigured. Anything that is not a usable ``oph_`` string must read as "no handle"
562+
instead of becoming a state key.
563+
"""
564+
return project_operator_handle(raw)
565+
528566
def project_signer_verdict(self, raw: dict[str, Any] | None, claimed_address: str) -> SignerVerdict | None:
529567
"""Project ``signer_match`` + ``signer_sanctions`` from a SPECIFIC raw assess response.
530568

agentscore_commerce/identity/django.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,13 @@ def __call__(self, request: HttpRequest) -> Any:
247247
return self.get_response(request)
248248
return self._on_denied(request, DenialReason(code="api_error"))
249249

250+
# The pairwise account handle rides this same assess response. Stash it BEFORE the
251+
# allow/deny branch: it is identity rather than a verdict, so a merchant recording a
252+
# denial against the buyer needs it on the path where its handler never runs.
253+
_handle_state = getattr(request, "_agentscore_gate", None)
254+
if isinstance(_handle_state, dict):
255+
_handle_state["operator_handle"] = self._client.project_operator_handle(result.raw)
256+
250257
if result.allow:
251258
setattr(request, "agentscore", result.raw) # noqa: B010 — dynamic attribute attach on HttpRequest
252259
state = getattr(request, "_agentscore_gate", None)
@@ -472,3 +479,23 @@ def __init__(self, get_response: Any) -> None:
472479

473480
super().__init__(get_response)
474481
self._condition = lambda request: has_agent_identity_header_parts(dict(request.headers))
482+
483+
484+
def get_operator_handle(request: HttpRequest) -> str | None:
485+
"""Read the stable pairwise operator handle for the account behind this request's token.
486+
487+
This is what durable merchant state (prepaid balances first) should key on: it survives
488+
the token rotating, expiring or being revoked, whereas anything keyed on the token
489+
instance is stranded every time one rotates.
490+
491+
Synchronous and free. The handle rides the gate's existing ``/v1/assess`` call, so
492+
reading it costs no extra round trip and nothing extra against the merchant's quota.
493+
494+
Returns ``None`` when the gate did not run, when no operator token was presented (wallet
495+
or AIT paths), or when the API has no handle salt configured. Available on denied
496+
requests too, so a merchant recording a denial against a buyer can still key it.
497+
"""
498+
state = getattr(request, "_agentscore_gate", None)
499+
if not isinstance(state, dict):
500+
return None
501+
return state.get("operator_handle")

agentscore_commerce/identity/fastapi.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,13 @@ async def __call__(self, request: Request) -> None:
341341
return
342342
self._deny(request, DenialReason(code="api_error"))
343343

344+
# The pairwise account handle rides this same assess response. Stash it BEFORE the
345+
# allow/deny branch: it is identity rather than a verdict, so a merchant recording a
346+
# denial against the buyer needs it on the path where its handler never runs.
347+
_handle_state = getattr(request.state, GATE_STATE_KEY, None)
348+
if isinstance(_handle_state, dict):
349+
_handle_state["operator_handle"] = self._client.project_operator_handle(result.raw)
350+
344351
if result.allow:
345352
setattr(request.state, ASSESS_STATE_KEY, result.raw)
346353
state = getattr(request.state, GATE_STATE_KEY, None)
@@ -598,3 +605,23 @@ async def checkout(ait = Depends(get_verified_ait)):
598605
...
599606
"""
600607
return getattr(request.state, AIT_STATE_KEY, None)
608+
609+
610+
def get_operator_handle(request: Request) -> str | None:
611+
"""Read the stable pairwise operator handle for the account behind this request's token.
612+
613+
This is what durable merchant state (prepaid balances first) should key on: it survives
614+
the token rotating, expiring or being revoked, whereas anything keyed on the token
615+
instance is stranded every time one rotates.
616+
617+
Synchronous and free. The handle rides the gate's existing ``/v1/assess`` call, so
618+
reading it costs no extra round trip and nothing extra against the merchant's quota.
619+
620+
Returns ``None`` when the gate did not run, when no operator token was presented (wallet
621+
or AIT paths), or when the API has no handle salt configured. Available on denied
622+
requests too, so a merchant recording a denial against a buyer can still key it.
623+
"""
624+
state = getattr(request.state, GATE_STATE_KEY, None)
625+
if not isinstance(state, dict):
626+
return None
627+
return state.get("operator_handle")

agentscore_commerce/identity/flask.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,14 @@ def _agentscore_check() -> Response | tuple[Response, int] | None:
270270
try:
271271
result = client.check_identity(identity, chain_override, signer=signer_payload)
272272

273+
# The pairwise account handle rides this same assess response. Stash it BEFORE
274+
# the allow/deny branch: it is identity rather than a verdict, so a merchant
275+
# recording a denial against the buyer needs it on the path where its handler
276+
# never runs.
277+
_handle_state = getattr(g, "_agentscore_gate", None)
278+
if isinstance(_handle_state, dict):
279+
_handle_state["operator_handle"] = client.project_operator_handle(result.raw)
280+
273281
if result.allow:
274282
g.agentscore = result.raw
275283
state = getattr(g, "_agentscore_gate", None)
@@ -527,3 +535,30 @@ def _has_header(request: Request) -> bool:
527535

528536
kwargs["condition"] = _has_header
529537
aip_gate(app, **kwargs)
538+
539+
540+
def get_operator_handle() -> str | None:
541+
"""Read the stable pairwise operator handle for the account behind this request's token.
542+
543+
This is what durable merchant state (prepaid balances first) should key on: it survives
544+
the token rotating, expiring or being revoked, whereas anything keyed on the token
545+
instance is stranded every time one rotates.
546+
547+
Synchronous and free. The handle rides the gate's existing ``/v1/assess`` call, so
548+
reading it costs no extra round trip and nothing extra against the merchant's quota.
549+
550+
Returns ``None`` when the gate did not run, when no operator token was presented (wallet
551+
or AIT paths), or when the API has no handle salt configured. Available on denied
552+
requests too, so a merchant recording a denial against a buyer can still key it.
553+
"""
554+
from flask import g
555+
556+
try:
557+
state = getattr(g, "_agentscore_gate", None)
558+
except RuntimeError:
559+
# No application context (called outside a request). Same posture as the sibling
560+
# accessor: absent state reads as no handle, never as an error.
561+
return None
562+
if not isinstance(state, dict):
563+
return None
564+
return state.get("operator_handle")

agentscore_commerce/identity/middleware.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,13 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
298298
await response(scope, receive, send)
299299
return
300300

301+
# The pairwise account handle rides this same assess response. Stash it BEFORE the
302+
# allow/deny branch: it is identity rather than a verdict, so a merchant recording a
303+
# denial against the buyer needs it on the path where its handler never runs.
304+
_handle_state = getattr(request.state, GATE_STATE_KEY, None)
305+
if isinstance(_handle_state, dict):
306+
_handle_state["operator_handle"] = self._client.project_operator_handle(result.raw)
307+
301308
if result.allow:
302309
scope["state"] = {**scope.get("state", {}), "agentscore": result.raw}
303310
state = scope["state"].get(GATE_STATE_KEY)
@@ -513,3 +520,23 @@ class ConditionalAipGate(AipGate):
513520
def __init__(self, app: ASGIApp, **kwargs: Any) -> None:
514521
kwargs["condition"] = has_agent_identity_header
515522
super().__init__(app, **kwargs)
523+
524+
525+
def get_operator_handle(request: Request) -> str | None:
526+
"""Read the stable pairwise operator handle for the account behind this request's token.
527+
528+
This is what durable merchant state (prepaid balances first) should key on: it survives
529+
the token rotating, expiring or being revoked, whereas anything keyed on the token
530+
instance is stranded every time one rotates.
531+
532+
Synchronous and free. The handle rides the gate's existing ``/v1/assess`` call, so
533+
reading it costs no extra round trip and nothing extra against the merchant's quota.
534+
535+
Returns ``None`` when the gate did not run, when no operator token was presented (wallet
536+
or AIT paths), or when the API has no handle salt configured. Available on denied
537+
requests too, so a merchant recording a denial against a buyer can still key it.
538+
"""
539+
state = getattr(request.state, GATE_STATE_KEY, None)
540+
if not isinstance(state, dict):
541+
return None
542+
return state.get("operator_handle")

agentscore_commerce/identity/sanic.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,14 @@ async def _agentscore_check(request: Request) -> HTTPResponse | None:
238238
try:
239239
result = await client.acheck_identity(identity, chain_override, signer=signer_payload)
240240

241+
# The pairwise account handle rides this same assess response. Stash it BEFORE
242+
# the allow/deny branch: it is identity rather than a verdict, so a merchant
243+
# recording a denial against the buyer needs it on the path where its handler
244+
# never runs.
245+
_handle_state = getattr(request.ctx, GATE_STATE_ATTR, None)
246+
if isinstance(_handle_state, dict):
247+
_handle_state["operator_handle"] = client.project_operator_handle(result.raw)
248+
241249
if result.allow:
242250
request.ctx.agentscore = result.raw
243251
state = getattr(request.ctx, GATE_STATE_ATTR, None)
@@ -454,3 +462,23 @@ def conditional_aip_gate(app: Sanic, **kwargs: Any) -> None:
454462

455463
kwargs["condition"] = has_agent_identity_header
456464
aip_gate(app, **kwargs)
465+
466+
467+
def get_operator_handle(request: Request) -> str | None:
468+
"""Read the stable pairwise operator handle for the account behind this request's token.
469+
470+
This is what durable merchant state (prepaid balances first) should key on: it survives
471+
the token rotating, expiring or being revoked, whereas anything keyed on the token
472+
instance is stranded every time one rotates.
473+
474+
Synchronous and free. The handle rides the gate's existing ``/v1/assess`` call, so
475+
reading it costs no extra round trip and nothing extra against the merchant's quota.
476+
477+
Returns ``None`` when the gate did not run, when no operator token was presented (wallet
478+
or AIT paths), or when the API has no handle salt configured. Available on denied
479+
requests too, so a merchant recording a denial against a buyer can still key it.
480+
"""
481+
state = getattr(request.ctx, GATE_STATE_ATTR, None)
482+
if not isinstance(state, dict):
483+
return None
484+
return state.get("operator_handle")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "agentscore-commerce"
7-
version = "2.5.18"
7+
version = "2.6.0"
88
description = "Agentic commerce SDK for Python: identity middleware (FastAPI, Flask, Django, AIOHTTP, Sanic, ASGI) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agentic commerce."
99
readme = "README.md"
1010
license = "MIT"

0 commit comments

Comments
 (0)