Skip to content

feat: factor available-instrument constraints into distinct axes#580

Closed
igrigorik wants to merge 2 commits into
proto/funding-source-and-credential-constraintsfrom
feat/constraints-jsonschema-light
Closed

feat: factor available-instrument constraints into distinct axes#580
igrigorik wants to merge 2 commits into
proto/funding-source-and-credential-constraintsfrom
feat/constraints-jsonschema-light

Conversation

@igrigorik

@igrigorik igrigorik commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Alternative shape for the constraints work in #424 — stacked on this branch as a concrete variant, not a merge-over. ucp-schema lint + doc-example validation pass; net smaller than the current shape.

The core proposal isn't a vocabulary change — it's factoring constraints by axis. The current single constraints bag mixes several genuinely different things, which is what makes it hard to reason about. Pulling them apart resolves that and, as a bonus, makes each part a clean, honest mechanism.

The insight: "constraints" is four different axes

Axis What it does Right mechanism Key
Presence "this field must be present" required (+ properties to nest) constraints
Allowed values (submitted field) "this field's value ∈ set" enum under properties.<field> constraints
Derived menus "I support these options" (attribute is derived, not submitted — e.g. card network) a uniform menu map accepts
Discriminated "if subtype T, then rules-for-T" typed list, applied by data lookup (never oneOf) credentials

Mixing all four under one constraints key is the confusion. Factoring them by key makes each honest.

Shape

{
  "type": "card",

  "constraints": {
    "required": ["billing_address"],
    "properties": {
      "billing_address": {
        "required": ["postal_code", "address_country"],
        "properties": { "address_country": { "enum": ["US", "CA"] } }
      }
    }
  },

  "accepts": { "brand": ["visa", "mastercard"] },

  "credentials": [
    { "type": "pan",           "constraints": { "required": ["cvc"] } },
    { "type": "network_token", "constraints": { "required": ["cryptogram"] } }
  ]
}

Why factoring helps (not just tidier)

  • constraints is now a genuine bounded JSON Schema over the instrument's own fields (required/properties/enum). In the mixed bag it wasn't — a validator run over it silently ignores credentials (an unknown keyword), giving false confidence. Moving the non-schema axes out fixes that.
  • brands becomes honest. A card's network is derived from the credential, not a submitted field, so "accepted brands" is a menu you read to offer options — not a field-value constraint. accepts says that plainly; a uniform map ({ attr: [values] }) also stops accepted-value menus from proliferating as bespoke keys.
  • Discrimination stays a data list, never oneOf. A base oneOf can't be widened by an extension allOf — it re-closes the type vocabulary exactly like an enum (verified). A typed list resolved by lookup keeps extension credential types open with no dangerous keywords.

   Constraints let a business declare, per checkout, which otherwise-optional
   fields it requires (e.g. CVC, billing postal code) and which values it
   accepts. This cannot live in the base schema: UCP schemas are deliberately
   open and multi-tenant -- one shared schema (e.g. dev.shopify.card) serves
   thousands of merchants, each with different, per-context requirements. A
   design-time schema fixes one contract; the requirement here is a per-party,
   per-interaction *narrowing* of that contract, so it must travel as data.

   And a narrowing is, conceptually, a schema -- "these fields required, these
   values allowed." Rather than invent a parallel vocabulary (required_fields,
   allowed_values, ...) that reimplements JSON Schema under worse names, a
   constraint is modeled as a *restricted, open* subset of JSON Schema itself,
   carried as data and consumed by the platform to learn requirements.

   The contract (constraint.json):

     - A constraint is a sparse OVERLAY on an already-typed base schema. It
       carries no `type` -- the base defines shape; the overlay only narrows.
     - The vocabulary is bounded to `required` (which properties must be
       present), `properties` (per-property nested constraints, recursive),
       and `enum` (allowed values for a property).
     - It is OPEN: it narrows named properties and never forbids unknown ones.
       `additionalProperties:false` / `unevaluatedProperties` are disallowed so
       extensions can still add fields, and `enum` here is per-party runtime
       data -- not a base-schema enum -- so it never closes the protocol
       vocabulary.
     - Excluded on purpose: `oneOf`, `if`/`then`, `not`, `pattern`, and
       cross-file `$ref`. These are the interop-tax / DoS / extensibility-
       closing surface that every open multi-party format bounds or
       externalizes (DIF Presentation Exchange, FHIR profiles, HAL-FORMS). The
       restriction IS the contract: a consumer only ever has to understand
       presence + nested presence + allowed values.

   Discriminated families (accepted credential types) are a typed list --
   type_constraint.json is a constraint plus a `type` discriminator -- resolved
   by data lookup: the consumer applies the entry matching the submitted
   credential's type, and unknown types are extension branches that pass
   through. Keeping per-type requirements in a list (not a schema `oneOf`) is
   what lets the vocabulary above stay free of the excluded keywords.

   Changes:
     - constraint.json: required_fields -> required/properties/enum; recursion
       via `$ref:"#"` (cross-file self-ref breaks the bundler; same-document
       `#` does not).
     - type_constraint.json: inline {type + constraint}, dropping the nested
       `constraints` wrapper so credential entries are flat {type, required}.
     - available_payment_instrument.json: {type, constraints:{constraint +
       credentials list}}.
     - card_payment_instrument.json: the card branch only pins type:"card";
       brands and credential entries ride on the open base.
     - token_credential.json: drop the now-unused $defs/constraint, whose
       if/then dispatch is replaced by the credentials data list.

   Notes:
     - `brands` stays a capability key (accepted networks), not a
       `properties.brand.enum` value constraint: a card's network is derived
       from the credential rather than a settable field, so it is capability
       advertisement, not field-value validation.
     - Requirements a constraint cannot pre-state (dynamic/conditional, e.g.
       risk-based step-up) remain discoverable via the reactive message loop
       (checkout.status + message_error.path). The declared constraint is an
       authoritative floor, not an exhaustive spec.
@igrigorik
igrigorik requested review from a team as code owners July 10, 2026 22:49
@igrigorik
igrigorik requested review from carolinerg1, knightlin-shopify, mmohades and tormin-google and removed request for a team July 10, 2026 22:49
   An `available_instruments[]` entry declares what an acceptable instrument must
   satisfy. Folding every kind of rule into one `constraints` bag is hard to
   reason about and — because some of those keys are not JSON Schema keywords —
   quietly wrong when the object is run through a validator (a `credentials` rule,
   for instance, is silently ignored). Separate the rules into three sibling
   axes, each with the mechanism it actually wants:

       {
         "type": "card",

         "constraints": {                       // fields of the instrument ITSELF
           "required": ["billing_address"],
           "properties": {
             "billing_address": {
               "required": ["postal_code", "address_country"],
               "properties": { "address_country": { "enum": ["US", "CA"] } }
             }
           }
         },

         "accepts": {                           // menus of DERIVED/selected options
           "brand": ["visa", "mastercard"]
         },

         "credentials": [                       // per-credential-type requirements
           { "type": "pan",           "constraints": { "required": ["cvc"] } },
           { "type": "network_token", "constraints": { "required": ["cryptogram"] } }
         ]
       }

   - constraints — a bounded JSON Schema over the instrument's OWN submitted
     fields: `required` (presence) and `properties.<field>.enum` (allowed values,
     a per-party runtime set, not a base-schema enum). Now that the non-schema
     axes are siblings, `constraints` is finally a real, runnable schema over the
     instrument; the mixed bag was not (a validator ignores unknown keys, so
     `credentials` rules under it were inert).

   - accepts — a uniform { attribute: [values] } menu of DERIVED or selected
     options. A card's network is derived from the credential, not a submitted
     field, so "accepted brands" is a menu the platform reads to OFFER options,
     not a value constraint on a field. One map also keeps accepted-value menus
     from proliferating as bespoke top-level keys.

   - credentials — per-credential-type requirements as a typed list, applied by a
     data lookup on the submitted credential's `type`. Never a schema `oneOf`: a
     base `oneOf` cannot be widened by an extension `allOf`, so it would re-close
     the credential-type vocabulary exactly like an enum. Unknown types are
     handler/extension branches and pass through.

   Schema: constraint.json = { required, properties (recursive via `$ref:"#"`),
   enum }; type_constraint.json = { type, constraints }; the card branch pins
   `type:"card"` (brands/credentials ride the open base); token_credential drops
   its now-unused `$defs/constraint`.
@igrigorik igrigorik changed the title feat: model constraints as a restricted JSON Schema overlay feat: factor available-instrument constraints into distinct axes Jul 11, 2026
@raginpirate

Copy link
Copy Markdown
Contributor

Thanks for the deep thinking here Ilya. I think defaulting to using JSON schema fragments is a great observation, and it sent me down a rabbit hole of experimenting!

While trying out these shapes, I ran into two rough edges I wanted to highlight and see if you have opinions on what "good" looks like, especially compared to how #424 solved for it.

First, let’s look at billing address.

For pure field requirements, the JSON Schema-shaped version works well:

  {                                                                                                                      
    "type": "card",                                                                                                      
    "constraints": {                                                                                                     
      "required": ["billing_address"],                                                                                
      "properties": {                                                                                                    
        "billing_address": {                                                                                             
          "required": ["postal_code", "address_country"]                                                                 
        }                                                                                                                
      }                                                                                                                  
    }                                                                                                                    
  }                                                                                                                      

Where I get stuck is when the billing-address concern is still local to billing address, but is not itself a JSON Schema
assertion. For example, suppose we want to advertise that a provider-supplied address is acceptable (just an imaginary example of field toggling optionality rather than constraining fields):

  {                                                                                                                      
    "type": "card",                                                                                                      
    "constraints": {                                                                                                     
      "required": ["billing_address"],                                                                                   
      "properties": {                                                                                                    
        "billing_address": {                                                                                             
          "required": ["postal_code"],                                                                                   
          "allows_provider_address": true                                                                                
        }                                                                                                                
      }                                                                                                                  
    }                                                                                                                    
  }                                                                                                                      

That keeps locality, but now constraints is no longer an honest JSON Schema fragment. The alternative seems to be
pulling that out into another axis:

  {                                                                                                                      
    "type": "card",                                                                                                      
    "constraints": {                                                                                                     
      "required": ["billing_address"],                                                                                   
      "properties": {                                                                                                    
        "billing_address": {                                                                                             
          "required": ["postal_code"]                                                                                    
        }                                                                                                                
      }                                                                                                                  
    },                                                                                                                   
    "accepts": {                                                                                                         
      "billing_address_source": ["buyer", "provider"]                                                                 
    }                                                                                                                    
  }                                                                                                                      

That keeps constraints clean, but loses the locality that #424 had by letting billing-address-related constraints live
under the billing-address constraint object.

Next, accepts vs credentials.

I understand why brand moved out of constraints: it is an accepted/derived option, not a submitted field constraint. But
then I’m not sure why credentials is a separate sibling rather than an accepts family.

This shape splits two “accepted option” concepts:

  {                                                                                                                      
    "type": "card",                                                                                                      
    "accepts": {                                                                                                         
      "brand": ["visa", "mastercard"]                                                                                 
    },                                                                                                                   
    "credentials": [                                                                                                     
      {                                                                                                                  
        "type": "pan",                                                                                                   
        "constraints": { "required": ["cvc"] }                                                                            
      },                                                                                                                 
      {                                                                                                                  
        "type": "network_token",                                                                                         
        "constraints": { "required": ["cryptogram"] }                                                                    
      }                                                                                                                  
    ]                                                                                                                    
  }                                                                                                                      

Conceptually, I’m tempted by:

  {                                                                                                                      
    "type": "card",                                                                                                      
    "accepts": {                                                                                                         
      "brand": ["visa", "mastercard"],                                                                                
      "credentials": [                                                                                                   
        {                                                                                                                
          "type": "pan",                                                                                                 
          "constraints": { "required": ["cvc"] }                                                                         
        },                                                                                                               
        {                                                                                                                
          "type": "network_token",                                                                                       
          "constraints": { "required": ["cryptogram"] }                                                                  
        }                                                                                                                
      ]                                                                                                                  
    }                                                                                                                    
  }                                                                                                                      

I realize credentials are richer than brands: brand is a scalar menu, while credentials are typed branches with
branch-local requirements. But they still feel like the same negotiation class: “these are the options this handler
accepts.” I'm not sure why we conceptually need to separate these buckets, but I might be missing something; an
example could help!

Overall, I have an opinion that because this approach still requires implementers to apply UCP-specific interpretation
anyway — applying overlays, resolving credential entries by type, merging platform/business constraints, etc. — I’m not
yet convinced we should optimize primarily for JSON Schema fragments if doing so makes larger/nested constraints harder to express locally. But I'd love to hear your thoughts on these examples, and see if the shape in #424 may continue to have legs to stand on.

@igrigorik

Copy link
Copy Markdown
Contributor Author

Agree on the conclusion and landed in similar spot after iterating through this. I'll close this, but I think there are some takeaways and patterns here that we should consider for #424 -- I'll leave it to your discretion though on if and which are worth bringing over. Tag me for final review once you feel #424 is ready.

@igrigorik igrigorik closed this Jul 14, 2026
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