Skip to content

Commit 63f575e

Browse files
vvillait88claude
andauthored
Let CreateSessionOnMissing mint a sign_in session (#133)
## Summary `CreateSessionOnMissing` gains an optional `kind` (`"kyc" | "sign_in"`) that rides through to `POST /v1/sessions`. A gate running an empty compliance policy that only needs an account to key durable state on (a prepaid balance, say) could previously auto-mint only the KYC kind, which asks the buyer for identity documents nothing in that merchant ever checks; `sign_in` is registration-only and still yields an operator token. When the kind is `sign_in` the minted denial carries a `message` describing an account sign-in instead of the KYC default, so a merchant using the default 403 body does not tell the agent something false. When `kind` is `None` nothing changes: the body omits it and the API default applies. Parity with the node library change of the same day. Takes `agentscore-py` 2.6.9, which carries the option. ## 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 `CreateSessionOnMissing.kind: Literal["kyc", "sign_in"] | None = None` (new, optional). No migration for existing callers. ## Test plan `tests/test_sessions.py`: a new case asserts the POST body carries `kind: "sign_in"` and that the returned reason's `message` talks about signing in with an AgentScore account and not about KYC; the existing forwarding case additionally asserts `kind` is absent when unset. Locally: `uv run ruff check`, `uv run ruff format --check`, `uv run ty check agentscore_commerce/`, `uv run pytest` (1869 passed, 4 skipped, 95.37% coverage 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 Worked with Varun. Version bumped to 2.9.0; the tag follows the merge. The README carries no `CreateSessionOnMissing` example today, so the field's docstring is its documentation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 31268a1 commit 63f575e

4 files changed

Lines changed: 48 additions & 10 deletions

File tree

‎agentscore_commerce/identity/sessions.py‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import logging
88
from collections.abc import Awaitable, Callable
99
from dataclasses import dataclass
10-
from typing import Any, cast
10+
from typing import Any, Literal, cast
1111

1212
from agentscore import AgentScore, AgentScoreError
1313

@@ -46,6 +46,13 @@ class CreateSessionOnMissing:
4646
base_url: str = "https://api.agentscore.com"
4747
context: str | None = None
4848
product_name: str | None = None
49+
# Session kind sent to POST /v1/sessions. "kyc" (the API default) runs identity
50+
# verification; "sign_in" is registration-only (the buyer signs in with an AgentScore
51+
# account, no identity documents) and mints a sign_in-scoped credential. Use it when the
52+
# gate runs with an EMPTY compliance policy and only needs an account to key state on
53+
# (a prepaid balance, say): a KYC session there asks for documents nothing will check.
54+
# The denial's default error.message follows the kind.
55+
kind: Literal["kyc", "sign_in"] | None = None
4956
# Per-request override of context / product_name. Receives the framework request
5057
# object; returns a dict with optional "context" and/or "product_name" keys.
5158
get_session_options: Callable[[Any], _Hookable] | None = None
@@ -91,12 +98,22 @@ def _resolved_session_options(cfg: CreateSessionOnMissing, dynamic: Any) -> dict
9198
options["context"] = cfg.context
9299
if cfg.product_name is not None:
93100
options["product_name"] = cfg.product_name
101+
if cfg.kind is not None:
102+
options["kind"] = cfg.kind
94103
return _apply_dynamic_options(options, dynamic)
95104

96105

106+
SIGN_IN_REQUIRED_MESSAGE = (
107+
"Sign-in is required to access this resource. Visit verify_url to sign in with an "
108+
"AgentScore account (no identity documents), then poll poll_url for the operator token "
109+
"and retry."
110+
)
111+
112+
97113
def _session_denial_reason(
98114
data: dict[str, Any],
99115
extra: dict[str, Any] | None = None,
116+
kind: Literal["kyc", "sign_in"] | None = None,
100117
) -> DenialReason | None:
101118
# Validate required fields before trusting the response. A misbehaving (or
102119
# mocked-wrong) API could 200 without session_id/poll_secret/verify_url, which
@@ -116,6 +133,9 @@ def _session_denial_reason(
116133
agent_instructions = json.dumps(next_steps) if next_steps else None
117134
return DenialReason(
118135
code="identity_verification_required",
136+
# The per-code default message talks about KYC, which a sign_in session never runs;
137+
# say what this session actually asks for so a merchant's default 403 is not a lie.
138+
message=SIGN_IN_REQUIRED_MESSAGE if kind == "sign_in" else None,
119139
verify_url=data["verify_url"],
120140
session_id=data["session_id"],
121141
poll_secret=data["poll_secret"],
@@ -171,7 +191,7 @@ async def try_create_session_denial_reason(
171191
except Exception as err:
172192
logger.warning("on_before_session hook failed: %s", err)
173193

174-
return _session_denial_reason(data, extra)
194+
return _session_denial_reason(data, extra, cfg.kind)
175195
except Exception:
176196
return None
177197

@@ -219,6 +239,6 @@ def try_create_session_denial_reason_sync(
219239
except Exception as err:
220240
logger.warning("on_before_session hook failed: %s", err)
221241

222-
return _session_denial_reason(data, extra)
242+
return _session_denial_reason(data, extra, cfg.kind)
223243
except Exception:
224244
return None

‎pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "agentscore-commerce"
7-
version = "2.8.3"
7+
version = "2.9.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"
1111
requires-python = ">=3.11"
1212
keywords = ["agentscore", "agent-commerce", "agentic-payments", "402", "x402", "mpp", "machine-payments-protocol", "fastapi", "starlette", "flask", "django", "aiohttp", "sanic", "middleware", "trust", "reputation", "kyc", "identity", "stripe", "tempo", "solana", "base", "ai-agent"]
1313
dependencies = [
1414
"httpx>=0.25.0,<1.0.0",
15-
"agentscore-py>=2.6.8",
15+
"agentscore-py>=2.6.9",
1616
]
1717
classifiers = [
1818
"Development Status :: 5 - Production/Stable",

‎tests/test_sessions.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,24 @@ def test_forwards_context_and_product_name(self):
8181
body = json.loads(route.calls[0].request.content)
8282
assert body["context"] == "purchase_flow"
8383
assert body["product_name"] == "Example Merchant"
84+
assert "kind" not in body
85+
86+
@respx.mock
87+
def test_forwards_kind_and_swaps_the_kyc_message_for_sign_in(self):
88+
route = respx.post(SESSIONS_URL).mock(return_value=httpx.Response(200, json=SESSION_RESPONSE))
89+
reason = try_create_session_denial_reason_sync(
90+
CreateSessionOnMissing(api_key="ask_test", kind="sign_in"),
91+
user_agent="agentscore-commerce/1.0",
92+
)
93+
import json
94+
95+
body = json.loads(route.calls[0].request.content)
96+
assert body["kind"] == "sign_in"
97+
assert reason is not None
98+
assert reason.code == "identity_verification_required"
99+
assert reason.message is not None
100+
assert "sign in with an AgentScore account" in reason.message
101+
assert "KYC" not in reason.message
84102

85103
@respx.mock
86104
def test_omits_context_and_product_name_when_not_provided(self):

‎uv.lock‎

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)