From c1602262c88de90e02760998ee493fec0f404493 Mon Sep 17 00:00:00 2001 From: Daniel Wyckoff Date: Fri, 21 Aug 2026 07:47:27 -0400 Subject: [PATCH 1/4] introduce anyOf constraint with payments example --- docs/documentation/schema-authoring.md | 2 +- docs/specification/overview/index.md | 46 +++++++++++++++++-- .../common/types/constraint_expression.json | 6 +++ .../common/types/request_constraints.json | 6 +++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/docs/documentation/schema-authoring.md b/docs/documentation/schema-authoring.md index f1a4e81ae..9af9cc04e 100644 --- a/docs/documentation/schema-authoring.md +++ b/docs/documentation/schema-authoring.md @@ -541,7 +541,7 @@ registered member. | Position | Admitted members | Shape | | :-- | :-- | :-- | -| Object Constraint | `required`, `properties` | `required` contains unique field names; `properties` maps field names to Object or Value Constraints; an empty object is a no-op. | +| Object Constraint | `required`, `properties`, `anyOf` | `required` contains unique field names; `properties` maps field names to Object or Value Constraints; `anyOf` is a non-empty array of Object Constraints; an empty object is a no-op. | | Value Constraint | `enum`, `const` | `enum` is non-empty and unique; at least one member is present; both apply when present together. | The listed members define the grammar. A `request_constraints` value may diff --git a/docs/specification/overview/index.md b/docs/specification/overview/index.md index 041f53473..2e4e5c3c1 100644 --- a/docs/specification/overview/index.md +++ b/docs/specification/overview/index.md @@ -221,16 +221,22 @@ optional `path`; nested constraint objects may not. The grammar does not admit `ucp`. Keys in `properties` name fields on selected request objects. The constraint begins at an Object Constraint. Object Constraints may nest -through `properties`; Value Constraints occur only as values in an Object -Constraint's `properties` map. +through `properties` and `anyOf`; Value Constraints occur only as values in an +Object Constraint's `properties` map. | Position | Admitted members | Shape and behavior | | :-- | :-- | :-- | -| Object Constraint | `required`, `properties` | `required` is an array of unique field names. `properties` maps field names to Object or Value Constraints. An empty Object Constraint is a valid no-op at any Object Constraint position. | +| Object Constraint | `required`, `properties`, `anyOf` | `required` is an array of unique field names. `properties` maps field names to Object or Value Constraints. `anyOf` is a non-empty array of Object Constraints, at least one of which the object must satisfy. An empty Object Constraint is a valid no-op at any Object Constraint position. | | Value Constraint | `enum`, `const` | `enum` is a non-empty array of unique JSON values. `const` is any JSON value. At least one member is present; when both are present, both apply. | No other member is admitted at either grammar position. +Members present at the same Object Constraint all apply. `anyOf` does not +narrow, override, or replace its siblings; the object must satisfy every +sibling member and at least one branch. Each branch is an ordinary Object +Constraint and cannot carry `path`, so every branch is evaluated against the +same selected object. + ### Path Every `request_constraints` value has exactly one effective path. When `path` @@ -534,6 +540,40 @@ The payment-handler or instrument contract defines any stronger association. This example does not define card brands, credentials, support, availability, or payment policy. +#### Alternative verification requirements on a submitted credential + +A Business accepts more than one credential shape and requires different +verification data for each. In this example, a submitted credential must carry +either a `cvc` or a `cryptogram` with its `eci_value`: + + +```json +{ + "type": "card", + "ucp": { + "request_constraints": { + "path": "$['payment']['instruments'][?@['handler_id'] == 'processor_1' && @['type'] == 'card']", + "required": ["credential"], + "properties": { + "credential": { + "anyOf": [ + {"required": ["cvc"]}, + {"required": ["cryptogram", "eci_value"]} + ] + } + } + } + } +} +``` + +One path selects the submitted instrument, and one Object Constraint describes +it. The sibling `required` applies to every matching instrument; the `anyOf` +branches then apply to the nested `credential` object, which must satisfy at +least one. A credential carrying neither field fails, as does one carrying +`cryptogram` without `eci_value`. Expressing the same rule as two separately +targeted constraints would restate the path and lose the single-object reading. + ## Actions An Action is an outstanding unit of extension-defined work for a Platform to diff --git a/source/schemas/common/types/constraint_expression.json b/source/schemas/common/types/constraint_expression.json index 41ba8c97a..9ba36c7d4 100644 --- a/source/schemas/common/types/constraint_expression.json +++ b/source/schemas/common/types/constraint_expression.json @@ -22,6 +22,12 @@ {"$ref": "#/$defs/value_constraint"} ] } + }, + "anyOf": { + "type": "array", + "description": "Alternative Object Constraints. The constrained object must satisfy at least one.", + "minItems": 1, + "items": {"$ref": "#"} } }, "additionalProperties": false, diff --git a/source/schemas/common/types/request_constraints.json b/source/schemas/common/types/request_constraints.json index 26bca43ee..a551f5bc2 100644 --- a/source/schemas/common/types/request_constraints.json +++ b/source/schemas/common/types/request_constraints.json @@ -26,6 +26,12 @@ {"$ref": "constraint_expression.json#/$defs/value_constraint"} ] } + }, + "anyOf": { + "type": "array", + "description": "Alternative Object Constraints. The constrained object must satisfy at least one.", + "minItems": 1, + "items": {"$ref": "constraint_expression.json"} } }, "additionalProperties": false From 4c53c46701d43e5d9cc2ddd06b86aeaba2588eca Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Fri, 21 Aug 2026 09:39:05 -0700 Subject: [PATCH 2/4] Ig/anyof review suggestions (#760) * docs: complete abridged payment instrument examples Both examples elided id, handler_id, and type behind a "more required fields" comment, and one also omitted credential.type. They validated only because shipped ucp-schema never applied the self-root allOf requirements in selected_payment_instrument, so payment.instruments[] was never checked against payment_instrument's required members. Showing the fields costs one line each, keeps both examples validated once that resolver bug is fixed, and removes a comment that promised required fields while showing none. * feat: require non-empty anyOf branches in the constraint grammar An empty Object Constraint is a valid no-op at every other position, but an empty anyOf branch is satisfied by every object, so it neutralizes the alternation rather than doing nothing. The grammar should not admit a branch that silently disables the alternation it belongs to. A minProperties sibling on the recursive $ref is legal 2020-12 composition: siblings to $ref apply alongside the referenced schema, so the branch stays an ordinary Object Constraint while gaining the non-empty requirement, at every nesting depth. Mirrors the constraint into both grammar tables, since the schema and its companion prose are one unit. * docs: discriminate the anyOf credential example The undiscriminated form accepts nonsense: a credential declaring any card_number_type at all satisfies the cvc branch as long as it carries a cvc, so the example teaches an idiom that does not enforce what the surrounding prose claims. Pinning card_number_type with const in each branch, and naming it in that branch's required, makes a branch match only the shape it describes. Documents the consequence that pinning creates: because every branch pins the discriminator, the branch set also closes the accepted values, so a dpan credential valid under card_credential.json satisfies neither branch. That closure is the rule a per-branch conditional cannot express, and it is load-bearing enough to state. Replaces the "would restate the path" justification, which understated the case. Request Constraints conjoin, so two separately targeted constraints would require both cvc and cryptogram; the real alternative is discriminating in the path filter, which puts conditional logic in the selector. Adds the two grammar mechanics the example relies on next to the composition rules rather than in the Business guidelines: branches are alternatives rather than a partition, and properties asserts nothing about an absent member. Both are consequences of how the grammar composes, not conformance requirements, so they carry no BCP 14 keywords and belong with the grammar they describe. --- docs/documentation/schema-authoring.md | 2 +- docs/specification/overview/index.md | 49 ++++++++++++++----- .../common/types/constraint_expression.json | 4 +- .../common/types/request_constraints.json | 4 +- 4 files changed, 43 insertions(+), 16 deletions(-) diff --git a/docs/documentation/schema-authoring.md b/docs/documentation/schema-authoring.md index 9af9cc04e..b94ec28aa 100644 --- a/docs/documentation/schema-authoring.md +++ b/docs/documentation/schema-authoring.md @@ -541,7 +541,7 @@ registered member. | Position | Admitted members | Shape | | :-- | :-- | :-- | -| Object Constraint | `required`, `properties`, `anyOf` | `required` contains unique field names; `properties` maps field names to Object or Value Constraints; `anyOf` is a non-empty array of Object Constraints; an empty object is a no-op. | +| Object Constraint | `required`, `properties`, `anyOf` | `required` contains unique field names; `properties` maps field names to Object or Value Constraints; `anyOf` is a non-empty array of non-empty Object Constraints; an empty object is a no-op except as an `anyOf` branch. | | Value Constraint | `enum`, `const` | `enum` is non-empty and unique; at least one member is present; both apply when present together. | The listed members define the grammar. A `request_constraints` value may diff --git a/docs/specification/overview/index.md b/docs/specification/overview/index.md index 2e4e5c3c1..dc2498578 100644 --- a/docs/specification/overview/index.md +++ b/docs/specification/overview/index.md @@ -226,7 +226,7 @@ Object Constraint's `properties` map. | Position | Admitted members | Shape and behavior | | :-- | :-- | :-- | -| Object Constraint | `required`, `properties`, `anyOf` | `required` is an array of unique field names. `properties` maps field names to Object or Value Constraints. `anyOf` is a non-empty array of Object Constraints, at least one of which the object must satisfy. An empty Object Constraint is a valid no-op at any Object Constraint position. | +| Object Constraint | `required`, `properties`, `anyOf` | `required` is an array of unique field names. `properties` maps field names to Object or Value Constraints. `anyOf` is a non-empty array of non-empty Object Constraints, at least one of which the object must satisfy. An empty Object Constraint is a valid no-op at every Object Constraint position except an `anyOf` branch. | | Value Constraint | `enum`, `const` | `enum` is a non-empty array of unique JSON values. `const` is any JSON value. At least one member is present; when both are present, both apply. | No other member is admitted at either grammar position. @@ -237,6 +237,13 @@ sibling member and at least one branch. Each branch is an ordinary Object Constraint and cannot carry `path`, so every branch is evaluated against the same selected object. +Branches are alternatives, not a partition: an object satisfying more than one +branch is valid. Within a branch, `properties` constrains a member only when +that member is present, so a branch pinning a discriminator through +`properties` alone is also satisfied by an object that omits it; naming the +discriminator in the branch's `required` makes the branch match only the shape +it describes. + ### Path Every `request_constraints` value has exactly one effective path. When `path` @@ -543,8 +550,8 @@ payment policy. #### Alternative verification requirements on a submitted credential A Business accepts more than one credential shape and requires different -verification data for each. In this example, a submitted credential must carry -either a `cvc` or a `cryptogram` with its `eci_value`: +verification data for each. In this example, a raw PAN must carry a `cvc`, and a +network token must carry a `cryptogram` with its `eci_value`: ```json @@ -557,8 +564,14 @@ either a `cvc` or a `cryptogram` with its `eci_value`: "properties": { "credential": { "anyOf": [ - {"required": ["cvc"]}, - {"required": ["cryptogram", "eci_value"]} + { + "properties": {"card_number_type": {"const": "fpan"}}, + "required": ["card_number_type", "cvc"] + }, + { + "properties": {"card_number_type": {"const": "network_token"}}, + "required": ["card_number_type", "cryptogram", "eci_value"] + } ] } } @@ -570,9 +583,21 @@ either a `cvc` or a `cryptogram` with its `eci_value`: One path selects the submitted instrument, and one Object Constraint describes it. The sibling `required` applies to every matching instrument; the `anyOf` branches then apply to the nested `credential` object, which must satisfy at -least one. A credential carrying neither field fails, as does one carrying -`cryptogram` without `eci_value`. Expressing the same rule as two separately -targeted constraints would restate the path and lose the single-object reading. +least one. Each branch pins `card_number_type` and also names it in `required`, +so a branch matches only the credential shape it describes. A raw PAN without a +`cvc` fails, as does a network token missing its `eci_value`. + +Because every branch pins the discriminator, the branch set also closes the +accepted values. A `dpan` credential is valid under +[`card_credential.json`](site:schemas/shopping/types/card_credential.json) but +satisfies neither branch, so this Business does not accept it at this path. A +Business that later accepts a new variant adds a branch for it. + +Two separately targeted constraints cannot express this rule. Request +Constraints conjoin, so one value requiring `cvc` and another requiring +`cryptogram` would require both. Discriminating through the path filter instead +— selecting `fpan` credentials in one value and `network_token` credentials in +another — moves conditional logic into the selector, which paths do not carry. ## Actions @@ -2714,9 +2739,10 @@ POST /checkout-sessions/{id}/complete "payment": { "instruments": [ { + "id": "pi_tok_visa", "handler_id": "merchant_tokenizer", - // ... more instrument required field - "credential": { "token": "tok_visa_123" } + "type": "card", + "credential": { "type": "card", "token": "tok_visa_123" } } ] }, @@ -2793,8 +2819,9 @@ POST /checkout-sessions/{id}/complete "payment": { "instruments": [ { + "id": "pi_ap2_card", "handler_id": "ap2_234352", - // other required instruments fields + "type": "card", "credential": { "type": "card", "token": "eyJhbGciOiJ..." // Token would contain payment_mandate, the signed proof of funds auth diff --git a/source/schemas/common/types/constraint_expression.json b/source/schemas/common/types/constraint_expression.json index 9ba36c7d4..ee736e27d 100644 --- a/source/schemas/common/types/constraint_expression.json +++ b/source/schemas/common/types/constraint_expression.json @@ -25,9 +25,9 @@ }, "anyOf": { "type": "array", - "description": "Alternative Object Constraints. The constrained object must satisfy at least one.", + "description": "Alternative Object Constraints. The constrained object must satisfy at least one. A branch must be non-empty: an empty branch is satisfied by every object and neutralizes the alternation.", "minItems": 1, - "items": {"$ref": "#"} + "items": {"$ref": "#", "minProperties": 1} } }, "additionalProperties": false, diff --git a/source/schemas/common/types/request_constraints.json b/source/schemas/common/types/request_constraints.json index a551f5bc2..bde0a4f1e 100644 --- a/source/schemas/common/types/request_constraints.json +++ b/source/schemas/common/types/request_constraints.json @@ -29,9 +29,9 @@ }, "anyOf": { "type": "array", - "description": "Alternative Object Constraints. The constrained object must satisfy at least one.", + "description": "Alternative Object Constraints. The constrained object must satisfy at least one. A branch must be non-empty: an empty branch is satisfied by every object and neutralizes the alternation.", "minItems": 1, - "items": {"$ref": "constraint_expression.json"} + "items": {"$ref": "constraint_expression.json", "minProperties": 1} } }, "additionalProperties": false From d8b999cb2f02185012508b4573ef5a353b973e86 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Fri, 21 Aug 2026 10:56:35 -0700 Subject: [PATCH 3/4] fix: resolve self-root schema refs in generated reference links A bare "$ref": "#" names the schema being rendered and carries no filename of its own, so create_link derived an empty anchor and emitted a broken "/##" link. The docs build's link check fails on it, which is why build_and_verify_main has been red on this branch since the first push. Recursive references cannot be inlined -- ucp-schema preserves them even under --bundle, and the docs macro deliberately resolves without bundling so that $refs survive for hyperlink generation. The docs layer is therefore the only place that can give a self-root ref a name. Resolve "#" against the enclosing schema's own $id at each of the three sites that turn a $ref into a link: direct, array items, and oneOf branches. The array-items path is the one the constraint grammar's anyOf trips today. The oneOf path has the identical defect -- a named property with oneOf: [{"$ref": "#"}, ...] renders the same broken anchor -- and is fixed alongside it rather than left to be rediscovered. --- main.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index f8f8a8fea..4e7326281 100644 --- a/main.py +++ b/main.py @@ -795,6 +795,19 @@ def _render_table_from_schema( ) ) else: + # A bare "#" is a self-root reference naming the schema being rendered. + # It carries no filename, so create_link derives an empty anchor and emits + # a broken "/##" link. Recursive refs are irreducible: ucp-schema + # preserves them even under --bundle, so the docs layer is the only place + # that can name them. Resolve against this schema's own $id. + self_id = schema_data.get("$id") + self_ref_name = self_id.rsplit("/", 1)[-1] if self_id else None + + def _deref_self(ref_value): + if ref_value == "#" and self_ref_name: + return self_ref_name + return ref_value + for field_name, details in properties.items(): if field_name == "$ref": md.append( @@ -811,7 +824,7 @@ def _render_table_from_schema( ) f_type = details.get("type", "any") - ref = details.get("$ref") + ref = _deref_self(details.get("$ref")) # Resolve UCP $defs references inline so properties render as # expanded tables (with anchors) instead of opaque links. @@ -837,7 +850,7 @@ def _render_table_from_schema( # Check for Array specific logic items = details.get("items", {}) - items_ref = items.get("$ref") + items_ref = _deref_self(items.get("$ref")) # Special handling for UCP version version_data = None @@ -859,7 +872,9 @@ def _render_table_from_schema( for one_of_type in details.get("oneOf", []): if "$ref" in one_of_type: parts.append( - create_link(one_of_type["$ref"], spec_file_name, context) + create_link( + _deref_self(one_of_type["$ref"]), spec_file_name, context + ) ) elif one_of_type.get("type"): parts.append(f"`{one_of_type['type']}`") From e8da3e819cea46bcd4dcf3c549b8624e0da05dbd Mon Sep 17 00:00:00 2001 From: Daniel Wyckoff Date: Fri, 21 Aug 2026 19:33:48 -0400 Subject: [PATCH 4/4] address feedback --- main.py | 32 ++++++++++--------- .../common/types/constraint_expression.json | 6 ++-- .../common/types/request_constraints.json | 6 ++-- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/main.py b/main.py index 4e7326281..419641e20 100644 --- a/main.py +++ b/main.py @@ -712,6 +712,19 @@ def _render_table_from_schema( if not schema_data: return "_No content fields defined._" + # A bare "#" is a self-root reference naming the schema being rendered. + # It carries no filename, so create_link derives an empty anchor and emits + # a broken "/##" link. Recursive refs are irreducible: ucp-schema + # preserves them even under --bundle, so the docs layer is the only place + # that can name them. Resolve against this schema's own $id. + self_id = schema_data.get("$id") + self_ref_name = self_id.rsplit("/", 1)[-1] if self_id else None + + def _deref_self(ref_value): + if ref_value == "#" and self_ref_name: + return self_ref_name + return ref_value + # If schema is ONLY a oneOf, render as prose instead of table if ( "oneOf" in schema_data @@ -722,7 +735,9 @@ def _render_table_from_schema( links = [] for item in schema_data["oneOf"]: if "$ref" in item: - links.append(create_link(item["$ref"], spec_file_name, context)) + links.append( + create_link(_deref_self(item["$ref"]), spec_file_name, context) + ) elif item.get("type"): links.append(f"`{item.get('type')}`") if links: @@ -795,19 +810,6 @@ def _render_table_from_schema( ) ) else: - # A bare "#" is a self-root reference naming the schema being rendered. - # It carries no filename, so create_link derives an empty anchor and emits - # a broken "/##" link. Recursive refs are irreducible: ucp-schema - # preserves them even under --bundle, so the docs layer is the only place - # that can name them. Resolve against this schema's own $id. - self_id = schema_data.get("$id") - self_ref_name = self_id.rsplit("/", 1)[-1] if self_id else None - - def _deref_self(ref_value): - if ref_value == "#" and self_ref_name: - return self_ref_name - return ref_value - for field_name, details in properties.items(): if field_name == "$ref": md.append( @@ -907,7 +909,7 @@ def _deref_self(ref_value): continue if branch.get("$ref"): inner_type = create_link( - branch["$ref"], spec_file_name, context + _deref_self(branch["$ref"]), spec_file_name, context ) break if branch.get("title"): diff --git a/source/schemas/common/types/constraint_expression.json b/source/schemas/common/types/constraint_expression.json index ee736e27d..eb3e2d0e3 100644 --- a/source/schemas/common/types/constraint_expression.json +++ b/source/schemas/common/types/constraint_expression.json @@ -7,7 +7,8 @@ "properties": { "required": { "type": "array", - "description": "Property names required by the constrained object.", + "description": "Property names required by the constrained object. Must be non-empty: an empty array applies no constraint.", + "minItems": 1, "uniqueItems": true, "items": { "type": "string" @@ -15,7 +16,8 @@ }, "properties": { "type": "object", - "description": "Constraints keyed by property name.", + "description": "Constraints keyed by property name. Must be non-empty: an empty object applies no constraint.", + "minProperties": 1, "additionalProperties": { "oneOf": [ {"$ref": "#"}, diff --git a/source/schemas/common/types/request_constraints.json b/source/schemas/common/types/request_constraints.json index bde0a4f1e..4c9b08fda 100644 --- a/source/schemas/common/types/request_constraints.json +++ b/source/schemas/common/types/request_constraints.json @@ -11,7 +11,8 @@ }, "required": { "type": "array", - "description": "Property names required by the constrained object.", + "description": "Property names required by the constrained object. Must be non-empty: an empty array applies no constraint.", + "minItems": 1, "uniqueItems": true, "items": { "type": "string" @@ -19,7 +20,8 @@ }, "properties": { "type": "object", - "description": "Constraints keyed by property name.", + "description": "Constraints keyed by property name. Must be non-empty: an empty object applies no constraint.", + "minProperties": 1, "additionalProperties": { "oneOf": [ {"$ref": "constraint_expression.json"},