Skip to content

fix(sdk): preserve type-specific PaymentInstrument extension fields through signing - #329

Open
vishkaty wants to merge 1 commit into
google-agentic-commerce:mainfrom
vishkaty:fix/preserve-payment-instrument-extensions
Open

fix(sdk): preserve type-specific PaymentInstrument extension fields through signing#329
vishkaty wants to merge 1 commit into
google-agentic-commerce:mainfrom
vishkaty:fix/preserve-payment-instrument-extensions

Conversation

@vishkaty

@vishkaty vishkaty commented Aug 10, 2026

Copy link
Copy Markdown

fix(sdk): preserve type-specific PaymentInstrument extension fields through signing

Addresses #299 item 1: the Python SDK drops PaymentInstrument extension
properties before a mandate is signed. Items 2 (allowed-instrument matching,
#301/#324) and the sample amount fallback (#300) are handled separately and are
not touched here.

Observed vs expected

The specification permits a Payment Instrument type to define additional
properties, but the generated PaymentInstrument model keeps only id, type,
and description and silently discards the rest. Because signed claims are built
with model_dump(), the discarded properties never reach the signed Payment
Mandate, so a verifier that reads them sees a missing value.

Expected: properties a type defines (for x402, payee_address and
facilitator) survive parse -> model_dump -> sign -> verify, so the verifier
acts on the values that were actually signed.

Runtime reproduction (main @ e1ea56d)

from ap2.sdk.generated.types.payment_instrument import PaymentInstrument

PaymentInstrument(
    id="x402-usdc-1", type="x402",
    payee_address="0xAbCd...0001", facilitator="https://facilitator.example",
).model_dump()
# -> {'id': 'x402-usdc-1', 'type': 'x402', 'description': None}
# payee_address and facilitator are gone before signing.

Driving the actual x402 Credential Provider sample end to end with a genuinely
signed mandate chain (verified destination 0xAbCd...0001, verified amount
199c): the CP reads the now-missing payee_address, hits AttributeError, and
authorizes an EIP-3009 transfer to 0x7099...79C8 (DEFAULT_MERCHANT_ADDRESS)
for 12500000 USDC units (the hard-coded 1250c fallback), instead of the
verified destination and amount. A valid signed mandate is replaced with
fabricated fallback values on a payment path.

Exact sites

  • Model drops the fields: code/sdk/python/ap2/sdk/generated/types/payment_instrument.py#L10-L22
  • Signed via model_dump: code/sdk/python/ap2/sdk/sdjwt/common.py#L225-L229
  • CP fail-open on the missing field: code/samples/python/src/roles/x402_credentials_provider_mcp/server.py#L148-L170

Root cause

payment_instrument.json declares no additionalProperties, and generate.py
runs datamodel-codegen, which then emits a model with Pydantic's default posture
(unknown properties ignored). --field-extra-keys only whitelists the two
selective-disclosure markers, so nothing else is preserved.

Fix

Declare the open extension surface in the schema, and let codegen carry it
through:

  • code/sdk/schemas/ap2/types/payment_instrument.json: add
    "additionalProperties": true.
  • Regenerated payment_instrument.py now carries
    model_config = ConfigDict(extra='allow').

datamodel-codegen already maps a schema's additionalProperties onto a Pydantic
extra policy in this repo: jwk.json (additionalProperties: false) generates
extra='forbid', and ucp/types/buyer.json / ucp/types/checkout.json
(additionalProperties: true) already generate extra='allow'. This change just
applies the same, existing convention to PaymentInstrument. Pydantic v2 then
preserves the extra properties through model_dump (hence through signing,
parsing, and verification) and exposes them for attribute access, so the x402 CP
reads the verified payee_address and payment_amount instead of falling back.

Why schema-driven rather than a per-type model or a global codegen flag:

  • The invariant lives in the schema (data), not in hand-written per-type code,
    so new instrument types need no SDK changes.
  • It opens the extension surface rather than closing it, so it does not
    constrain what a type may define (additionalProperties: false would).
  • It is scoped to the one type the spec calls extensible; jwk's intentional
    extra='forbid' and every other model are untouched.
  • It is the AP2 analogue of the extension-preservation fixes in UCP
    python-sdk#66 and js-sdk#40.

Spec grounding

  • AP2 specification.md, Payment Instrument: "additional properties MAY be
    defined for that specific type."
  • UCP preserves extension (extra) data through its model round trip; this keeps
    AP2 consistent with that behavior.

Class sweep

The class: a generated model that drops a property which is passed in practice
and must travel through signing. Swept every schema and every construction in the
SDK and samples.

Model Schema additionalProperties Non-schema fields passed? Disposition
PaymentInstrument absent -> now true yes: payee_address, facilitator (x402) converted
types/buyer, types/checkout already true n/a already extra='allow'; no change needed
types/jwk false (intentionally closed) no out of scope; must stay closed
PaymentReceipt, CheckoutReceipt oneOf variants fields are all schema-declared and preserved out of scope; no drop
all other generated models absent (default ignore) no construction passes non-schema fields out of scope; no observed extension use

Only PaymentInstrument is a genuine instance.

Side benefit (no behavior anyone relies on changes)

Preserving extension fields also strengthens the existing "Pre-set
payment_instrument mismatch" equality check (constraints.py): with extras
retained, PaymentInstrument(id, type) no longer compares equal to
PaymentInstrument(id, type, payee_address="0xATTACKER"), so a payee_address
swap that an id/type-only comparison silently accepted is now caught. Extensions
are only ever carried inside the SD-JWT-signed payload and are read by name where
used; instrument matching keys on declared fields only, so no allow-list or
matching decision is loosened by this change.

Tests

Adds code/sdk/python/ap2/tests/payment_instrument_extension_tests.py:

  • model_dump and delegate-claims preserve the x402 extension fields.
  • The fields survive the full sign -> verify -> typed-parse round trip.
  • Kill-test mirroring the CP extraction (server.py#L148-L160): the verified
    destination and amount are sourced, never the default address or the 1250
    fallback.

Each test fails on main for the right reason (fields dropped) and passes with
the fix; removing the regenerated model_config line reddens all four.

Verification

  • Full SDK suite: 190 passed. The two failing kb_sd_jwt aud/nonce tests are
    pre-existing on main (the KB-hop area of fix(sdjwt): honor expected aud/nonce on every KB hop #313/fix(sdjwt): require expected aud/nonce when a terminal KB hop carries them #326) and unrelated to this
    change.
  • Regeneration reproducibility: running generate.py against the updated schema
    reproduces the committed model body byte for byte. The only regen churn is the
    pre-existing header-timestamp non-determinism, so the committed file keeps the
    existing timestamp to avoid unrelated diff.
  • E2E against the real x402 Credential Provider sample: with the fix, the CP
    authorizes the verified payee and 199c; without it, DEFAULT_MERCHANT_ADDRESS
    and 1250c.
  • Lint: the diff introduces no new Biome-lint findings; the new test is
    ruff-clean under the repo .ruff.toml. Spellcheck is addressed below.

Spellcheck

The spellcheck workflow (cspell-action, incremental_files_only) re-scans each
changed file in full, so it surfaced domain terms not yet in the dictionary
(datamodel/codegen in the generated model header; sdjwt/SECP in the new
test). Added those four to .cspell/custom-words.txt. No prose words introduced.

@vishkaty
vishkaty requested a review from a team as a code owner August 10, 2026 22:05
…hrough signing

## Observed vs expected

The AP2 specification permits a Payment Instrument `type` to define additional
properties, but the generated `PaymentInstrument` model silently discards every
property beyond `id`, `type`, and `description`. Because signed claims are built
with `model_dump()`, those extension fields are absent from the signed Payment
Mandate, and a downstream verifier that reads them observes a missing value.

Expected: fields a `type` defines (for x402: `payee_address`, `facilitator`)
survive parse -> model_dump -> sign -> verify, so a verifier acts on the values
the user actually signed.

## Runtime reproduction (main @ e1ea56d)

    PaymentInstrument(
        id="x402-usdc-1", type="x402",
        payee_address="0xAbCd...0001", facilitator="https://facilitator.example",
    ).model_dump()
    # -> {'id': 'x402-usdc-1', 'type': 'x402', 'description': None}
    # payee_address and facilitator are dropped before signing.

Driving the actual x402 Credential Provider sample end to end with a genuinely
signed mandate chain (destination 0xAbCd...0001, amount 199c), the CP authorizes
an EIP-3009 transfer to 0x7099...79C8 (DEFAULT_MERCHANT_ADDRESS) for 12500000
USDC units (the hard-coded 1250c fallback) rather than the verified destination
and amount. This is a fail-open on a payment path: a valid, signed mandate is
replaced by fabricated fallback values.

## Root cause and exact sites

- Model drops the fields: code/sdk/python/ap2/sdk/generated/types/payment_instrument.py#L10-L22
- Signed via model_dump: code/sdk/python/ap2/sdk/sdjwt/common.py#L225-L229
- CP fail-open on the missing field: code/samples/python/src/roles/x402_credentials_provider_mcp/server.py#L148-L170

The generated model carries no `extra` policy, so Pydantic's default silently
ignores unknown properties. `code/sdk/schemas/ap2/types/payment_instrument.json`
declares no `additionalProperties`, and `generate.py` runs datamodel-codegen
which, given no `additionalProperties`, emits a model with the default
(ignore) posture.

## Fix

Declare the open extension surface in the schema:
`payment_instrument.json` gains `"additionalProperties": true`. datamodel-codegen
already maps a schema's `additionalProperties` to a Pydantic `extra` policy
(`jwk.json` -> `extra='forbid'`; `ucp/types/buyer.json` and
`ucp/types/checkout.json` -> `extra='allow'`), so regenerating emits
`model_config = ConfigDict(extra='allow')` on `PaymentInstrument`. Pydantic v2
then preserves the extra properties through `model_dump` (hence through signing,
parsing, and verification) and exposes them for attribute access.

This is schema-driven, not per-type code: the durable invariant lives in the
schema (data), it opens the extension surface rather than closing it (so it does
not constrain what a `type` may define), and it reuses the same convention the
repo already applies to buyer/checkout. It is the AP2 analogue of the
extension-preservation fixes made in UCP python-sdk#66 and js-sdk#40.

## Spec grounding

- AP2 specification.md, Payment Instrument: "additional properties MAY be
  defined for that specific `type`."
- UCP models preserve extension (`extra`) data through their round trip; this
  keeps AP2 consistent with that.

## Dedup

google-agentic-commerce#299 item 1 is unaddressed by any open PR. google-agentic-commerce#301 (item 2, allowed-instrument
matching) explicitly left item 1 to maintainers: "Preserving type-specific
instrument fields through parsing and signing (google-agentic-commerce#299 item 1) is a schema and
generated-model design decision ... which I have left for maintainers." google-agentic-commerce#300
hardens the sample amount fallback (fail closed) and is complementary: it does
not restore the dropped destination field, which this change does at the source.

## Class sweep (schema drops a field that is passed and must travel)

| Model | Schema additionalProperties | Passed non-schema fields? | Disposition |
|-------|-----------------------------|---------------------------|-------------|
| PaymentInstrument | absent -> now `true` | yes: payee_address, facilitator (x402) | CONVERTED |
| types/buyer, types/checkout | already `true` | n/a | already `extra='allow'`, no change |
| types/jwk | `false` (intentionally closed) | no | out of scope, must stay closed |
| PaymentReceipt, CheckoutReceipt | oneOf variants | fields are schema-declared, preserved | out of scope, no drop |
| all other generated models | absent (default ignore) | no construction passes non-schema fields | out of scope, no observed extension use |

## Tests

Adds code/sdk/python/ap2/tests/payment_instrument_extension_tests.py:
- model_dump / delegate-claims preserve x402 extension fields
- fields survive the full sign -> verify -> typed-parse round trip
- kill-test mirroring the CP extraction: verified destination + amount are
  sourced, never the default address or the 1250 fallback

Full SDK suite: 190 passed. The two failing kb_sd_jwt aud/nonce tests are
pre-existing on main and unrelated to this change.
@vishkaty
vishkaty force-pushed the fix/preserve-payment-instrument-extensions branch from a2b8d7d to 901f1e2 Compare August 10, 2026 22:18
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.

1 participant