Skip to content

feat(identity): UCP profile signing helpers (1.3.7)#14

Open
vvillait88 wants to merge 32 commits intomainfrom
feat/ucp-signing
Open

feat(identity): UCP profile signing helpers (1.3.7)#14
vvillait88 wants to merge 32 commits intomainfrom
feat/ucp-signing

Conversation

@vvillait88
Copy link
Copy Markdown
Contributor

Summary

  • Add four helpers for UCP §6 trust-mode profiles: generate_ucp_signing_key, sign_ucp_profile, verify_ucp_profile, build_jwks_response. Profiles are JWS-signed (Compact Serialization) over a JCS-canonicalized body. Both EdDSA (Ed25519, default) and ES256 supported.
  • Cross-language byte parity with @agent-score/commerce Node SDK 1.3.4 — profiles signed by either SDK verify in the other.
  • joserfc is an optional extra (pip install agentscore-commerce[ucp]); vendors install it only when publishing signed UCP profiles. Unsigned build_ucp_profile keeps working.
  • /.well-known/jwks.json added to the default discovery-path allowlist.
  • Bumps to 1.3.7.

Test plan

  • Round-trip sign+verify for Ed25519 and ES256
  • Multi-key JWKS resolves by kid
  • Tamper detection via byte-equal canonical-body check after JWS verify
  • Missing key in JWKS rejects with InvalidKeyIdError
  • Profile without signature rejects
  • Key-order-independent verification (canonicalization)

🤖 Generated with Claude Code

vvillait88 and others added 3 commits May 8, 2026 10:58
Add four helpers for UCP §6 trust-mode profiles: generate_ucp_signing_key,
sign_ucp_profile, verify_ucp_profile, build_jwks_response. Profiles are
signed with JWS Compact Serialization over a JCS-canonicalized body;
verifiers look up the kid in the merchant's JWKS and validate against the
canonical body of the presented profile. Both EdDSA (Ed25519) and ES256
are supported.

Cross-language byte parity with @agent-score/commerce Node SDK: profiles
signed by either flavor verify in the other.

Bumps to 1.3.7. joserfc is an optional extra (`pip install
agentscore-commerce[ucp]`); vendors install it only when publishing signed
UCP profiles. /.well-known/jwks.json is added to the default discovery
path allowlist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- markdown-it-py 4.1.0 → 4.2.0
- pydantic-settings 2.14.0 → 2.14.1
- types-requests 2.33.0.20260503 → 2.33.0.20260508
- urllib3 2.6.3 → 2.7.0

Full suite green (737 passed, 3 skipped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer audit surfaced three real issues + several gaps. Fixes:

Security:
- verify_ucp_profile pre-deserializes the JWS protected header and
  enforces kid + typ='ucp-profile+jws' + alg in {EdDSA, ES256} BEFORE
  passing to joserfc, closing the missing-kid bypass where joserfc's
  KeySet-based verify silently iterates every key.
- Rejects duplicate kids in JWKS.
- _canonicalize_profile rejects float values at sign-time
  (cross-language float canonicalization is not stable).

Ergonomics:
- New UCPVerificationError (ValueError subclass) with discriminated
  `code` attribute. Wraps joserfc's BadSignatureError / DecodeError so
  consumers don't import joserfc internals.
- New UCPSigningKey.from_jwk() classmethod fixes the broken
  README/Mintlify example: `UCPSigningKey(**key.public_jwk)` raises
  TypeError because dataclass has fixed fields. The classmethod routes
  known fields + captures `x`/`y` into `extras`.

Docs:
- README documents kid/typ/alg enforcement, error codes, float defense,
  HSM/KMS via joserfc, key rotation runbook.
- Mintlify python-commerce.mdx fixed.
- New examples/signed_ucp_merchant.py demonstrates the full sign+verify
  flow with env-var-loaded key + asyncio.Lock-protected cache + cache
  headers + alg auto-detection from JWK shape.

Tests:
- Added 14 new tests covering kid-less JWS rejection, wrong typ,
  alg-confusion (HS256 hostile key), dup-kid, body mismatch, no_signature,
  tampered signature segment, malformed JWS, EdDSA determinism, ES256
  non-determinism, float rejection, UCPSigningKey.from_jwk round-trip.
- New cross-language fixture corpus (tests/fixtures/cross-lang/) with 6
  signed profiles (3 Node + 3 Python) that both SDKs verify, locking in
  byte parity at CI level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread examples/signed_ucp_merchant.py Fixed
vvillait88 and others added 19 commits May 8, 2026 15:14
Mirrors node-commerce hardening for cross-language parity.

- verify_ucp_profile: wrap UnsupportedHeaderError (RFC 7515 §4.1.11
  unrecognized `crit`) into UCPVerificationError('unrecognized_critical_header').
- verify_ucp_profile: malformed JWKS shape (non-dict, missing keys array)
  now emits UCPVerificationError('malformed_jwks') instead of cryptic
  AttributeError. Same for non-dict signed_profile input.
- verify_ucp_profile: reject JWKs with use != 'sig' as 'unusable_key'
  (RFC 7517 §4.2).
- sign_ucp_profile: enforce kid in profile.signing_keys[] at sign-time.
- UCPSigningKey.from_jwk: reject `oct` symmetric keys + missing
  `kid`/`kty` with typed ValueError instead of bare KeyError.
- Drop 3 redundant `del joserfc` statements.
- Cross-language fixture corpus: 6 → 12 fixtures (minimal, ES256-rails,
  extras-int, capability, unicode, multi-key per language).
- Fix vacuous key-order-independence test (json.dumps preserves order on
  Python 3.7+); now hand-constructs reverse insertion-order dict.
- README KMS claim rewritten — `OKPKey`/`ECKey` import key MATERIAL, they
  don't wrap remote signers.

Tests: 773 pass, 3 skipped. ruff + ty clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HIGH:
- verify_ucp_profile: _peek_jws_header now asserts the decoded header is a
  dict; non-dict JWS header (e.g. `null`, list, number) was raising
  AttributeError instead of UCPVerificationError(malformed_jws). Parity
  with Node.

Other:
- UCPProfile.to_dict: extras-collision filter rejects keys matching
  reserved profile fields (signing_keys, services, etc.) so
  `extras={'signing_keys': [...]}` can't silently destroy the explicit
  field.
- generate_ucp_signing_key, sign_ucp_profile, verify_ucp_profile: wrap
  joserfc calls in a context manager that suppresses the SecurityWarning
  emitted by joserfc on every EdDSA op (RFC 9864 deprecation, but UCP §6
  explicitly mandates EdDSA support). pyproject.toml ships an additional
  pytest filterwarnings rule for tests that hand-construct joserfc calls.
- sign_ucp_profile: kid must be a non-empty string. Empty kid silently
  passed the in-signing_keys check before.

Tests: 773 pass, 3 skipped. ruff + ty clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ADME codes)

Tighten the EdDSA SecurityWarning suppression to the exact joserfc message
and class so a future, unrelated EdDSA-vulnerability warning is no longer
swallowed: scope is now `joserfc.errors.SecurityWarning` with literal
regex `^EdDSA is deprecated via RFC 9864$` in both the helper and the
pyproject `filterwarnings`. Add explicit canonicalization tests for NaN,
positive-infinity, and negative-infinity floats (matching node-commerce
parity). Add coverage for `unusable_key` (JWK with `use=enc`),
non-string `signature` field (int / None / list / dict), non-dict JWKS
entries, and JWS protected headers that decode to a JSON array. Extend
the README error-code list with `unusable_key`, `malformed_jwks`, and
`unrecognized_critical_header`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…non-ASCII keys fixture)

verify_ucp_profile now rejects matched JWKs whose declared alg disagrees with
the JWS protected header alg (RFC 7517 §4.4: a JWK with `alg` constrains its
use to that algorithm). Closes a small security gap where a JWKS publisher
advertising the wrong alg on a key would silently let mismatched signatures
through joserfc's verify path.

_suppress_joserfc_eddsa_warning docstring corrected. The previous text claimed
joserfc emits the RFC-9864 SecurityWarning at every call including key
generation; empirically the warning fires only at JWS sign/verify (in the JWS
registry), not at OKPKey.generate_key. Removed the redundant suppression
wrapper around generate_key in generate_ucp_signing_key — it was a no-op.

New cross-language fixture py-emoji-keys.json mirrors the node-emoji-keys.json
fixture landing in node-commerce. Locks codepoint-aware key sort: Python's
default sorted() orders by Unicode codepoint, JS default sort orders by UTF-16
code units which diverges for supplementary-plane chars (e.g. U+1F377). The
fixture covers BMP CJK Compatibility (U+8C48), non-BMP wine glass (U+1F377),
and ASCII so both languages must explicitly sort by codepoint to maintain byte
parity.

Tests: 786 pass + 3 skipped, 95.17% coverage. ruff + ty clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ject set)

Tighten cross-lang fixture corpus + extras-collision defenses + _reject_floats
walking so cross-language byte parity has airtight regression coverage.

Cross-lang fixture corpus: previous py-emoji-keys.json used chars (cafe, JP, wine
glass, CJK Compat) where UTF-16 first-unit sort and Unicode codepoint sort produce
identical canonical bodies, so the codepoint-sort fix was silently untested in
the verifier-side parity check. Regenerates py-emoji-keys.json with a key set
that genuinely distinguishes the two: BMP private use (U+E000, codepoint 57344)
alongside supplementary-plane wine glass (U+1F377, codepoint 127863, UTF-16 first
unit 55356). Codepoint sort puts U+E000 BEFORE U+1F377; UTF-16 first-unit sort
reverses that. Both repos now ship both node-emoji-keys.json and py-emoji-keys.json
in their fixture corpus so each repo's verifier validates the OTHER language's
canonical body. A regression in either language's key sort surfaces here.

UCPProfile.to_dict reserved set adds __class__, __dict__, __init__ for symmetry
with node-commerce's prototype-pollution defense. Python's runtime model doesn't
have JS-style __proto__ pollution but the reserved-name check guards against
bidirectional vendor data passing through both SDKs and surprising downstream
consumers.

_reject_floats now walks set / frozenset in addition to list / tuple. A profile
containing a set with a float would otherwise crash later in json.dumps with an
untyped TypeError; catching it at the same surface as the list/tuple path keeps
the error message consistent.

README joserfc note adds tested-version pin (joserfc>=1.0.0,<2) mirroring
node-commerce's "tested against jose v5.x; pin jose@^5".

Tests: 800 pass + 3 skipped, 95.22% coverage. ruff + ty clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- verify_ucp_profile: compare signed payload against canonical body
  using hmac.compare_digest for constant-time equality, matching the
  node-commerce sibling.
- UCPProfile.to_dict reserved set: replace Python dunders with
  __proto__ / constructor / prototype so the reserved set is
  byte-identical across the Node and Python SDKs (Node-signed
  profiles carrying those keys are rejected by both).
- README: tell publishers to set Cache-Control: public, max-age=300
  on /.well-known/jwks.json and wait at least that long before
  removing the old JWK during rotation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Close cross-language byte-parity hole: Python's _reject_floats only walked for
float, while Node's stableStringify hard-rejects integers outside Number.MAX_SAFE_INTEGER
(2^53 - 1). A Python-signed profile with an oversized int produced a valid
self-verifying envelope that Node could not parse. Sign-time rejection on the
Python side now matches Node, plus a checked-in cross-lang fixture
(int-boundary) covers the safe-edge integer for both languages.

Renames _reject_floats to _reject_unsafe_numbers (private; not exported) and
extends it to raise ValueError for any int whose magnitude exceeds 2^53 - 1.
bool subclass of int still allowed; container walking (dict, list, tuple, set,
frozenset) preserved.

Tests: 7 new cases under TestUnsafeNumberRejection (max_safe boundary accept,
min_safe boundary accept, 2^53 reject, 2^60 reject, -(2^53) reject, nested
2^60 reject, bool accept). Existing float rejection tests kept intact under
the broader class name.

Cross-lang fixture: tests/fixtures/cross-lang/{py,node}-int-boundary.json
exercise max_safe_int / min_safe_int / small_int / neg_small_int / zero. Both
fixtures verify in both languages. node-int-boundary.json was generated by the
node-commerce companion script and copied here.

scripts/generate_int_boundary_fixture.py: one-shot regenerator.

Tests: 809 pass + 3 skipped, 95.22% coverage. ruff + ty clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y parity)

Wrap verifier-side _canonicalize_profile call so JCS-incompatible
numbers in a received profile emit a typed UCPVerificationError
(body_mismatch) instead of a raw ValueError. Symmetric with the Node
sibling fix.

Reorder verify_ucp_profile shape checks profile-first so verify(None,
malformed_jwks) returns no_signature, matching node-commerce.

Walk dict keys in _reject_unsafe_numbers so {2**60: "a"} raises at
sign-time. String dict keys (including numeric-looking ones like
"1.5") remain allowed; bool keys remain allowed (bool is int).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ity)

Move the JWS `crit` header check ahead of the JWKS kid lookup in
`verify_ucp_profile` so a crit-violating JWS with a missing/duplicate/unusable
kid surfaces `unrecognized_critical_header` instead of `kid_not_found` /
`duplicate_kid` / `unusable_key`.

Matches node-commerce's manual peek order: typ -> alg -> kid -> crit ->
kid_lookup. Without this, joserfc only enforces `crit` after its own kid
lookup, so the two SDKs diverged on inputs like `{kid: 'real',
crit: ['unknown']}` against a JWKS missing 'real'.

Adds a regression-guard test that hand-crafts a JWS carrying both a crit
violation AND a missing kid; existing crit + kid_not_found single-fault
tests still pass because the new check fires only when `crit` is present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round-17 added a `crit` precedence check gated on `if crit is not None`,
which let JSON null fall through to joserfc's iter and surface a raw
TypeError instead of the typed UCPVerificationError. Switch to a
key-presence gate (`if "crit" in header`) so null hits the shape branch
and emits malformed_jws, matching node-commerce's behavior.

Adds regression tests for crit=null, crit=[], and crit="string" (all
RFC 7515 §4.1.11 violations); the existing crit=['fakething'] coverage
keeps unrecognized_critical_header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the rejection in core/api/src/lib/canonicalize.ts so the SDK
canonicalizer stays byte-symmetric across the Node and Python siblings
on any pre-ES2019 V8 (where JSON.stringify still escapes these
codepoints) or browser-side verifier path; today's V8 emits them raw,
so the divergence is theoretical but the contract is now consistent
with the upstream check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The example used `UCPSigningKey(**key.public_jwk)`, which raises
TypeError because UCPSigningKey only accepts kid/kty/alg/use/crv/extras
(the JWK `x`/`y` fields belong in extras). Switch to the
`UCPSigningKey.from_jwk(...)` classmethod, matching the runnable
example in examples/signed_ucp_merchant.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ode sibling

The API can return account_verification with either null or empty-string for
un-set fields depending on the row state. The python builder used
dict.get(k, DEFAULT) for age_bracket/jurisdiction, which returns None verbatim
when the key is present-but-null instead of falling through to the default.
The node sibling used `??` (collapse null/undefined only, pass empty-string
verbatim).

For the same AssessResult shape, that meant the two SDKs emitted different
canonical claims blocks, so a profile signed in one language failed verify in
the other.

Switch python to `dict.get(k) or DEFAULT` for age_bracket and jurisdiction so
null AND empty-string both fall through to the schema default. The kyc_level
and verified_at branches already used `or` semantics; verified and left
as-is. Node sibling switched to `||` in the same round.

Adds a data-driven-claims cross-lang fixture that, unlike the rest of the
corpus, exercises build_ucp_profile's actual data path (constructs a synthetic
AssessResult with the API "missing" sentinels and lets the builder coalesce).
Both languages now emit identical canonical bytes for the input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…+ set/frozenset reject

Three reviewer LOW findings on the UCP profile path:

* `build_ucp_profile` now falls back to the typed `AssessResult.operator_verification`
  (and ad-hoc `account_verification`) field when `data.raw` doesn't carry the block.
  Production callers populate `raw`, but a hand-constructed `AssessResult(raw=None)`
  was silently dropping the operator verification block, diverging from the node
  sibling's typed-field read path.
* `UCPService`, `UCPCapability`, and `UCPSigningKey` now mirror `UCPProfile.to_dict`'s
  reserved-key collision guard so vendor `extras` can't silently overwrite a
  canonical declared field via `out.update(extras)`. `UCPPaymentHandler` has no
  `extras` attribute and is unaffected.
* `_reject_unsafe_numbers` now rejects `set` / `frozenset` outright with a typed
  `ValueError` (mirroring node's `stableStringify: Set values are not allowed`).
  Previously an empty set or a set-of-valid-strings fell through cleanly and
  surfaced a raw `TypeError` from `json.dumps` later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
verify_ucp_profile now treats explicit JSON null on JWK.use / JWK.alg as
absent (skip-on-null) rather than rejecting via joserfc's stricter
KeySet.import_key_set validation; both fields are optional per RFC 7517
and the Node sibling now uses ``!= null`` semantics, so a JWK with
``"use": null`` / ``"alg": null`` should pass language-symmetric verify.
The ``is not None`` checks above already skip null; we now also drop the
explicit-null entries before handing the JWK to joserfc so its key
parameter registry doesn't reject them downstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…FC 7515

RFC 7515 §4.1.11 requires crit array entries to be strings. The previous
shape check accepted arrays like [42] or [42, "valid"] because it only
guarded against non-list and empty-list shapes. Node-commerce already
rejects these with malformed_jws; Python now matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two cross-language parity fixes from round-29 SDK review:

LOW-1: build_ucp_profile now reads operator_verification and
account_verification from the typed AssessResult fields first, falling
back to data.raw only when the typed field is missing. Previously raw
won, diverging from node-commerce which reads typed fields directly.
With raw and typed in disagreement, both languages now pick the same
source so a profile signed by one verifies in the other.

LOW-2: _reject_unsafe_numbers now rejects bytes / bytearray with a
typed ValueError before json.dumps can raise its raw
"Object of type bytes is not JSON serializable" TypeError. Mirrors the
node sibling's "typed arrays are not allowed" rejection in
stableStringify.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ield

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>
…nt_handler config

Three cross-language parity fixes for build_ucp_profile / UCPPaymentHandler:

1. `data.account_verification == {}` (and `data.operator_verification == {}`)
   means "API returned the block with no populated values" and now wins over
   the `data.raw` fallback. The previous `if not account_verification:` check
   treated empty-dict as falsy and bled raw values through. Mirrors the Node
   sibling, which reads the typed field directly without consulting raw.

2. `UCPPaymentHandler.to_dict()` always emits `config` (even when empty).
   TypeScript serializes `{name: 'tempo', config: {}}` with `config`
   preserved; the dataclass default is `field(default_factory=dict)` so the
   field is always a dict. The previous `if self.config:` truthy gate
   produced byte divergence on explicit `config={}` callers.

3. New `typed-claims` cross-lang fixture exercises the typed-field-only read
   path (`AssessResult(account_verification={...}, raw=None)`) that the
   existing `data-driven-claims` fixture didn't cover (it uses `raw=`).
   Both languages must produce byte-identical canonical bytes for the typed
   path or cross-lang verify silently drifts in production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread tests/test_ucp.py Fixed
vvillait88 and others added 6 commits May 9, 2026 08:06
The previous comment claimed parity with node-commerce read order, but
Node has no raw fallback at all. Rewrite to accurately describe the
behavior: typed fields are canonical; raw fallback is a Python-only
hatch for hand-constructed AssessResult instances and may not verify
cross-language.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run `uv lock --upgrade`. Transitive bump only:
- propcache 0.4.1 -> 0.5.2

All declared direct deps remain at their latest versions
within existing constraint ranges. joserfc held at 1.x.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Replace em dashes with periods, semicolons, colons, or parens across
  README.md, CLAUDE.md, CONTRIBUTING.md, examples/README.md.
- examples/README.md: remove stale claim that Python lacks
  `create_x402_server` / `create_mppx_server` factories; both have
  shipped (in `agentscore_commerce.payment`) and are now native.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y import

CodeQL flagged the `/_selftest/ucp` route returning `str(exc)` to the HTTP
response body as information exposure. Match the round-26 sanitization
pattern used in core/store: log the full exception server-side via
logger.exception, expose only `type(exc).__name__` + the structured
verification code to the caller. The error class name is enough for an
operator to triage without revealing internal verification machinery.

Also drop the unused `Any` import from tests/test_ucp.py; the only cast()
target gets retyped to `OperatorVerification` (the actual field type),
which keeps the typed-empty-wins-over-raw test intent intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
….4.0)

UCP §6 does not define a profile-as-JWS typ, and UCP capability names must follow
reverse-DNS namespacing (``^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$``). The previous
``typ: ucp-profile+jws`` and ``agentscore-identity`` capability name implied
UCP-canonical signing/slots they aren't. Renamed to vendor-namespaced honest
forms:

- JWS protected header ``typ``: ``ucp-profile+jws`` -> ``agentscore-profile+jws``
  (matches the ``agentscore-risk-signal+jws`` pattern AP2 already uses).
- Capability name: ``agentscore-identity`` -> ``sh.agentscore.identity``
  (passes the UCP regex; namespace authority ``agentscore.sh``).
- Schema URL path: ``agentscore-identity.v1.json`` ->
  ``sh-agentscore-identity-v1.json`` (path matches the new capability name; URL
  hosted under namespace authority ``agentscore.sh`` per UCP convention).

All 10 cross-lang fixture scenarios re-signed via the new
``scripts/regenerate_cross_lang_fixtures.py`` orchestrator. Hand-crafted
``*-capability.json`` fixtures bumped to the new name to keep the corpus honest
about what callers should publish.

871 tests pass at 95.20% coverage. Cross-lang verify against the node sibling's
regenerated ``node-*`` corpus passes byte-identically for the data-driven and
typed-claims fixtures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The capability scenario in the cross-lang orchestrator hand-crafted the
old `https://agentscore.sh/schema/identity/1` URL while the SDK's
build_ucp_profile auto-injects the new
`https://agentscore.sh/schemas/ucp/sh-agentscore-identity-v1.json`. The
inconsistency made the corpus dishonest about what callers should
publish.

Update the orchestrator script to use the canonical URL and regenerate
all 20 fixtures so canonical bytes stay byte-identical between Node
and Python siblings. Also bump the README stability section to reflect
the current 1.4.0 version.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vvillait88 and others added 4 commits May 9, 2026 10:50
…ndary cross-lang regenerator

The Python regenerator passed `extras={"extras": {...}}` for the emoji-keys
and int-boundary scenarios, so the test keys ended up nested under a literal
"extras" key in the profile body. The Node sibling passes `extras={...}`
directly and `buildUCPProfile` flattens via `Object.assign(profile, extras)`,
so node fixtures had the keys at profile root interleaved with canonical
fields. Net: a regression in either language's top-level key-sort that fires
only when non-ASCII keys interleave with canonical fields would have surfaced
in node fixtures but not py fixtures, defeating the cross-lang corpus.

Also clarify the typed-empty-wins-over-raw behavior in `build_ucp_profile`
as Python-only (Node has no raw fallback at all, so the asymmetry covers
both directions: raw fallback AND the empty-dict-suppresses-fallback rule).

Both flavors of py-emoji-keys.json and py-int-boundary.json now match
node-* canonical bodies byte-for-byte (modulo per-run keypair material).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onal convention

Two cross-language parity fixes:

1. UCPPaymentHandler.to_dict() now omits the `config` key when empty. Node's
   `UCPPaymentHandler.config` is a TypeScript optional property and
   `buildUCPProfile` passes the array verbatim, so a Node caller writing
   `{ name: 'tempo' }` shipped a wire profile WITHOUT `config`. Python was
   force-emitting `config: {}` for that same input, producing different
   canonical bytes between SDKs for the same logical input. Explicit
   `config={}` is semantically identical to absent and follows the same
   omit rule.

2. Standalone int-boundary fixture script now uses build_ucp_profile(extras=...)
   instead of hand-building a profile dict with a top-level `extras` key.
   The orchestrator was already correct; the standalone version (used for
   one-off debug regen of a single fixture) was producing canonical bytes
   with a nested `extras` block while the orchestrator-produced fixture was
   flat. Mirrors how the data-driven-claims and typed-claims standalone
   scripts already work.

Cross-lang fixture corpus regenerated; new tests cover the omit behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…estrator

The cross-lang fixture orchestrator (regenerate_cross_lang_fixtures.py)
already covers int-boundary, data-driven-claims, and typed-claims; the
per-scenario one-shots duplicate the work with subtly different
json.dumps flags (no ensure_ascii=False), which would silently drift
the on-disk fixture if a future unicode field landed at the relevant
levels. Drop them so the orchestrator is the single source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…arity

Python's chained `or` returns the last falsy value, so
`account_verification.get("verified_at") or operator_verification.get("verified_at")`
returned `""` when both source values were empty strings. The Node sibling
returned `null` via `a || b || null`. Cross-language byte-parity divergence
on a corner the doc comment explicitly calls out (API may emit either null
or "" for un-set fields).

Add a trailing `or None` to match Node, plus a regression test covering
the both-empty corner. Also add a per-element shape parity note near the
dataclasses describing how Node's TypeScript-interface + flat-extras
approach reaches the same net contract as Python's dataclass-with-extras-
slot + runtime collision guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants