Skip to content

feat: structured constraints for instruments and beyond#424

Open
raginpirate wants to merge 3 commits into
mainfrom
proto/funding-source-and-credential-constraints
Open

feat: structured constraints for instruments and beyond#424
raginpirate wants to merge 3 commits into
mainfrom
proto/funding-source-and-credential-constraints

Conversation

@raginpirate

@raginpirate raginpirate commented May 8, 2026

Copy link
Copy Markdown
Contributor

Description

Larger-scope continuation of #288
Built ontop of by #564 and #565 (WIP)

Introduces a single constraint primitive (constraint.json) and applies it consistently across instrument and credential availability. Replaces ad-hoc booleans that implementers might have used (requires_card_verification,
billing_address_granularity) with a uniform shape: required_fields[] plus domain-specific custom keys.

New primitives

  • constraint.json — universal base: required_fields[] of property names + open additionalProperties for domain keys. Every constraint object in UCP follows this shape.
  • type_constraint.json — structural piece: an array of types by their constraints; makes it easy to constrain an anyOf.

Primitive applications

  • address_constraint.json — constraint-shaped, required_fields scoped to postal_address.json properties. Reusable across payments and potentially other domains (in follow up PRs).
  • credential_constraint.json — { type, constraints } entry. Single primitive used on both axes below.

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
{
"type": "object",
"required": ["type", "number", "cryptogram", "eci_value"],
"properties": {

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.

Thoughts about adding token_requestor_id (#296) onto network_token_credential while we're at it? not widely used as discussed on that PR but we do have the Braintree BYOT example

Comment thread source/schemas/shopping/types/network_token_credential.json Outdated
@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
Contributor 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
@raginpirate

raginpirate commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback @igrigorik and @jamesandersen! ❤️

  • Totally right call to avoid enums, that was an overstep on my part where I looked at it from an "unlikely to change" perspective rather than an immutability perspective. Made the change on this to kill the address constraint and simplify the base constraint language. With this, I don't see any other extensibility concerns in the PR, but please flag if there still is.
  • Also aligned on the typing concern: I recognize that no matter what, once we shift away from being able to use an enum on these branches, the type enforcement level I tried pursuing was off-base. Also made the changes on this front to just keep the typing simple across the board.

Now... hear me out, I am not sold on the shift from constraints as a mixin to this requires proposal. When I look at anywhere I'd want to place this requires field, I notice that I'm actually colocating it within an object that uses convention to accomplish the path your encoding there... examples being:

  • available_payment_instrument already implicitly targets the payment instrument
  • fulfillment_available_method already implicitly targets the fulfillment method
  • the config of an extension
    I think that having the fullpath on messages is important because the entirety of the message is bound to a single target point: but these objects are constraining a wide number of fields and subfields.

Maybe to help drive it home, the $path based proposal:

  {                                                                                                                                                       
    "available_instruments": [                                                                                                                            
      {                                                                                                                                                   
        "type": "card",                                                                                                                                   
        "constraints": {                                                                                                                                      
          "brands": ["visa", "mastercard"],                                                                                                               
          "credentials": [                                                                                                                                
            {                                                                                                                                             
              "type": "pan",                                                                                                                              
              "requires": [                                                                                                                               
                "$.payment.instruments[].credential.cvc"                                                                             
              ]                                                                                                                                           
            },                                                                                                                                            
            {                                                                                                                                             
              "type": "network_token",                                                                                                                    
              "requires": [                                                                                                                               
                "$.payment.instruments[].credential.cryptogram"                                                                      
              ]                                                                                                                                           
            }                                                                                                                                             
          ],                                                                                                                                                
        "requires": [                                                                                                                                     
          "$.payment.instruments[].billing_address.postal_code",                                                                     
          "$.payment.instruments[].billing_address.address_country"                                                                  
        ]                                                                                                                                                
        }                                                                                                                                                
      }                                                                                                                                                   
    ]                                                                                                                                                     
  }                                                                                                                                                       

The localized required_fields proposal:

{                                                                                                                                                       
  "available_instruments": [                                                                                                                            
    {                                                                                                                                                   
      "type": "card",                                                                                                                                   
      "constraints": {                                                                                                                                  
        "brands": ["visa", "mastercard"],                                                                                                               
        "required_fields": ["billing_address"],                                                                                                         
        "billing_address": {                                                                                                                            
          "required_fields": ["postal_code", "address_country"]                                                                                         
        },                                                                                                                                              
        "credentials": [                                                                                                                                
          {                                                                                                                                             
            "type": "pan",                                                                                                                              
            "constraints": {                                                                                                                            
              "required_fields": ["cvc"]                                                                                                                
            }                                                                                                                                           
          },                                                                                                                                            
          {                                                                                                                                             
            "type": "network_token",                                                                                                                    
            "constraints": {                                                                                                                            
              "required_fields": ["cryptogram"]                                                                                                         
            }                                                                                                                                           
          }                                                                                                                                             
        ]                                                                                                                                               
      }                                                                                                                                                 
    }                                                                                                                                                   
  ]                                                                                                                                                     
}                                                                                                                                                       

Very similar but I think the later is significantly less noisy, and your earlier feedback greatly improved the complexity and correctness of the final solution.

WDYT? I'm happy to be pushed back on if theres a benefit I've missed on the fullpath here. Also happy to rename required_fields to requires if you think thats a better name!

@igrigorik

Copy link
Copy Markdown
Contributor

@raginpirate good turn, I think we're converging on right shape. As a wildcard thought experiment, what if we push this one step further: #580

@jamesandersen

Copy link
Copy Markdown
Contributor

Really like where this landed, @raginpirate — dropping address_constraint.json and the closed enum in favor of the open required_fields: string[] addresses the extensibility concern cleanly, and the if type == "token" then … dispatch on credentials is exactly the right shape: it validates known credential types while letting handler-extended types fall through to the open base, no rejection. That's additive and extension-safe.

Good call on keeping local required_fields over the requires: [JSONPath] direction — a constraint object already implicitly targets its host, so full paths would be redundant here. Agreed.

The one item I don't think is closed yet is the enforcement gap:

  • payment_handler.json → available_instruments[].items refs the base available_payment_instrument.json.
  • The base doesn't dispatch on type, so the card-specific rules (brands, the if type==token credential dispatch) — which live in card_payment_instrument.json#/$defs/available_card_payment_instrument — are never pulled into the validated path.
  • Concretely: a handler declaring { "type": "card", "constraints": { "brands": "visa" } } (bare string, malformed) still validates clean, because it only ever hits the base's open additionalProperties.

That's the same "validation isn't actually enforced" point from earlier, still true on HEAD. The fix is the same if/then pattern you already used for token credentials, just applied one level up at the instrument type — but it needs to sit at the point-of-use (or a small dispatcher schema), not in the base itself, since available_card_payment_instrument already allOfs the base and a base→card ref would recurse. Roughly:

// what available_instruments[].items validates against — a dispatcher, not the bare base
{
  "if":   { "properties": { "type": { "const": "card" } }, "required": ["type"] },
  "then": { "$ref": "card_payment_instrument.json#/$defs/available_card_payment_instrument" }
  // unknown instrument types fall through to the open base — extension-safe, same as your credential dispatch
}

That turns the brands/credential rules from documented-but-dormant into actually-enforced, and closes the gap without reintroducing enums or a base-level mixin.

@damaz91 damaz91 added gov:needs-gc-review status:needs-triage Signal that the PR is ready for human triage area:payments and removed status:needs-triage Signal that the PR is ready for human triage payments labels Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants