From 3edac5f431bb5a2b7fe603fed418f7ece6f9f925 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Fri, 10 Jul 2026 15:43:33 -0700 Subject: [PATCH 1/2] feat: model constraints as a restricted JSON Schema overlay 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. --- docs/documentation/schema-authoring.md | 56 +++++++++--------- docs/specification/payment-handler-guide.md | 57 ++++++++++--------- .../types/available_payment_instrument.json | 53 ++++++++--------- .../types/card_payment_instrument.json | 28 +-------- source/schemas/shopping/types/constraint.json | 16 +++++- .../shopping/types/token_credential.json | 17 +----- .../shopping/types/type_constraint.json | 27 ++++----- 7 files changed, 113 insertions(+), 141 deletions(-) diff --git a/docs/documentation/schema-authoring.md b/docs/documentation/schema-authoring.md index d4e337f11..ca4e67af6 100644 --- a/docs/documentation/schema-authoring.md +++ b/docs/documentation/schema-authoring.md @@ -429,22 +429,23 @@ runtime requirement attached to a concrete context, not a global schema change. All new constraint objects **SHOULD** extend [`constraint.json`](site:{{ ucp_version }}/schemas/shopping/types/constraint.json): -- `required_fields` names fields on the local constrained object that must be - present in an acceptable instance for this context. -- The schema property that embeds the constraint defines the target object. For - example, `available_instruments[].constraints` targets the payment instrument, - while `constraints.billing_address` targets that instrument's billing address. -- Domain-specific constraint keys remain sibling properties. For example, the - base payment instrument can require `billing_address` and add a nested - `billing_address` constraint describing which address fields are needed. -- Reusable base schemas **SHOULD NOT** close `required_fields` with enums when - the target object can be extended. Concrete resolved schemas MAY narrow - `required_fields` when they own the complete target vocabulary, while leaving - the object open to future domain-specific keys. +- `required` names properties of the constrained object that must be present in + an acceptable instance for this context. +- `properties` maps a property name to a nested `Constraint` applied to that + property (recursive), e.g. `billing_address` narrowing which address fields are + required. The schema property that embeds the constraint defines the target + object: `available_instruments[].constraints` targets the payment instrument, + and `properties.billing_address` targets that instrument's billing address. +- `enum` restricts a property's value to a per-merchant allowed set. This is a + runtime, per-party value restriction, not a base-schema enum — it does not + close the protocol vocabulary. +- A constraint is a sparse overlay on an already-typed base schema, so it carries + no `type` and stays open: it narrows named properties and never forbids unknown + ones (no `additionalProperties:false`, `oneOf`, or `if`/`then`). Prefer field-level constraints over ad-hoc booleans. For example, prefer -`required_fields: ["billing_address"]` plus a nested address constraint over -new booleans such as `requires_billing_address` or +`required: ["billing_address"]` plus `properties.billing_address.required` +over new booleans such as `requires_billing_address` or `requires_billing_postal_code`. @@ -453,31 +454,32 @@ new booleans such as `requires_billing_address` or "type": "card", "constraints": { "brands": ["visa", "mastercard"], - "required_fields": ["billing_address"], - "billing_address": { - "required_fields": ["postal_code", "address_country"] + "required": ["billing_address"], + "properties": { + "billing_address": { + "required": ["postal_code", "address_country"] + } } } } ``` -For arrays of constraints over a typed family, extend -[`type_constraint.json`](site:{{ ucp_version }}/schemas/shopping/types/type_constraint.json). Each -known branch should publish a `$defs/constraint` entry with a `type` discriminator -and, when applicable, a narrowed `constraints` body. The parent schema can then -validate UCP-defined branches while still leaving an extension point for -handler-specific branches. +For arrays of constraints over a typed family, use +[`type_constraint.json`](site:{{ ucp_version }}/schemas/shopping/types/type_constraint.json): +a list where each entry is a `Constraint` carrying a `type` discriminator plus +that branch's `required` fields. Discrimination is a data lookup over the list — +the consumer applies the entry matching the submitted instance's `type` — so +UCP-defined branches and handler/extension branches coexist without a schema +`oneOf` or `if`/`then`. ```json { "credentials": [ - { "type": "token" }, + { "type": "pan", "required": ["cvc"] }, { "type": "com.example.wallet_token", - "constraints": { - "required_fields": ["assurance_level"] - } + "required": ["assurance_level"] } ] } diff --git a/docs/specification/payment-handler-guide.md b/docs/specification/payment-handler-guide.md index a720b026f..ffb433f36 100644 --- a/docs/specification/payment-handler-guide.md +++ b/docs/specification/payment-handler-guide.md @@ -315,33 +315,34 @@ is excluded from the response even though the platform supports it. #### Constraint Semantics `available_instruments[].constraints` describes what an acceptable instrument -must satisfy for a handler declaration or resolved checkout response. Constraint -objects extend [`Constraint`](site:schemas/shopping/types/constraint.json): +must satisfy for a handler declaration or resolved checkout response. A +[`Constraint`](site:schemas/shopping/types/constraint.json) is a sparse overlay +on the instrument's base schema, using a bounded, JSON-Schema-aligned vocabulary: | Constraint key | Meaning | | :------------- | :------ | -| `required_fields` | Field names from the constrained object that must be present in this context. | -| Domain-specific keys | Additional constraints defined by the concrete instrument or handler schema. | +| `required` | Names of the instrument's properties that MUST be present in this context (e.g. `billing_address`). | +| `properties` | Per-property nested constraints — each value is itself a `Constraint` applied to that property (e.g. `billing_address` narrowing which address fields are required). | +| `enum` | Allowed values for a constrained property (a per-merchant closed value set). | +| Domain-specific keys | Additional keys defined by the concrete instrument or handler schema, e.g. `credentials`, `brands`. | Base payment instruments define these common constraints: | Key | Description | | :-- | :---------- | -| `required_fields` | Payment instrument fields required by this handler. The base schema intentionally keeps this list open for handler-specific instrument extensions; `billing_address` is the standard base field constrained here. | -| `billing_address` | Nested local [`Constraint`](site:schemas/shopping/types/constraint.json) whose `required_fields` values name billing-address fields. | -| `credentials` | Accepted credential families and credential-specific constraints. Entries are typed constraints; concrete instrument schemas can narrow known entries while still allowing handler-specific entries. | +| `required` / `properties` | Which instrument fields are required and their nested requirements. `billing_address` is the standard base field; `properties.billing_address.required` names the address fields needed (e.g. AVS postal code). | +| `credentials` | Accepted credential families as a typed list — each entry is a `Constraint` carrying a `type` discriminator plus that credential's `required` fields. Discrimination is a data lookup: the consumer applies the entry matching the submitted credential; unknown `type` values are handler/extension branches. | -Card instruments inherit those base constraints and add card-specific constraints: +Card instruments add: | Key | Description | | :-- | :---------- | -| `brands` | Accepted card network names, such as `visa`, `mastercard`, or `amex`. | -| `credentials` | Refines the base typed credential list with UCP-defined card credential entries while preserving extension credential entries. | +| `brands` | Accepted card network names (e.g. `visa`, `mastercard`) — a capability advertisement, open to any string. The accepted network is derived from the credential, so this is a capability rather than a field-value constraint. | -Use field-level constraints instead of handler-specific booleans when the -requirement is about data that is already modeled by a schema. For example, an -AVS postal-code requirement is expressed as a billing address constraint rather -than a new `requires_billing_postal_code` flag. +Express requirements as field-level constraints instead of handler-specific +booleans. For example, an AVS postal-code requirement is +`properties.billing_address.required: ["postal_code"]`, not a new +`requires_billing_postal_code` flag. ```json @@ -353,12 +354,15 @@ than a new `requires_billing_postal_code` flag. "type": "card", "constraints": { "brands": ["visa", "mastercard"], - "required_fields": ["billing_address"], - "billing_address": { - "required_fields": ["postal_code", "address_country"] + "required": ["billing_address"], + "properties": { + "billing_address": { + "required": ["postal_code", "address_country"] + } }, "credentials": [ - { "type": "token" } + { "type": "pan", "required": ["cvc"] }, + { "type": "network_token", "required": ["cryptogram"] } ] } } @@ -578,15 +582,14 @@ is the payment-instrument application of the generic that selected branch. Each instrument schema defines its own `available_*` variant in `$defs` that specializes this typed entry. For example, [`card_payment_instrument.json`](site:schemas/shopping/types/card_payment_instrument.json) -defines `available_card_payment_instrument` as the `card` branch with -card-specific constraints such as `brands` and card credential refinements. Base -payment-instrument constraints such as `billing_address` and `credentials` also -apply. - -| Schema | Constraints | -| :--------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------- | -| [`available_payment_instrument.json`](site:schemas/shopping/types/available_payment_instrument.json) | Base typed entry: type, open `required_fields`, `billing_address`, and `credentials` constraints | -| `card_payment_instrument.json#/$defs/available_card_payment_instrument` | Card branch: `type: "card"`, `brands`, and card credential refinements | +defines `available_card_payment_instrument` as the `card` branch. Card-specific +constraints (`brands`, accepted `credentials`) ride on the open base `Constraint`, +so the card branch only pins `type: "card"`. + +| Schema | Constraints | +| :--------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------ | +| [`available_payment_instrument.json`](site:schemas/shopping/types/available_payment_instrument.json) | Base: `type` + a `Constraint` (`required`/`properties`/`enum`) plus a `credentials` typed list | +| `card_payment_instrument.json#/$defs/available_card_payment_instrument` | Card branch: pins `type: "card"`; `brands` and credential entries ride on the open base | Handlers reference these instrument-defined schemas from `$defs.{handler_name}.available_payment_instrument` when they need machine diff --git a/source/schemas/shopping/types/available_payment_instrument.json b/source/schemas/shopping/types/available_payment_instrument.json index 63c50feb1..dde4fedca 100644 --- a/source/schemas/shopping/types/available_payment_instrument.json +++ b/source/schemas/shopping/types/available_payment_instrument.json @@ -2,36 +2,31 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://ucp.dev/schemas/shopping/types/available_payment_instrument.json", "title": "Available Payment Instrument", - "description": "An instrument type available from a payment handler with optional constraints.", - "allOf": [ - { "$ref": "type_constraint.json" }, - { - "type": "object", - "properties": { - "constraints": { - "allOf": [ - { "$ref": "constraint.json" }, - { - "type": "object", - "properties": { - "billing_address": { - "$ref": "constraint.json", - "description": "Local constraint on the instrument's `billing_address` field." - }, - "credentials": { - "type": "array", - "items": { "$ref": "type_constraint.json" }, - "uniqueItems": true, - "minItems": 1, - "description": "Credential specific constraints accepted for the payment instrument branch selected by `type`. Concrete instrument schemas can narrow known credential entries while preserving handler-extended credential entries." - } - } + "description": "An instrument type a handler accepts, with a Constraint describing what an acceptable instrument of this type must satisfy.", + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "The instrument type identifier (e.g., 'card'). References an instrument schema's type constant." + }, + "constraints": { + "allOf": [ + { "$ref": "constraint.json" }, + { + "type": "object", + "properties": { + "credentials": { + "type": "array", + "items": { "$ref": "type_constraint.json" }, + "uniqueItems": true, + "minItems": 1, + "description": "Accepted credential families and their per-family constraints, each a Typed Constraint keyed by `type`. The consumer applies the entry matching the submitted credential; unknown types are handler/extension branches. This is a data list, not a schema `oneOf`." } - ], - "description": "Constraints on this instrument type. Base payment instrument constraints can require and describe `billing_address` and `credentials`; concrete instrument schemas SHOULD add instrument-specific constraint keys while remaining open to extensions.", - "minProperties": 1 + } } - } + ], + "description": "Constraint on an acceptable instrument. `required`/`properties`/`enum` describe presence and allowed values of the instrument's own fields (e.g. `billing_address`, `brand`); `credentials` extends it with per-credential-type constraints. Open to instrument-specific keys." } - ] + } } diff --git a/source/schemas/shopping/types/card_payment_instrument.json b/source/schemas/shopping/types/card_payment_instrument.json index 1e40ae40b..febf58b68 100644 --- a/source/schemas/shopping/types/card_payment_instrument.json +++ b/source/schemas/shopping/types/card_payment_instrument.json @@ -6,37 +6,13 @@ "$defs": { "available_card_payment_instrument": { "title": "Available Card Payment Instrument", - "description": "Declares card instrument availability with card-specific constraints.", + "description": "Declares card instrument availability. Card-specific constraints ride on the open base Constraint: `constraints.properties.brand.enum` limits accepted card networks, and `constraints.credentials[]` lists accepted credential types (e.g. `pan`, `network_token`) with their required fields. No card-specific schema keys are needed.", "allOf": [ { "$ref": "available_payment_instrument.json" }, { "type": "object", "properties": { - "type": { "const": "card" }, - "constraints": { - "type": "object", - "properties": { - "brands": { - "type": "array", - "items": { "type": "string" }, - "minItems": 1, - "uniqueItems": true, - "description": "Limit to specific card brands (e.g., ['visa', 'mastercard', 'amex'])." - }, - "credentials": { - "items": { - "if": { - "type": "object", - "required": ["type"], - "properties": { - "type": { "const": "token" } - } - }, - "then": { "$ref": "token_credential.json#/$defs/constraint" } - } - } - } - } + "type": { "const": "card" } } } ] diff --git a/source/schemas/shopping/types/constraint.json b/source/schemas/shopping/types/constraint.json index 7727d2bc9..f7635ac7b 100644 --- a/source/schemas/shopping/types/constraint.json +++ b/source/schemas/shopping/types/constraint.json @@ -2,15 +2,25 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://ucp.dev/schemas/shopping/types/constraint.json", "title": "Constraint", - "description": "Reusable local mixin for a constraint object. The schema property that embeds this mixin defines the constrained target object. `required_fields` names properties of that local target MUST be present in an acceptable instance. Additional properties are domain-specific constraint keys defined by concrete constraint schemas.", + "description": "A sparse overlay describing what an acceptable instance of the constrained object must satisfy, consumed by the platform to learn per-merchant requirements. Uses a bounded, JSON-Schema-aligned vocabulary: `required` (which properties must be present), `properties` (per-property nested constraints, recursive), and `enum` (allowed values for a property). Deliberately open — it only narrows named properties and never forbids unknown ones (no `additionalProperties:false`, `oneOf`, `if`/`then`, `not`, or cross-file `$ref`). It is an overlay on the already-typed base schema, so it carries no `type`.", "type": "object", "properties": { - "required_fields": { + "required": { "type": "array", "items": { "type": "string" }, "uniqueItems": true, + "description": "Names of properties on the constrained object that MUST be present in an acceptable instance. Open string list so extension fields can be named without changing this schema." + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "description": "Per-property nested constraints. Each value is itself a Constraint applied to that property (recursive), e.g. `billing_address` narrows which address fields are required." + }, + "enum": { + "type": "array", "minItems": 1, - "description": "Names of properties on the local constrained object that MUST be present in an acceptable instance. The base mixin intentionally accepts any string so extension schemas can add fields without changing this schema; concrete resolved schemas may narrow this list when they own the complete target vocabulary." + "uniqueItems": true, + "description": "If present, the constrained property's value MUST equal one of these. A per-merchant allowed-value set (e.g. accepted card brands); it restricts values at runtime and does not close the protocol vocabulary." } } } diff --git a/source/schemas/shopping/types/token_credential.json b/source/schemas/shopping/types/token_credential.json index 2bdca570c..aa0cde381 100644 --- a/source/schemas/shopping/types/token_credential.json +++ b/source/schemas/shopping/types/token_credential.json @@ -22,20 +22,5 @@ } } } - ], - "$defs": { - "constraint": { - "title": "Token Credential Constraint", - "description": "Typed constraint entry indicating support for generic token credentials. Handler-specific token credential schemas can extend this entry and narrow `constraints` to their own fields.", - "allOf": [ - { "$ref": "type_constraint.json" }, - { - "type": "object", - "properties": { - "type": { "const": "token" } - } - } - ] - } - } + ] } diff --git a/source/schemas/shopping/types/type_constraint.json b/source/schemas/shopping/types/type_constraint.json index 05caa9fe8..6638a8094 100644 --- a/source/schemas/shopping/types/type_constraint.json +++ b/source/schemas/shopping/types/type_constraint.json @@ -1,18 +1,19 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://ucp.dev/schemas/shopping/types/type_constraint.json", - "title": "Type Constraint", - "description": "Base shape for a constraint entry that selects one branch of a typed or discriminated family. The `type` value identifies the selected branch, and `constraints` applies to that branch's schema. Domain-specific schemas extend this base to document what the `type` refers to and to narrow `constraints` to the selected branch's constraint schema.", - "type": "object", - "required": ["type"], - "properties": { - "type": { - "type": "string", - "description": "The discriminator value for the constrained branch. Concrete schemas typically narrow this with `const`, `enum`, or another type-family constraint. Unknown values are reserved for extension schemas that document their own constraints." - }, - "constraints": { - "$ref": "constraint.json", - "description": "Constraints applied to the branch selected by `type`. Concrete typed-constraint schemas should narrow this to the selected branch's constraint schema. Omit when accepting the branch with only its schema-defined required fields." + "title": "Typed Constraint", + "description": "A Constraint for one branch of a discriminated family (e.g. an accepted credential type), carrying a `type` discriminator inline. Consumers match the submitted instance's discriminator to `type` and apply this constraint. Discrimination is a data lookup over a list, NOT a schema `oneOf`/`if`/`then` — so unknown `type` values are simply handler/extension branches and pass through untouched.", + "allOf": [ + { "$ref": "constraint.json" }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "The discriminator value this constraint applies to (e.g. a credential type). Open string: unknown values are extension branches." + } + } } - } + ] } From 8f08ecc3928b03c0d34c61c80bf20e0677f5795c Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Sat, 11 Jul 2026 07:14:22 -0700 Subject: [PATCH 2/2] feat: factor available-instrument constraints into distinct axes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..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`. --- docs/documentation/schema-authoring.md | 21 ++++-- docs/specification/payment-handler-guide.md | 71 +++++++++---------- .../types/available_payment_instrument.json | 39 +++++----- .../shopping/types/type_constraint.json | 25 ++++--- 4 files changed, 83 insertions(+), 73 deletions(-) diff --git a/docs/documentation/schema-authoring.md b/docs/documentation/schema-authoring.md index ca4e67af6..4173823ef 100644 --- a/docs/documentation/schema-authoring.md +++ b/docs/documentation/schema-authoring.md @@ -443,6 +443,15 @@ All new constraint objects **SHOULD** extend no `type` and stays open: it narrows named properties and never forbids unknown ones (no `additionalProperties:false`, `oneOf`, or `if`/`then`). +`constraints` covers only field-level requirements over the *submitted* object. +Two related but distinct axes use their own keys, **not** `constraints`: +**accepted-value menus** for derived or selected attributes (e.g. +`accepts: { "brand": ["visa", "mastercard"] }` — a card's network is derived, not +a submitted field, so it is a menu, not a field constraint), and **discriminated +per-subtype requirements** (e.g. `credentials`, below). Keeping them separate is +what lets `constraints` stay a genuine bounded JSON Schema over the object's own +fields. + Prefer field-level constraints over ad-hoc booleans. For example, prefer `required: ["billing_address"]` plus `properties.billing_address.required` over new booleans such as `requires_billing_address` or @@ -453,14 +462,18 @@ over new booleans such as `requires_billing_address` or { "type": "card", "constraints": { - "brands": ["visa", "mastercard"], "required": ["billing_address"], "properties": { "billing_address": { "required": ["postal_code", "address_country"] } } - } + }, + "accepts": { "brand": ["visa", "mastercard"] }, + "credentials": [ + { "type": "pan", "constraints": { "required": ["cvc"] } }, + { "type": "network_token", "constraints": { "required": ["cryptogram"] } } + ] } ``` @@ -476,10 +489,10 @@ UCP-defined branches and handler/extension branches coexist without a schema ```json { "credentials": [ - { "type": "pan", "required": ["cvc"] }, + { "type": "pan", "constraints": { "required": ["cvc"] } }, { "type": "com.example.wallet_token", - "required": ["assurance_level"] + "constraints": { "required": ["assurance_level"] } } ] } diff --git a/docs/specification/payment-handler-guide.md b/docs/specification/payment-handler-guide.md index ffb433f36..3e2c82c17 100644 --- a/docs/specification/payment-handler-guide.md +++ b/docs/specification/payment-handler-guide.md @@ -218,8 +218,8 @@ and typically includes different configuration: "available_instruments": [ { "type": "card", - "constraints": { - "brands": ["visa", "mastercard"] + "accepts": { + "brand": ["visa", "mastercard"] } } ], @@ -242,8 +242,8 @@ and typically includes different configuration: "available_instruments": [ { "type": "card", - "constraints": { - "brands": ["visa", "mastercard", "amex", "discover"] + "accepts": { + "brand": ["visa", "mastercard", "amex", "discover"] } } ], @@ -264,8 +264,8 @@ and typically includes different configuration: "available_instruments": [ { "type": "card", - "constraints": { - "brands": ["visa", "mastercard"] + "accepts": { + "brand": ["visa", "mastercard"] } } ], @@ -305,44 +305,37 @@ authoritative value returned in the `response_schema`. | Source | `available_instruments` | | :----- | :---------------------- | -| Platform profile | `[{type: "card", constraints: {brands: ["visa", "mastercard", "amex", "discover"]}}]` | -| Business profile | `[{type: "card", constraints: {brands: ["visa", "mastercard", "amex"]}}]` | -| **Response (resolved)** | `[{type: "card", constraints: {brands: ["visa", "mastercard", "amex"]}}]` | +| Platform profile | `[{type: "card", accepts: {brand: ["visa", "mastercard", "amex", "discover"]}}]` | +| Business profile | `[{type: "card", accepts: {brand: ["visa", "mastercard", "amex"]}}]` | +| **Response (resolved)** | `[{type: "card", accepts: {brand: ["visa", "mastercard", "amex"]}}]` | In this example, the business's PSP is not configured for Discover, so Discover is excluded from the response even though the platform supports it. #### Constraint Semantics -`available_instruments[].constraints` describes what an acceptable instrument -must satisfy for a handler declaration or resolved checkout response. A -[`Constraint`](site:schemas/shopping/types/constraint.json) is a sparse overlay -on the instrument's base schema, using a bounded, JSON-Schema-aligned vocabulary: - -| Constraint key | Meaning | -| :------------- | :------ | -| `required` | Names of the instrument's properties that MUST be present in this context (e.g. `billing_address`). | -| `properties` | Per-property nested constraints — each value is itself a `Constraint` applied to that property (e.g. `billing_address` narrowing which address fields are required). | -| `enum` | Allowed values for a constrained property (a per-merchant closed value set). | -| Domain-specific keys | Additional keys defined by the concrete instrument or handler schema, e.g. `credentials`, `brands`. | +An `available_instruments[]` entry declares what an acceptable instrument must +satisfy along **three distinct axes**, each with its own mechanism: -Base payment instruments define these common constraints: - -| Key | Description | -| :-- | :---------- | -| `required` / `properties` | Which instrument fields are required and their nested requirements. `billing_address` is the standard base field; `properties.billing_address.required` names the address fields needed (e.g. AVS postal code). | -| `credentials` | Accepted credential families as a typed list — each entry is a `Constraint` carrying a `type` discriminator plus that credential's `required` fields. Discrimination is a data lookup: the consumer applies the entry matching the submitted credential; unknown `type` values are handler/extension branches. | +| Axis | Key | Mechanism | +| :--- | :-- | :-------- | +| Field requirements | `constraints` | A [`Constraint`](site:schemas/shopping/types/constraint.json) — a bounded JSON Schema over the instrument's OWN submitted fields: `required` (presence) and `properties..enum` (allowed values). | +| Accepted options | `accepts` | A uniform map of derived/selected attributes to supported values (e.g. `{ "brand": ["visa","mastercard"] }`). Read to OFFER options; the attribute (e.g. card network) is derived, not a submitted field — a menu, not a field-value constraint. | +| Credential requirements | `credentials` | A typed list keyed by credential `type`, each carrying its own `Constraint`. Applied by data lookup on the submitted credential's `type`; unknown types are handler/extension branches (never a schema `oneOf`). | -Card instruments add: +Within `constraints`: | Key | Description | | :-- | :---------- | -| `brands` | Accepted card network names (e.g. `visa`, `mastercard`) — a capability advertisement, open to any string. The accepted network is derived from the credential, so this is a capability rather than a field-value constraint. | +| `required` | Instrument fields that MUST be present (e.g. `billing_address`). | +| `properties` | Per-field nested constraints, recursive — `properties.billing_address.required` names the address fields needed (AVS postal code); `properties..enum` restricts a field's value (a per-merchant runtime set, not a base-schema enum). | Express requirements as field-level constraints instead of handler-specific -booleans. For example, an AVS postal-code requirement is -`properties.billing_address.required: ["postal_code"]`, not a new -`requires_billing_postal_code` flag. +booleans. An AVS postal-code requirement is +`constraints.properties.billing_address.required: ["postal_code"]`, not a new +`requires_billing_postal_code` flag. An accepted-brands list is `accepts.brand`, +not a bespoke `brands` key — so accepted-value menus stay uniform instead of +proliferating. ```json @@ -353,18 +346,20 @@ booleans. For example, an AVS postal-code requirement is { "type": "card", "constraints": { - "brands": ["visa", "mastercard"], "required": ["billing_address"], "properties": { "billing_address": { "required": ["postal_code", "address_country"] } - }, - "credentials": [ - { "type": "pan", "required": ["cvc"] }, - { "type": "network_token", "required": ["cryptogram"] } - ] - } + } + }, + "accepts": { + "brand": ["visa", "mastercard"] + }, + "credentials": [ + { "type": "pan", "constraints": { "required": ["cvc"] } }, + { "type": "network_token", "constraints": { "required": ["cryptogram"] } } + ] } ] } diff --git a/source/schemas/shopping/types/available_payment_instrument.json b/source/schemas/shopping/types/available_payment_instrument.json index dde4fedca..c8122ae60 100644 --- a/source/schemas/shopping/types/available_payment_instrument.json +++ b/source/schemas/shopping/types/available_payment_instrument.json @@ -2,31 +2,34 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://ucp.dev/schemas/shopping/types/available_payment_instrument.json", "title": "Available Payment Instrument", - "description": "An instrument type a handler accepts, with a Constraint describing what an acceptable instrument of this type must satisfy.", + "description": "An instrument type a handler accepts. What it accepts is declared along three distinct axes, each with its own mechanism: `constraints` (a bounded JSON Schema over the instrument's own fields), `accepts` (menus of derived/selected options), and `credentials` (per-credential-type requirements, discriminated by a data lookup).", "type": "object", "required": ["type"], "properties": { "type": { "type": "string", - "description": "The instrument type identifier (e.g., 'card'). References an instrument schema's type constant." + "description": "The instrument type identifier (e.g., 'card')." }, "constraints": { - "allOf": [ - { "$ref": "constraint.json" }, - { - "type": "object", - "properties": { - "credentials": { - "type": "array", - "items": { "$ref": "type_constraint.json" }, - "uniqueItems": true, - "minItems": 1, - "description": "Accepted credential families and their per-family constraints, each a Typed Constraint keyed by `type`. The consumer applies the entry matching the submitted credential; unknown types are handler/extension branches. This is a data list, not a schema `oneOf`." - } - } - } - ], - "description": "Constraint on an acceptable instrument. `required`/`properties`/`enum` describe presence and allowed values of the instrument's own fields (e.g. `billing_address`, `brand`); `credentials` extends it with per-credential-type constraints. Open to instrument-specific keys." + "$ref": "constraint.json", + "description": "A Constraint (bounded JSON Schema) over the instrument's OWN submitted fields: `required` (presence) and `properties..enum` (allowed values). This is the only part that is a runnable schema over the instrument." + }, + "accepts": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "minItems": 1 + }, + "description": "Menus of derived/selected attributes the handler supports (e.g. `{ \"brand\": [\"visa\",\"mastercard\"] }`). Consumers read these to OFFER options; the attribute is derived (e.g. card network from the PAN), not a submitted field, so it is a menu rather than a field-value constraint. A uniform map keyed by attribute, open to new attributes." + }, + "credentials": { + "type": "array", + "items": { "$ref": "type_constraint.json" }, + "uniqueItems": true, + "minItems": 1, + "description": "Accepted credential families with per-family requirements — each a Typed Constraint keyed by `type`. Discriminated by data lookup on the submitted credential's `type`; unknown types are handler/extension branches. A list, not a schema `oneOf`." } } } diff --git a/source/schemas/shopping/types/type_constraint.json b/source/schemas/shopping/types/type_constraint.json index 6638a8094..4e5d9d1d0 100644 --- a/source/schemas/shopping/types/type_constraint.json +++ b/source/schemas/shopping/types/type_constraint.json @@ -2,18 +2,17 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://ucp.dev/schemas/shopping/types/type_constraint.json", "title": "Typed Constraint", - "description": "A Constraint for one branch of a discriminated family (e.g. an accepted credential type), carrying a `type` discriminator inline. Consumers match the submitted instance's discriminator to `type` and apply this constraint. Discrimination is a data lookup over a list, NOT a schema `oneOf`/`if`/`then` — so unknown `type` values are simply handler/extension branches and pass through untouched.", - "allOf": [ - { "$ref": "constraint.json" }, - { - "type": "object", - "required": ["type"], - "properties": { - "type": { - "type": "string", - "description": "The discriminator value this constraint applies to (e.g. a credential type). Open string: unknown values are extension branches." - } - } + "description": "One entry of a discriminated family (e.g. an accepted credential type): a `type` discriminator plus a `Constraint` over that branch's own fields. Consumers apply the entry whose `type` matches the submitted instance — a data lookup, NOT a schema `oneOf`/`if`. Unknown `type` values are handler/extension branches and pass through.", + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "The discriminator value this entry applies to (e.g. a credential type). Open string: unknown values are extension branches." + }, + "constraints": { + "$ref": "constraint.json", + "description": "A Constraint (bounded JSON Schema) over the fields of the branch selected by `type`." } - ] + } }