Skip to content

Commit c00d51b

Browse files
vvillait88claude
andcommitted
hardening(identity): make AssessResult.account_verification a typed field
The UCP profile builder reads the typed AssessResult.operator_verification first then falls back to data.raw, but the typed account_verification branch was unreachable because the field wasn't declared on the dataclass. Add the optional account_verification: dict[str, Any] | None field for symmetry with operator_verification (mirrors node-commerce AgentScoreData.account_verification), populate it in AgentScoreClient._project, and adjust the UCP builder to read the typed field directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4dfee92 commit c00d51b

4 files changed

Lines changed: 36 additions & 26 deletions

File tree

agentscore_commerce/identity/client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,9 @@ def _project(self, data: dict[str, Any]) -> AssessResult:
218218
else None
219219
)
220220

221+
av_data = data.get("account_verification")
222+
account_verification = av_data if isinstance(av_data, dict) else None
223+
221224
# SDK populates `quota` on the AssessResponse from X-Quota-* headers. Surface up
222225
# to adapters so merchants can monitor approach-to-cap proactively.
223226
quota_raw = data.get("quota")
@@ -237,6 +240,7 @@ def _project(self, data: dict[str, Any]) -> AssessResult:
237240
reasons=reasons,
238241
identity_method=data.get("identity_method"),
239242
operator_verification=operator_verification,
243+
account_verification=account_verification,
240244
resolved_operator=data.get("resolved_operator"),
241245
verify_url=data.get("verify_url"),
242246
policy_result=data.get("policy_result"),

agentscore_commerce/identity/types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,11 @@ class AssessResult:
308308
reasons: list[str] = field(default_factory=list)
309309
identity_method: str | None = None
310310
operator_verification: OperatorVerification | None = None
311+
# Account-level verification block (KYC level, age bracket, jurisdiction,
312+
# sanctions verdict). Mirrors node-commerce's typed AgentScoreData.account_verification
313+
# field so a hand-constructed AssessResult emits the same UCP claims in both
314+
# languages without a raw-dict round trip.
315+
account_verification: dict[str, Any] | None = None
311316
resolved_operator: str | None = None
312317
verify_url: str | None = None
313318
policy_result: PolicyResult | None = None

agentscore_commerce/identity/ucp.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from __future__ import annotations
2222

2323
from dataclasses import dataclass, field
24-
from typing import TYPE_CHECKING, Any
24+
from typing import TYPE_CHECKING, Any, cast
2525

2626
if TYPE_CHECKING:
2727
from agentscore_commerce.identity.types import AssessResult
@@ -279,28 +279,29 @@ async def ucp_profile():
279279
# ``raw``; if a caller hand-constructs an AssessResult with mismatched
280280
# typed and raw verification blocks, both languages must pick the same
281281
# source so a profile signed in one verifies in the other.
282-
typed_op = getattr(data, "operator_verification", None)
283-
if typed_op is not None and not isinstance(typed_op, dict):
282+
typed_op = data.operator_verification
283+
operator_verification: dict[str, Any] = {}
284+
if isinstance(typed_op, dict):
285+
operator_verification = cast("dict[str, Any]", typed_op)
286+
elif typed_op is not None:
284287
# Convert OperatorVerification dataclass to a plain dict.
285288
operator_verification = {
286289
"level": getattr(typed_op, "level", None),
287290
"operator_type": getattr(typed_op, "operator_type", None),
288291
"verified_at": getattr(typed_op, "verified_at", None),
289292
}
290-
else:
291-
operator_verification = typed_op
292293
if not operator_verification:
293294
raw = data.raw or {}
294-
operator_verification = raw.get("operator_verification") if isinstance(raw, dict) else None
295-
if not isinstance(operator_verification, dict):
296-
operator_verification = {}
295+
raw_op = raw.get("operator_verification") if isinstance(raw, dict) else None
296+
if isinstance(raw_op, dict):
297+
operator_verification = raw_op
297298

298-
account_verification = getattr(data, "account_verification", None)
299+
account_verification: dict[str, Any] = data.account_verification or {}
299300
if not account_verification:
300301
raw = data.raw or {}
301-
account_verification = raw.get("account_verification") if isinstance(raw, dict) else None
302-
if not isinstance(account_verification, dict):
303-
account_verification = {}
302+
raw_av = raw.get("account_verification") if isinstance(raw, dict) else None
303+
if isinstance(raw_av, dict):
304+
account_verification = raw_av
304305
# `dict.get(k) or DEFAULT` (not `dict.get(k, DEFAULT)`) coerces both a
305306
# missing key AND a present-but-falsy (None / "") value to the default,
306307
# matching the node sibling's `||` semantics. The API can return

tests/test_ucp.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,9 @@ def test_coerces_empty_string_verified_at_to_none() -> None:
183183

184184

185185
# Typed-field fallback: production callers populate `data.raw`, but a
186-
# hand-constructed AssessResult (no raw) should still surface the operator
187-
# verification block via the typed `AssessResult.operator_verification` field
188-
# and the (optional) `account_verification` attribute. Mirrors the node sibling's
186+
# hand-constructed AssessResult (no raw) should still surface the verification
187+
# block via the typed `AssessResult.operator_verification` /
188+
# `AssessResult.account_verification` fields. Mirrors the node sibling's
189189
# typed-field read path.
190190

191191

@@ -209,22 +209,22 @@ def test_typed_operator_verification_fallback_when_raw_is_none() -> None:
209209
assert claims["verified_at"] == "2026-04-01T00:00:00Z"
210210

211211

212-
def test_typed_account_verification_fallback_via_setattr() -> None:
213-
# `AssessResult` doesn't declare `account_verification` as a typed field, but
214-
# a caller can still attach one ad-hoc. The fallback reads it via getattr so
215-
# parity with the node sibling holds whichever way the caller populates it.
212+
def test_typed_account_verification_fallback_when_raw_is_none() -> None:
213+
# `AssessResult.account_verification` is a typed optional field; a
214+
# hand-constructed result populates it directly via the constructor and the
215+
# builder reads it without consulting `raw`.
216216
result = AssessResult(
217217
allow=True,
218218
resolved_operator="op_typed",
219219
operator_verification=OperatorVerification(level="verified"),
220+
account_verification={
221+
"kyc_level": "verified",
222+
"age_bracket": "21+",
223+
"jurisdiction": "US",
224+
"sanctions_clear": True,
225+
},
220226
raw=None,
221227
)
222-
result.account_verification = { # type: ignore[attr-defined]
223-
"kyc_level": "verified",
224-
"age_bracket": "21+",
225-
"jurisdiction": "US",
226-
"sanctions_clear": True,
227-
}
228228
profile = build_ucp_profile(**_base_kwargs(), data=result)
229229
d = profile.to_dict()
230230
cap = next(c for c in d["capabilities"] if c["name"] == AGENTSCORE_UCP_CAPABILITY)
@@ -247,12 +247,12 @@ def test_typed_takes_precedence_over_raw() -> None:
247247
allow=True,
248248
resolved_operator="op_xyz",
249249
operator_verification=OperatorVerification(level="verified"),
250+
account_verification={"kyc_level": "verified"},
250251
raw={
251252
"operator_verification": {"level": "none"},
252253
"account_verification": {"kyc_level": "none"},
253254
},
254255
)
255-
result.account_verification = {"kyc_level": "verified"} # type: ignore[attr-defined]
256256
profile = build_ucp_profile(**_base_kwargs(), data=result)
257257
cap = next(c for c in profile.capabilities if c.name == AGENTSCORE_UCP_CAPABILITY)
258258
# Typed `account_verification.kyc_level == 'verified'` wins over the

0 commit comments

Comments
 (0)