Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 65 additions & 56 deletions docs/documentation/schema-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,58 +422,62 @@ typos in core metadata like the `ucp` block).

### Constraint Objects

UCP uses `constraints` objects when a declaration or response needs to narrow
what is acceptable without changing the base payload schema. Constraints are sparse runtime requirements on an already-typed target.
UCP defines three composable primitive constraints:
UCP uses constraint objects when a declaration or response needs to narrow what
is acceptable without changing the base payload schema. Constraints are sparse
runtime requirements on an already-typed target, declared along **two axes**:

- **`constraints`** — field requirements over the target's OWN fields, as an
Object Constraint: `required` (presence) plus one key per constrained field.
Comment thread
igrigorik marked this conversation as resolved.
A consumer compiles it to a JSON Schema overlay (`required` + `properties`) a
standard validator can run.
- **`options`** — accepted values and typed families the target negotiates:
a uniform map from attribute to acceptable set — a scalar list (e.g. `brands`) or
a typed family (Type Constraint entries keyed by `type` with per-branch
`constraints`, e.g. `credentials`). Resolved by lookup; not part of the overlay.
`required` names fields the buyer must **submit** (presence); it never lists
`options` attributes like `brands`, which name values the seller **accepts**,
not fields the buyer sends.

UCP defines three composable primitives:

| Primitive | Target | Built-in vocabulary |
| :-------- | :----- | :------------------ |
| [`ObjectConstraint`](site:{{ ucp_version }}/schemas/shopping/types/object_constraint.json) | Object | `required` |
| [`ValueConstraint`](site:{{ ucp_version }}/schemas/shopping/types/value_constraint.json) | Property value | `enum`, `const` |
| [`TypeConstraint`](site:{{ ucp_version }}/schemas/shopping/types/type_constraint.json) | Typed branch | `type`, optional `constraints` |

A concrete Object Constraint **MUST** extend `object_constraint.json` and define
every supported key. The key's schema determines its meaning:

- `ObjectConstraint` — recursively constrains a same-named object property.
- `ValueConstraint` — constrains a same-named property's value.
- `TypeConstraint` (or an array of them) — selects a typed branch.
- Any other schema — defines a domain-specific literal operator whose semantics
the owning specification must document.

Every name in `required` **MUST** be a property of the constrained object. The
Object Constraint stays open so handler and extension schemas can compose with
`allOf`; after negotiation, a key not defined by the concrete constraint schema
is an authoring or compatibility error.

This example defines all four key forms:
| [`ObjectConstraint`](site:{{ ucp_version }}/schemas/shopping/types/object_constraint.json) | An object's fields | `required` + a key per constrained field |
| [`ValueConstraint`](site:{{ ucp_version }}/schemas/shopping/types/value_constraint.json) | A property's value | `enum`, `const` |
| [`TypeConstraint`](site:{{ ucp_version }}/schemas/shopping/types/type_constraint.json) | A typed branch | `type`, optional `constraints` |

Inside an Object Constraint, `required` is reserved (the presence list); every
other key names a target field and carries that field's nested constraint — an
Object Constraint (recurse) or a Value Constraint. The two are distinguishable
**from the data alone** — `enum`/`const` is a Value Constraint, otherwise it's an
Object Constraint — so a consumer compiles the overlay without resolving the
concrete schema. Field constraints are **open by default**: they live in the wire
data and validate against the open Object Constraint, so most schemas only narrow
`options`. Every name in `required` **MUST** be a property of the target.

Concrete availability schema (a payment handler narrowing the card branch). Narrowing a
field constraint takes a single `properties` keyword — rarely needed, since field
constraints are open:

<!-- ucp:example skip reason="schema authoring example" -->
```json
{
"$defs": {
"constraint": {
"available_card": {
"allOf": [
{ "$ref": "object_constraint.json" },
{ "$ref": "available_payment_instrument.json" },
{
"properties": {
"billing_address": {
"allOf": [
{ "$ref": "object_constraint.json" },
{
"properties": {
"address_country": { "$ref": "value_constraint.json" }
}
}
]
"type": { "const": "card" },
"constraints": {
"properties": {
"billing_address": { "$ref": "object_constraint.json" }
}
},
"credentials": {
"type": "array",
"items": { "$ref": "type_constraint.json" }
},
"brands": {
"type": "array",
"items": { "type": "string" }
"options": {
"properties": {
"brands": { "type": "array", "items": { "type": "string" } }
}
}
}
}
Expand All @@ -483,29 +487,34 @@ This example defines all four key forms:
}
```

Its wire value remains local to the constrained fields:
Its wire value keeps field requirements and accepted options in separate axes,
and field constraints nest as plain direct keys:

<!-- ucp:example skip reason="schema authoring example" -->
<!-- ucp:example schema=shopping/types/card_payment_instrument def=available_card_payment_instrument -->
```json
{
"required": ["billing_address"],
"billing_address": {
"required": ["address_country"],
"address_country": { "enum": ["US", "CA"] }
"type": "card",
"constraints": {
"required": ["billing_address"],
"billing_address": {
"required": ["address_country"],
"address_country": { "enum": ["US", "CA"] }
}
},
"credentials": [{ "type": "token" }],
"brands": ["visa", "mastercard"]
"options": {
"brands": ["visa", "mastercard"],
"credentials": [{ "type": "token" }]
}
}
```

`ValueConstraint` is deliberately closed: unsupported assertions cannot be
ignored safely. `ObjectConstraint` and `TypeConstraint.type` are extension
points. To type-check a declaration, resolve its target and concrete constraint
schemas, validate each key and value, and check `required` names against the
target. Object and Value Constraints can compile into a JSON Schema overlay;
literal domain operators are only type-checked, and their owner enforces their
meaning. Declared constraints are an upfront minimum; dynamic requirements still
use recoverable errors and [`message_error.path`](site:{{ ucp_version }}/schemas/common/types/message_error.json).
`ValueConstraint` is deliberately closed: unsupported assertions cannot be ignored
safely. `ObjectConstraint` and `TypeConstraint.type` are extension points.
`constraints` compiles into a JSON Schema overlay (presence + allowed values) that
a standard validator runs; `options` is resolved by lookup — scalar lists by
membership, typed families by dispatch on the submitted `type`. Declared constraints
are an upfront minimum; dynamic requirements still use recoverable errors and
[`message_error.path`](site:{{ ucp_version }}/schemas/common/types/message_error.json).

### Property-Count Constraints (`minProperties` / `maxProperties`)

Expand Down
2 changes: 1 addition & 1 deletion docs/specification/checkout-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Businesses advertise MCP transport availability through their UCP profile at
"spec": "https://example.vendor.com/specs/delegate-payment",
"schema": "https://example.vendor.com/schemas/delegate-payment-config.json",
"available_instruments": [
{"type": "card", "constraints": {"brands": ["visa", "mastercard"]}}
{"type": "card", "options": {"brands": ["visa", "mastercard"]}}
],
"config": {}
}
Expand Down
6 changes: 3 additions & 3 deletions docs/specification/examples/encrypted-credential-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ have their own compliance requirements.
"available_instruments": [
{
"type": "card",
"constraints": {
"options": {
"brands": ["visa", "mastercard"]
}
}
Expand Down Expand Up @@ -194,7 +194,7 @@ The response config includes information about the encryption used.
"available_instruments": [
{
"type": "card",
"constraints": {
"options": {
"brands": ["visa", "mastercard"]
}
}
Expand Down Expand Up @@ -268,7 +268,7 @@ registry using `platform_config`.
"available_instruments": [
{
"type": "card",
"constraints": {
"options": {
"brands": ["visa", "mastercard", "amex", "discover"]
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ credential type (e.g., PCI DSS for cards).
"available_instruments": [
{
"type": "card",
"constraints": {
"options": {
"brands": ["visa", "mastercard"]
}
}
Expand Down Expand Up @@ -243,7 +243,7 @@ The response config includes runtime token lifecycle information.
"available_instruments": [
{
"type": "card",
"constraints": {
"options": {
"brands": ["visa", "mastercard"]
}
}
Expand Down Expand Up @@ -341,7 +341,7 @@ registry using `platform_config`.
"available_instruments": [
{
"type": "card",
"constraints": {
"options": {
"brands": ["visa", "mastercard", "amex", "discover"]
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ The handler's specification (referenced via the `spec` field) documents the
"available_instruments": [
{
"type": "card",
"constraints": {
"options": {
"brands": ["visa", "mastercard", "amex"]
}
}
Expand Down Expand Up @@ -157,7 +157,7 @@ The response config includes runtime information about what's available for this
"available_instruments": [
{
"type": "card",
"constraints": {
"options": {
"brands": ["visa", "mastercard", "amex"]
}
}
Expand Down Expand Up @@ -205,7 +205,7 @@ business's configuration.
"id": "processor_tokenizer",
"version": "{{ ucp_version }}",
"available_instruments": [
{"type": "card", "constraints": {"brands": ["visa", "mastercard", "amex"]}}
{"type": "card", "options": {"brands": ["visa", "mastercard", "amex"]}}
],
"config": {
"environment": "production",
Expand Down
6 changes: 3 additions & 3 deletions docs/specification/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ Businesses publish their profile at `/.well-known/ucp`. An example:
"available_instruments": [
{
"type": "card",
"constraints": {
"options": {
"brands": ["visa", "mastercard", "amex"]
}
}
Expand Down Expand Up @@ -727,7 +727,7 @@ example:
"spec": "https://example.com/specs/payments/processor_tokenizer-payment",
"schema": "https://example.com/schemas/payments/delegate-payment.json",
"available_instruments": [
{"type": "card", "constraints": {"brands": ["visa", "mastercard"]}}
{"type": "card", "options": {"brands": ["visa", "mastercard"]}}
]
}
]
Expand Down Expand Up @@ -1724,7 +1724,7 @@ request a challenge.
"available_instruments": [
{
"type": "card",
"constraints": {
"options": {
"brands": ["visa", "mastercard"]
}
}
Expand Down
40 changes: 20 additions & 20 deletions docs/specification/payment-handler-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ and typically includes different configuration:
"available_instruments": [
{
"type": "card",
"constraints": {
"options": {
"brands": ["visa", "mastercard"]
}
}
Expand All @@ -253,7 +253,7 @@ and typically includes different configuration:
"available_instruments": [
{
"type": "card",
"constraints": {
"options": {
"brands": ["visa", "mastercard", "amex", "discover"]
}
}
Expand All @@ -275,7 +275,7 @@ and typically includes different configuration:
"available_instruments": [
{
"type": "card",
"constraints": {
"options": {
"brands": ["visa", "mastercard"]
}
}
Expand Down Expand Up @@ -319,28 +319,26 @@ 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", options: {brands: ["visa", "mastercard", "amex", "discover"]}}]` |
| Business profile | `[{type: "card", options: {brands: ["visa", "mastercard", "amex"]}}]` |
| **Response (resolved)** | `[{type: "card", options: {brands: ["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

Within each available-instrument Type Constraint, `constraints` is an
[`ObjectConstraint`](site:schemas/shopping/types/object_constraint.json) on the
selected instrument. The base availability schema defines:
Within each available-instrument entry, requirements are declared along **two
axes**:

| Key | Constraint type | Meaning |
| :-- | :-------------- | :------ |
| `required` | Object | Instrument properties required in this context. |
| `billing_address` | Object | Nested requirements on the billing address. |
| `credentials` | Type | Accepted credential branches and their requirements. |
| Axis | Type | Meaning |
| :--- | :--- | :------ |
| `constraints` | [`ObjectConstraint`](site:schemas/shopping/types/object_constraint.json) | Field requirements on the instrument's OWN fields: `required` (presence) plus a key per constrained field carrying its nested requirement / allowed values. Compiles to a JSON Schema overlay. |
| `options` | map | Accepted values and typed families: `brands` (a scalar list of accepted networks) and `credentials` (typed branches with per-branch requirements). Resolved by lookup. |

Concrete schemas add their own keys. The card availability schema adds `brands`,
a literal list of accepted networks. Use field constraints instead of
handler-specific booleans for modeled data.
Express field requirements as `constraints` (an AVS postal-code requirement is
`constraints.billing_address.required: ["postal_code"]`), and accepted choices as
`options` — not handler-specific booleans or bespoke keys.

<!-- ucp:example schema=payment_handler def=business_schema -->
```json
Expand All @@ -354,9 +352,11 @@ handler-specific booleans for modeled data.
"required": ["billing_address"],
"billing_address": {
"required": ["postal_code", "address_country"]
},
"credentials": [{ "type": "token" }],
"brands": ["visa", "mastercard"]
}
},
"options": {
"brands": ["visa", "mastercard"],
"credentials": [{ "type": "token" }]
}
}
]
Expand Down
17 changes: 9 additions & 8 deletions source/schemas/shopping/types/available_payment_instrument.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,28 @@
"$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.",
"description": "An instrument type a handler accepts, declared along two axes. `constraints` is an Object Constraint over the instrument's OWN fields (a JSON Schema overlay: presence + allowed values). `options` is the set of accepted values and typed families this instrument negotiates (resolved by lookup, not compiled into the overlay).",
"allOf": [
{ "$ref": "type_constraint.json" },
{
"type": "object",
"properties": {
"constraints": {
"options": {
"type": "object",
"properties": {
"billing_address": {
"$ref": "object_constraint.json",
"description": "Local constraint on the instrument's `billing_address` field."
},
Comment on lines -14 to -17

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Without this, we'll fail to reject a constraint over field "billing_addr" when it should be "billing_address", right?

@jamesandersen jamesandersen Jul 31, 2026

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.

If I follow correctly, I think this is safe to remove — the field-name check belongs against the target schema (payment_instrument, where billing_address actually lives), not against available_payment_instrument. That's the lint @igrigorik defers in Trade-offs ("does the named field exist on the target?").

"credentials": {
"type": "array",
"items": { "$ref": "type_constraint.json" },
"uniqueItems": true,
"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": "Accepted credential families — each a Typed Constraint keyed by `type` with per-branch `constraints`. Applied by data lookup on the submitted credential's `type`; unknown types are handler/extension branches."
}
},
"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."
"additionalProperties": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true
Comment on lines +22 to +24

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This seems problematic to close the supported additional properties set, right?
I'm imagining other data types are acceptable keys to continue extending onto available instrument.

@jamesandersen jamesandersen Jul 31, 2026

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.

+1. The docs describe options as a uniform map — "a scalar list (e.g. brands) or a typed family (e.g. credentials)" — but the schema only makes the scalar case extensible: additionalProperties is {array of strings}, so credentials validates only because it's hardcoded as a named property. Any other typed family under a new key would fail.

I'd suggest widening additionalProperties to admit either shape (distinguished by item type, so still a pure data-shape check):

"additionalProperties": {
  "anyOf": [
    { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
    { "type": "array", "items": { "$ref": "type_constraint.json" }, "uniqueItems": true }
  ]
}

With this, credentials no longer needs to be a named property either — it just becomes the canonical typed-family value, and options is genuinely uniform (shape decides dispatch vs. membership). The tradeoff is losing credentials as signposting for a known family, so worth keeping it named if we'd rather advertise it explicitly.

Otherwise, if options is meant to be scalar-only with credentials a deliberate one-off, let's tighten the docs to say so. Either way, schema and prose should agree — which did you intend, @igrigorik?

},
"description": "Accepted options this instrument negotiates: `credentials` (a typed family) plus scalar value lists keyed by attribute (e.g. `brands`). A uniform map read to OFFER options; not part of the field-constraint overlay."
}
}
}
Expand Down
Loading
Loading