Skip to content

feat!: Express instrument requirements with request constraints, split PAN and Network Token credential types - #424

Merged
amithanda merged 4 commits into
mainfrom
proto/funding-source-and-credential-constraints
Aug 22, 2026
Merged

feat!: Express instrument requirements with request constraints, split PAN and Network Token credential types#424
amithanda merged 4 commits into
mainfrom
proto/funding-source-and-credential-constraints

Conversation

@raginpirate

@raginpirate raginpirate commented May 8, 2026

Copy link
Copy Markdown
Member

Larger-scope continuation of #288, now stacked on #655. Earlier iterations are #580 (axis factoring), #626 (constraints / options partition), #564 (fulfillment application) and #565 (credential split).

What changed since the original proposal. This PR opened by adding three new primitives — object_constraint.json, value_constraint.json, type_constraint.json — and modelling available_instruments[] as an array of Type Constraints. Review established that a constraint primitive grafted onto every object that negotiates input is not a generic contract (#424 review), that closed enums and closed dispatch permanently exclude extensions because allOf can only intersect, and that the validation the complexity bought was never actually enforced. #655 then landed a single closed Constraint Expression grammar and bound it to request data.

So this PR now adds no additional constraint primitive at all. It makes the requirements this arc was about expressible with the grammar that already exists, by fixing the two things that were blocking it: credential variants that could not be addressed positionally, and a protocol member that could only carry one rule.

Changes

  • Distinct pan_credential and network_token_credential. Different PCI
    scope, different required fields, different verification data.
    card_credential — which folded both behind card_number_type — is
    deprecated, not removed.
  • card_payment_instrument.network, the card scheme elected for a
    transaction. A submitted field, present when a co-badged selector was shown.
  • constraint_target.brand, representing the card scheme accepted. A value the Business
    derives from the account number, so it survives tokenization and is not a
    submitted field. This is the target for the standard constraint object on every available instrument.
  • Outright removed the $def for available_instruments; trying to maintain it through this significant prose change is tough, and this release is outright breaking all references to payments objects regardless. Its worth also renaming the constraint target now.

Example: Require a CVC for a PAN but a cryptogram for a network token

The anchoring ask from #288, and the one concern (C4) left open on #655. One path
selects the submitted instrument and a nested anyOf (#757) expresses the two
credential branches: splitting the credential types is what gives each branch a
stable discriminator, so no rule has to branch on a sibling field.

{                                                                                                                                                                  
  "type": "card",                                                                                                                                                  
  "constraints": { "properties": { "brand": { "enum": ["visa", "mastercard"] } } },                                                                                                  
  "ucp": {                                                                                                                                                         
    "request_constraints": {                                                                                                                                       
      "path": "$['payment']['instruments'][?@['handler_id'] == 'processor_1' && @['type'] == 'card']",                                                             
      "required": ["network", "billing_address", "credential"],                                                                                                    
      "properties": {                                                                                                                                              
        "billing_address": { "required": ["postal_code", "address_country"] },                                                                                     
        "credential": {                                                                                                                                            
          "anyOf": [                                                                                                                                               
            { "properties": { "type": { "const": "pan" } }, "required": ["cvc"] },                                                                                 
            { "properties": { "type": { "const": "network_token" } }, "required": ["cryptogram", "eci_value"] }                                                    
          ]                                                                                                                                                        
        }                                                                                                                                                          
      }                                                                                                                                                            
    }                                                                                                                                                              
  }                                                                                                                                                                
}                                                                                                                                                                  

One declaration covers all three of the use cases worked through on this PR:
CVV on a card, AVS fields on the billing address, and a network-token cryptogram.
Requirements shared by every card instrument are sibling members; the ones that
differ per credential family are branches. A network token is never asked for a
cvc, and a PAN is never asked for a cryptogram. brand sits in constraints
because it is derived; every requirement above targets a field a Platform
actually submits.

Where the review concerns land

Numbering follows the summary on #655.

# Concern Status
C1 Learn requirements before submitting, not via submit-and-fail Resolved. Response-carried, evaluated before the next request.
C2 Field-name typos (billing_addr) Build-time lint. Documented, with a SHOULD on the analyzable path form so tooling can resolve the target.
C3 Brittle named booleans (requires_card_verification) Resolved. No named booleans anywhere.
C4 Polymorphic credential discrimination Resolved. Structural split plus a nested anyOf; see the example above.
C5 type discriminator didn't drive validation Resolved where a handler references the instrument's available_* definition and declares the optional ucp property. Both now documented.
C6 constraints isn't truly generic Resolved by not adding a primitive. The grammar is constraint_expression.json, unchanged.
C7 Credential inception — requirements on what a token represents Not addressed. Off-wire; see below.
C8 Expressiveness and locality Resolved by division: derived values stay in the domain's constraints, wire data goes to request_constraints.
C9 options over-strict typing Dissolved with the options axis.
C10 properties keyword collision Resolved upstream in #655.
Closed enums exclude extensions Resolved. No enums; see example 3.

Not in scope

Requirements on what a token represents (C7). A token credential carries an
opaque token, so the credential it was minted from is not in the payload. This requires a bit of a taxonomy shift and the hardest part of the problem is wanting to evaluate "required": [] constraints for elements NOT being actively negotiated over the wire; requiring payments to commit to minting a potentially unique constraint application.

Category (Required)

Please select one or more categories that apply to this change.

  • Core Protocol: Changes to the base communication layer, global context, or breaking refactors. (Requires Technical Council approval)

Checklist

  • I have followed the Contributing Guide (including Conventional Commits title requirements and ! for breaking changes).
  • I have updated the documentation (if applicable).
  • My changes pass all local linting and formatting checks.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • (For Core/Capability) I have included/updated the relevant JSON schemas.
  • I have regenerated Python Pydantic models by running generate_models.sh under python_sdk.

@jamesandersen jamesandersen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@raginpirate thanks for putting #424 together — I do think this proposal works well:

  • (+) Introduces a new credential type network_token
    • Adds the distinction I was looking for originally in #296 ;-)
    • ... and probably will have implications for the doc changes proposed in #367
  • (+) Required fields are explicit via the new constraint primitive
    • e.g. under instrument -> credentials -> constraints -> required_fields (more like where #288 started but now implemented at the right level)
    • In contrast to where #288 was trending with implicit constraints via instrument -> constraints -> requires_card_verification and docs to explain intent at the credential level
  • (+) Cross-domain reuse e.g. address_constraint.json, credential_constraint.json
  • (-) Breaking change with removal of card_number_type ... left separate comment on this

On the "credential inception" point e.g. distinct constraint on a wire credential and - where applicable - the funding source underlying it. What do you think about taking this on in a separate PR? I'd like to better understand if there is a concrete scenario requiring both? e.g. can a business require a CVV on the underlying instrument while only accepting the network token? It's been a winding road so far to get alignment on updated constraint modeling for just the "wire credential" ... hopefully a follow-on for the underlying funding source could be quicker and cleaner after landing this.

TBH ... it took a while to grok this all (in large part for lack of good focus time) but I think the resolved examples are actually much easier than the schema suggests at first glance - just as another sanity check the multiple entries under credentials is how we address the hurdle that led us to the implicit bool and AVS enum on #288. Look right?

  {                                                                                                                            
    "available_instruments": [                              
      {                                                                                                                        
        "type": "card",  // card-based instrument                                                                                         
        "constraints": {                                                                               
          "brands": ["visa", "mastercard"],                                                                                    
          "required_fields": ["billing_address"],                                                                     
          "billing_address": {                                                                                                 
            "required_fields": ["postal_code"] // e.g. AVS1, but adapts to other schemes                                                                    
          },                                                                                                                   
          "credentials": [
            { 
              "type": "card", // FPAN
              "constraints": { "required_fields": ["cvc"] } 
            },
            { 
              "type": "network_token", // DPAN / CPAN
              "constraints": { "required_fields": ["cryptogram"] } // could add ECI if a business needs it
            }
          ]                                                                                                                    
        }                                                   
      },                                                                                                                        
    ]                                                                                                                          
  }

Comment thread source/schemas/shopping/types/card_credential.json
Comment thread source/schemas/shopping/types/network_token_credential.json Outdated
Comment thread source/schemas/shopping/types/network_token_credential.json
Comment thread source/schemas/shopping/types/network_token_credential.json
@TateLyman

Copy link
Copy Markdown

Schema read only.

One validation gap to keep an eye on if this moves from prototype to spec shape: credential_constraint.json describes constraints as being scoped to the named credential type, and the concrete credential schemas define narrowed $defs.constraint objects, but the actual reference is still the generic base constraint:

"constraints": {
  "$ref": "constraint.json"
}

Because of that, JSON Schema validation will not use type to select card_credential.json#/$defs/constraint or network_token_credential.json#/$defs/constraint. For example, these look invalid by the intended semantics but appear valid against the current primitive:

{ "type": "card", "constraints": { "required_fields": ["cryptogram"] } }
{ "type": "network_token", "constraints": { "required_fields": ["cvc"] } }

Same issue for funding_sources: the body says entries must reference schemas inheriting from payment_funding_source.json, but type is an unconstrained string in the schema itself.

Suggested direction: either make credential_constraint an explicit oneOf over known credential types (if type == card then constraints -> card_credential#/$defs/constraint, etc.), or keep it intentionally registry/runtime-validated and soften the schema language so platforms do not assume this is machine-checkable from the JSON Schema alone.

The interoperability risk is that merchants/platforms may think constraint payloads are statically validated, while cross-axis mistakes only exist in prose. That matters for checkout because a mistyped funding-source requirement can become a failed tokenization/payment path rather than a schema error.

@jamesandersen

jamesandersen commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Great catch @TateLyman — this is a real validation gap. As you noted, the type discriminator in credential_constraint.json doesn't drive schema selection for the constraints body, so cross-axis mistakes like { "type": "card", "constraints": { "required_fields": ["cryptogram"] } } pass validation silently and only surface as payment failures. This matters particularly because UCP aims for a machine-verifiable schema — it's the foundation for SDK generation, so constraints that are only enforced in prose undermine that goal.

Your if/then suggestion would work well and has precedent in UCP — total.json already uses this exact pattern (discriminating on type to apply different validation rules to a sibling property), as does pagination.json (conditional field requirements based on a sibling value).

A couple of alternative approaches that achieve the same schema validation, for consideration:

Per-credential $defs with oneOf dispatch: Each credential schema (card_credential.json, network_token_credential.json) already defines a $defs/constraint narrowing required_fields. Each could additionally define a $defs/credential_constraint that bundles type: { const: "card" } with a constraints ref to its own $defs/constraint. The instrument schema would then use oneOf over those refs instead of referencing credential_constraint.json directly. This keeps each credential schema self-contained — no central file needs to know about all types.

Single $defs/constraint (breaking change): Take the above a step further — instead of adding a parallel $defs/credential_constraint, redefine $defs/constraint itself to be the full credential constraint entry (extending credential_constraint.json via allOf, adding the type const and narrowed constraints). One concept, one $defs entry per credential schema. Cleanest mental model, but this would be a breaking change to the main schema if merged. Worth weighing whether the value of a tighter, self-contained schema outweighs the inconvenience to implementations that have already adopted $defs/constraint in its current form.

All three approaches reject the same invalid payloads — the difference is where the enforcement lives: central dispatch (if/then), per-schema with two defs, or per-schema with one def.

@raginpirate WDYT?

@raginpirate
raginpirate force-pushed the proto/funding-source-and-credential-constraints branch from 1fa04e8 to 6b04550 Compare June 8, 2026 06:06
@raginpirate

raginpirate commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

Thanks for the comments folks; totally agree with the schema validation concern. This actually goes back to the original payment handler design; we did not associate instruments with any credential types as an attempt to show they are open objects to be associated as needed. In that same vein, I did not originally try having machine-enforcable types for the constraints, but honestly I think this is just a gap we can close. In my latest revision I've actually taken the oneOf approach for both constraints as well as credentials themselves 👍

I wanted to get this into review this weekend but it took quite a bit of time shredding my original proposal apart here: one of the main contentious points is "funding sources" as you pointed out @jamesandersen, and in trying to isolate it I reverted my world view on the concept and moved it into a type of constraint specific to token credentials.

I'm feeling good about the work in this PR, and my steps to get this ready to publish are:

  • Isolate each commit into its own PR
  • Add documentation for schema-authoring to describe how to write constraints across UCP
  • Rewrite documentation for payment handlers across the board to include our new constraints

One interesting note is still how we refactor the card credential: I think the right path is to actually entirely deprecate it and just introduce a PAN credential and a network token credential. Its technically non-breaking too because card is isolated and attached to nothing in the base spec; handlers in the wild using it can just keep referencing it from the old api versions without concern if they really want to. WDYT about this or the plan above?

@jamesandersen

Copy link
Copy Markdown
Contributor

"funding sources" as you pointed out @jamesandersen, and in trying to isolate it I reverted my world view on the concept and moved it into a type of constraint specific to token credentials.

@raginpirate makes sense to me ... if introducing distinct PRs I think this will become more digestible ;-)

deprecate it and just introduce a PAN credential and a network token credential

I do think this is going to be easier to reason about in the long run

@kmcduffie
kmcduffie requested a review from GarethCOliver June 16, 2026 14:12
@raginpirate
raginpirate force-pushed the proto/funding-source-and-credential-constraints branch from 6b04550 to 2371672 Compare July 6, 2026 05:33
@raginpirate
raginpirate requested a review from jamesandersen July 6, 2026 05:43
@raginpirate
raginpirate marked this pull request as ready for review July 6, 2026 05:43
@raginpirate
raginpirate requested review from a team as code owners July 6, 2026 05:43
@raginpirate raginpirate self-assigned this Jul 6, 2026
@raginpirate raginpirate added TC review Ready for TC review and removed WIP labels Jul 6, 2026
@raginpirate
raginpirate force-pushed the proto/funding-source-and-credential-constraints branch from 2371672 to 446e45f Compare July 6, 2026 05:47
@igrigorik igrigorik added this to the Working Draft milestone Jul 6, 2026

@igrigorik igrigorik left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catching up, great discussion. The direction makes sense, I agree that modelling this as booleans doesn't scale and pollutes the schema. Reading the current shape, though, I hit a number of gotchas and I'm wondering if we're over-promising here.

Meta issue: constraints isn't actually generic

The pitch is a universal primitive ("every constraint object in UCP follows this shape"), but it isn't one — it's a shape you have to graft onto every object that negotiates input. That's a mixin re-attached per-object, not a generic contract, and it doesn't scale as the surface grows; we'd have to include this on every object at the limit.

#564 is the early evidence: it reuses the primitive as fulfillment_available_method.constraints.destination, but destination isn't a field of the fulfillment method, so it already breaks the base contract ("required_fields names properties of the constrained object").

If it is meant to be generic, it has to be extensibility-friendly

This rules out enums / closed dispatch. allOf can only intersect, never widen — so any closed construct in the base permanently excludes extensions:

  • Enum: address_constraint.required_fields is a closed enum of the 9 canonical fields. postal_address.json is open, so a handler can add tax_id — but can never require it ("required_fields": ["tax_id"] is rejected). A "declare what you need" primitive that can't name an extended field isn't extensibility-friendly.
  • Closed dispatch: validating a typed family by enumerating known types in the base rejects extension types. The credentials oneOf here avoids that only by adding an open catch-all branch + type_constraint to stay open.

And the validation this complexity buys isn't actually enforced. The brands and credentials rules live on the card schema, but a handler declaration validates against the base available_payment_instrument.json — which never dispatches on type == "card" to pull those rules in. So they go unchecked: the documented business_schema example still passes the repo's own validator even with brands broken (change ["visa","mastercard"] to a bare "visa" and it's still valid). Same gap @TateLyman/@jamesandersen flagged for credentials, one level up.


Stepping back: are we over-generalizing this problem?

Reactive feedback is the established mechanism, and it already handles dynamic requirements. Submit what you have → checkout.status: "incomplete" + messages[] with message_error.path (RFC 9535 JSONPath) and severity: "recoverable" ("fix inputs and retry via API"). Zero new schema, works with extensions and responds to dynamic requirements of the current negotiation. This does put an obligation on handlers to validate and return messages before tokenizing (rather than failing at the PSP) — but that's a smaller, more local ask than a new schema primitive. The main gap is that it costs a roundtrip.

I'm not against modeling some pre-submit requirements. An upfront signal is genuinely useful, so a platform can collect complete input (and render the right fields per option) before the first submit. Also agree that bools were the wrong mechanism.

Alt, what if: model it as requires array of JSONPaths...

{
  "type": "card",
  "accepts": {
    "brands": ["visa", "mastercard"],
    "credentials": [
      { "type": "card",          
         "requires": ["$.payment.instrument.credential.cvc"] },
      { "type": "network_token", 
         "requires": ["$.payment.instrument.credential.cryptogram"] }
    ]
  },
  "requires": ["$.payment.instrument.billing_address.postal_code"]
}
  • One vocabulary across requires and message_error.path
  • requires is a minimum, not a completeness guarantee
  • Models arbitrary requirements without introducing N booleans
  • Applied to payments, can be selectively adopted in other places if/as necessary

Net: no constraint.json / type_constraint.json / address_constraint.json, no enum, no per-object algebra, requirements separated from capabilities, and nothing to misapply (so #564 doesn't arise). This pattern can be applied selectively to other places too, but we would use it surgically in select places, instead of promising a generic contract that we can't deliver.

@jamesandersen

jamesandersen commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Appreciate this @igrigorik — good callout that even the landed brands constraints aren't really being enforced today (a handler config validates against the base available_payment_instrument.json, which never dispatches on type == "card", so the card-specific brands rule is never applied — a malformed brands passes validation). That's a fair reason to rethink the shape rather than build more on top of it.

On the JSONPath direction: worth naming that its main weakness — unvalidated paths (a cvv/cvc typo passes silently) — is the same one @alexpark20 raised on #288 that originally pushed us toward a schema-based approach. That said, you've pointed out that the schema/$defs route can get cumbersome quickly (constraints applied at the base getting out of sync with future object extensions) and still not be fully functional, I'm supportive of consciously going with requires: [JSONPath] — the consistency with existing UCP mechanisms (shared vocabulary with message_error.path) is worth more here than validation we're not actually delivering.

The one thing I'd hold firm on is the need for an upfront signal. The anchoring use case from #288 is a platform knowing before it submits complete checkout whether a business' payment handler requires CVV (a per-merchant setting on handlers like dev.shopify.card) — otherwise the platform guesses or eats a failed tokenization. Your requires proposal would solve that.

@raginpirate looks like this would still build on the distinct card / network_token credential types introduced here which I'm a fan of. WDYT?

@igrigorik

Copy link
Copy Markdown
Contributor

@jamesandersen — agreed on both, and I think we can close the typo concern rather than eat it...

Upfront signal: yes, requires[] is exactly that — declared at discovery so the platform knows before it submits.

Credential types: requires is type-agnostic — it co-locates on each credential entry, so it rides on card/network_token (or whatever we land).

On the typo gap: rather than an enum on the wire (which can't name extension fields), we can teach ucp-schema to resolve each requires path against the composed instrument/credential schema — a path to a non-existent property fails. That's build-time/CI validation (whoever authors the path runs it, same as any UCP payload), not a forced per-handshake operation, which I think is a nice balance.

@raginpirate
raginpirate force-pushed the proto/funding-source-and-credential-constraints branch from 446e45f to 9495857 Compare July 10, 2026 06:08
@gsmith85
gsmith85 self-requested a review August 20, 2026 16:58
Base automatically changed from feat/request-constraints to main August 20, 2026 20:29
Comment thread source/schemas/shopping/types/pan_credential.json Outdated
Comment thread docs/specification/overview.md
Comment thread docs/specification/tokenization-guide.md
Comment thread docs/specification/tokenization-guide.md Outdated
@raginpirate
raginpirate force-pushed the proto/funding-source-and-credential-constraints branch from acf608b to bbd2a24 Compare August 21, 2026 05:26
@raginpirate
raginpirate force-pushed the proto/funding-source-and-credential-constraints branch from bbd2a24 to 26a9300 Compare August 21, 2026 12:08
@raginpirate
raginpirate changed the base branch from main to raginpirate/anyof-constraint August 21, 2026 12:08
@raginpirate

Copy link
Copy Markdown
Member Author

Good doc suggestion @jamesandersen, thanks and adopted!

This is now based on top of the anyOf PR which simplifies the shape in our examples 😄

@igrigorik

Copy link
Copy Markdown
Contributor

Still working through the details, but quick sanity check: constraints isn't executable grammar.

From the guide example — the two constraint slots sit side by side (trimmed):

{
  "type": "card",
  "constraints": { "brand": { "enum": ["visa", "mastercard"] } },   // ← bare map
  "ucp": {
    "request_constraints": {                                        // ← Constraint Expression
      "path": "$['payment']['instruments'][?@['type'] == 'card']",
      "required": ["billing_address", "credential"],
      "properties": {
        "billing_address": { "required": ["postal_code", "address_country"] }
      }
    }
  }
}

request_constraints is a schema — a validator resolves path to an instance and evaluates it. constraints is {"type":"object","additionalProperties":true,"minProperties":1}, a map of domain keys onto value_constraint objects. It borrows the grammar's value slot without its object slot, so nothing can execute it.

If we're reusing the grammar, constraints ought to be one too:

"constraints": {
  "properties": { "brand": { "enum": ["visa", "mastercard"] } }
}

I believe the intent was to reuse the grammar?

@raginpirate
raginpirate force-pushed the proto/funding-source-and-credential-constraints branch from 26a9300 to 885fc27 Compare August 21, 2026 22:12
@raginpirate raginpirate changed the title feat: Express instrument requirements with request constraints, split PAN and Network Token credential types feat!: Express instrument requirements with request constraints, split PAN and Network Token credential types Aug 21, 2026

@igrigorik igrigorik left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work! 👍

@raginpirate
raginpirate force-pushed the raginpirate/anyof-constraint branch from 899935e to e8da3e8 Compare August 21, 2026 23:34
Base automatically changed from raginpirate/anyof-constraint to main August 22, 2026 02:22
Comment thread docs/specification/overview/index.md
@amithanda

amithanda commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Added a minor non-blocking comment, also seems like there is a merge conflict with /overview/index.md that needs to be take care of before we can merge. PTAL.

@raginpirate
raginpirate force-pushed the proto/funding-source-and-credential-constraints branch from 885fc27 to 6a668e1 Compare August 22, 2026 12:58
@raginpirate

Copy link
Copy Markdown
Member Author

Ah, documentation whiplash! Good catch @amithanda, ready for a final review!

@amithanda
amithanda merged commit d25cce3 into main Aug 22, 2026
19 checks passed
@amithanda
amithanda deleted the proto/funding-source-and-credential-constraints branch August 22, 2026 15:28
@github-actions github-actions Bot added the enhancement New feature or request label Aug 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:payments enhancement New feature or request gov:needs-tc-review status:stale-review Applied if a PR is waiting on a reviewer for too long TC review Ready for TC review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants