Skip to content
Merged
11 changes: 10 additions & 1 deletion agentscore_commerce/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,13 @@
agentscore_commerce.api - AgentScore SDK re-export
"""

__version__ = "1.0.0"
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version

try:
__version__ = _pkg_version("agentscore-commerce")
except PackageNotFoundError:
# Editable install or pre-build state — fall back to a sentinel so consumers
# don't crash on a missing dist-info dir. Real version always comes from
# pyproject.toml at install time.
__version__ = "0.0.0+local"
22 changes: 17 additions & 5 deletions agentscore_commerce/discovery/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,14 @@ def agentscore_denial_schemas() -> dict[str, Any]:
"payment_required",
],
"description": (
"Denial code emitted by AgentScore's gate middleware in 403 responses. Each comes with a "
"structured agent_instructions block describing recovery actions."
"Denial code emitted by AgentScore's gate middleware in 403 responses. Every code carries a "
"structured agent_instructions block describing recovery actions (per-code action: "
"missing_identity → probe_identity_then_session, identity_verification_required / "
"token_expired → deliver_verify_url_and_poll, invalid_credential → "
"switch_token_or_restart_session, wallet_signer_mismatch → resign_or_switch_to_operator_token, "
"wallet_auth_requires_wallet_signing → switch_to_operator_token, wallet_not_trusted → "
"contact_support — UNFIXABLE compliance only (sanctions/age/jurisdiction_restricted); "
"fixable reasons re-route to identity_verification_required, payment_required → contact_merchant)."
),
},
"AgentScoreDenialBody": {
Expand All @@ -56,14 +62,20 @@ def agentscore_denial_schemas() -> dict[str, Any]:
"agent_instructions": {
"type": "string",
"description": (
"JSON-encoded { action, steps, user_message } block. Agents parse this to learn how "
"to recover (e.g., poll a verify_url, switch headers, re-sign)."
"JSON-encoded { action, steps, user_message } block. Always present on every "
"denial; agents parse this to learn how to recover (e.g., poll verify_url, "
"switch headers, re-sign)."
),
},
"verify_url": {
"type": "string",
"format": "uri",
"description": "Present for missing_identity / token_expired denials.",
"description": (
"Present for missing_identity / identity_verification_required / token_expired "
"denials. Agent shares this with the user to complete KYC or claim a wallet. "
"Not present on wallet_not_trusted (UNFIXABLE compliance — re-verification "
"won't change the outcome)."
),
},
"session_id": {"type": "string"},
"poll_url": {"type": "string", "format": "uri"},
Expand Down
19 changes: 14 additions & 5 deletions agentscore_commerce/identity/_denial.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,35 @@

from agentscore_commerce.identity.types import DenialReason, VerifyWalletSignerResult

# Compliance denial reasons that can be resolved by re-completing KYC. The API emits these
# when KYC is missing/pending/failed; the user can re-verify and retry.
#
# `jurisdiction_restricted` is NOT in this set — the API only emits it AFTER KYC is verified,
# meaning the user's KYC'd country is in the merchant's blocked list (or absent from the
# allowed list). Re-doing KYC won't change the country, so it's permanent. Same shape as
# `sanctions_flagged` and `age_insufficient` — surface contact_support, don't waste a
# /v1/sessions mint.
FIXABLE_DENIAL_REASONS: frozenset[str] = frozenset(
{
"kyc_required",
"kyc_pending",
"kyc_failed",
"jurisdiction_restricted",
}
)


def is_fixable_denial(reasons: Iterable[str] | None) -> bool:
"""Return True when every reason is fixable (or reasons is empty/None).
"""Return True when every reason is fixable via KYC re-verification.

Sanctions and age failures are permanent — any of those in the list returns False.
False when any reason is permanent (sanctions, age, jurisdiction_restricted) OR when
reasons is empty/None — without a known reason we can't promise a fix, so default to
the bare denial path.
"""
if not reasons:
return True
return False
reasons_list = list(reasons)
if not reasons_list:
return True
return False
return all(r in FIXABLE_DENIAL_REASONS for r in reasons_list)


Expand Down
117 changes: 115 additions & 2 deletions agentscore_commerce/identity/_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,118 @@
}
)

WALLET_NOT_TRUSTED_INSTRUCTIONS = json.dumps(
{
"action": "contact_support",
"steps": [
(
"The wallet's operator failed an UNFIXABLE compliance check (sanctions, "
"age, or jurisdiction). `reasons` lists which: `sanctions_flagged` / "
"`age_insufficient` / `jurisdiction_restricted`. KYC re-verification "
"won't change the outcome — the policy denial is structural."
),
(
"Surface the denial to the user with the merchant's support contact. "
"Do not retry the same merchant request; do not hand the user a "
"verify_url (verification won't fix this code path)."
),
(
"Fixable compliance reasons (`kyc_required`, `kyc_pending`, "
"`kyc_failed`) do NOT land on this code — the gate auto-mints a "
"verification session for those and returns "
"`identity_verification_required` with poll endpoints, same shape as "
"`missing_identity`. `jurisdiction_restricted` IS in the unfixable "
"bucket because the API only emits it after KYC is verified (the "
"user's KYC'd country is in the blocked list — re-doing KYC won't "
"change the country)."
),
],
"user_message": (
"This purchase is denied by the merchant's compliance policy and cannot be "
"resolved by re-verifying. Contact the merchant's support if you believe "
"this is in error."
),
}
)

PAYMENT_REQUIRED_INSTRUCTIONS = json.dumps(
{
"action": "contact_merchant",
"steps": [
(
"The merchant's AgentScore tier does not include the assess feature, so "
"agent identity cannot be evaluated. This is a merchant-side configuration "
"gap — there is no agent-side recovery."
),
(
"Contact the merchant (their support channel — typically listed in "
"/llms.txt or the OpenAPI servers metadata) and request they upgrade "
"their AgentScore plan."
),
],
"user_message": (
"This merchant's identity gate is misconfigured (AgentScore tier doesn't "
"support assess). Contact the merchant — there's nothing to fix on the "
"agent side."
),
}
)

# Fallback when API didn't supply next_steps. Normal path provides them; this is
# defense-in-depth so 403s never go out without a machine-readable recovery step.
IDENTITY_VERIFICATION_REQUIRED_FALLBACK_INSTRUCTIONS = json.dumps(
{
"action": "deliver_verify_url_and_poll",
"steps": [
"Share verify_url with the user — they complete identity verification on AgentScore.",
(
"If session_id + poll_secret are present in the body, poll poll_url every "
"5 seconds with header `X-Poll-Secret: <poll_secret>` until status=verified. "
"The poll returns a one-time operator_token."
),
"Retry the original request with header `X-Operator-Token: <opc_...>`.",
],
"user_message": (
"Identity verification is required. Visit verify_url, then poll poll_url for the operator token and retry."
),
}
)

TOKEN_EXPIRED_FALLBACK_INSTRUCTIONS = json.dumps(
{
"action": "deliver_verify_url_and_poll",
"steps": [
(
"The operator token is expired or revoked. AgentScore auto-mints a fresh "
"verification session — complete it to receive a new opc_..."
),
(
"Share verify_url with the user, then poll poll_url every 5 seconds with "
"header `X-Poll-Secret: <poll_secret>` until status=verified. The poll "
"returns a fresh one-time operator_token."
),
"Retry the original request with header `X-Operator-Token: <new_opc_...>`.",
],
"user_message": (
"Operator token is expired or revoked. A new verification session has been "
"minted — visit verify_url to refresh."
),
}
)

# Default agent_instructions per denial code. Adapters can override by passing
# ``agent_instructions=`` on the DenialReason; otherwise the body emitter looks
# up this map so every denial carries a machine-readable next step.
_DEFAULT_AGENT_INSTRUCTIONS: dict[str, str] = {
"missing_identity": _MISSING_IDENTITY_INSTRUCTIONS,
"wallet_signer_mismatch": WALLET_SIGNER_MISMATCH_INSTRUCTIONS,
"wallet_auth_requires_wallet_signing": WALLET_AUTH_REQUIRES_WALLET_SIGNING_INSTRUCTIONS,
"wallet_not_trusted": WALLET_NOT_TRUSTED_INSTRUCTIONS,
"payment_required": PAYMENT_REQUIRED_INSTRUCTIONS,
"identity_verification_required": IDENTITY_VERIFICATION_REQUIRED_FALLBACK_INSTRUCTIONS,
"token_expired": TOKEN_EXPIRED_FALLBACK_INSTRUCTIONS,
}


def build_missing_identity_reason() -> DenialReason:
"""Construct a missing_identity DenialReason with the cross-merchant memory hint attached.
Expand Down Expand Up @@ -189,8 +301,9 @@ def denial_reason_to_body(reason: DenialReason) -> dict[str, Any]:
body["poll_secret"] = reason.poll_secret
if reason.poll_url:
body["poll_url"] = reason.poll_url
if reason.agent_instructions:
body["agent_instructions"] = reason.agent_instructions
instructions = reason.agent_instructions or _DEFAULT_AGENT_INSTRUCTIONS.get(reason.code)
if instructions:
body["agent_instructions"] = instructions
# Cross-merchant pattern hint.
if reason.agent_memory is not None:
body["agent_memory"] = asdict(reason.agent_memory)
Expand Down
18 changes: 18 additions & 0 deletions agentscore_commerce/identity/aiohttp.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,24 @@ async def _agentscore_middleware(
request["agentscore"] = result.raw
return await handler(request)

# Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the
# same UX as missing_identity: the gate mints a fresh verification session,
# the agent polls until status=verified, gets a fresh opc_..., and retries
# with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient,
# jurisdiction_restricted) keep the bare wallet_not_trusted denial.
# `jurisdiction_restricted` is unfixable: the API only emits it after KYC is
# verified (the user's KYC'd country is in the blocked list — re-doing KYC
# won't change the country).
if is_fixable_denial(result.reasons) and create_session_on_missing is not None:
session_reason = await try_create_session_denial_reason(
create_session_on_missing,
client.user_agent,
request,
)
if session_reason is not None:
body, status = _on_denied(request, session_reason)
return web.json_response(body, status=status)

reason = DenialReason(
code="wallet_not_trusted",
decision=result.decision,
Expand Down
17 changes: 17 additions & 0 deletions agentscore_commerce/identity/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,23 @@ def __call__(self, request: HttpRequest) -> Any:
setattr(request, "agentscore", result.raw) # noqa: B010 — dynamic attribute attach on HttpRequest
return self.get_response(request)

# Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the
# same UX as missing_identity: the gate mints a fresh verification session,
# the agent polls until status=verified, gets a fresh opc_..., and retries
# with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient,
# jurisdiction_restricted) keep the bare wallet_not_trusted denial.
# `jurisdiction_restricted` is unfixable: the API only emits it after KYC is
# verified (the user's KYC'd country is in the blocked list — re-doing KYC
# won't change the country).
if is_fixable_denial(result.reasons) and self._create_session_on_missing is not None:
session_reason = try_create_session_denial_reason_sync(
self._create_session_on_missing,
self._client.user_agent,
request,
)
if session_reason is not None:
return self._on_denied(request, session_reason)

reason = DenialReason(
code="wallet_not_trusted",
decision=result.decision,
Expand Down
17 changes: 17 additions & 0 deletions agentscore_commerce/identity/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,23 @@ async def __call__(self, request: Request) -> None:
setattr(request.state, ASSESS_STATE_KEY, result.raw)
return

# Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the
# same UX as missing_identity: the gate mints a fresh verification session, the
# agent polls until status=verified, gets a fresh opc_..., and retries with
# X-Operator-Token. No "go to verify_url and tell us when done" gap.
# Unfixable reasons (sanctions_flagged, age_insufficient, jurisdiction_restricted)
# keep the bare wallet_not_trusted denial — re-verification won't fix them.
# `jurisdiction_restricted` is unfixable because the API only emits it AFTER KYC
# is verified (the user's KYC'd country is in the blocked list).
if is_fixable_denial(result.reasons) and self._create_session_on_missing is not None:
session_reason = await try_create_session_denial_reason(
self._create_session_on_missing,
self._client.user_agent,
request,
)
if session_reason is not None:
self._deny(request, session_reason)

self._deny(
request,
DenialReason(
Expand Down
Loading
Loading