diff --git a/docs/documentation/schema-authoring.md b/docs/documentation/schema-authoring.md index d4e337f11..4173823ef 100644 --- a/docs/documentation/schema-authoring.md +++ b/docs/documentation/schema-authoring.md @@ -429,22 +429,32 @@ 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`). + +`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_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`. @@ -452,32 +462,37 @@ 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"] + } } - } + }, + "accepts": { "brand": ["visa", "mastercard"] }, + "credentials": [ + { "type": "pan", "constraints": { "required": ["cvc"] } }, + { "type": "network_token", "constraints": { "required": ["cryptogram"] } } + ] } ``` -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", "constraints": { "required": ["cvc"] } }, { "type": "com.example.wallet_token", - "constraints": { - "required_fields": ["assurance_level"] - } + "constraints": { "required": ["assurance_level"] } } ] } diff --git a/docs/specification/payment-handler-guide.md b/docs/specification/payment-handler-guide.md index a720b026f..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,43 +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. Constraint -objects extend [`Constraint`](site:schemas/shopping/types/constraint.json): - -| 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. | +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_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. | +| 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 inherit those base constraints and add card-specific constraints: +Within `constraints`: | 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. | +| `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). | -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. 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 @@ -352,15 +346,20 @@ 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"] - }, - "credentials": [ - { "type": "token" } - ] - } + "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"] } } + ] } ] } @@ -578,15 +577,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..c8122ae60 100644 --- a/source/schemas/shopping/types/available_payment_instrument.json +++ b/source/schemas/shopping/types/available_payment_instrument.json @@ -2,36 +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 available from a payment handler with optional constraints.", - "allOf": [ - { "$ref": "type_constraint.json" }, - { + "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')." + }, + "constraints": { + "$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", - "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": "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 - } - } + "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/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..4e5d9d1d0 100644 --- a/source/schemas/shopping/types/type_constraint.json +++ b/source/schemas/shopping/types/type_constraint.json @@ -1,18 +1,18 @@ { "$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.", + "title": "Typed Constraint", + "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 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." + "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": "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." + "description": "A Constraint (bounded JSON Schema) over the fields of the branch selected by `type`." } } }