diff --git a/docs/documentation/schema-authoring.md b/docs/documentation/schema-authoring.md index f1a4e81ae..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` | `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 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 041f53473..dc2498578 100644 --- a/docs/specification/overview/index.md +++ b/docs/specification/overview/index.md @@ -221,16 +221,29 @@ 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 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. +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. + +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` @@ -534,6 +547,58 @@ 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 raw PAN must carry a `cvc`, and a +network token must carry 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": [ + { + "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"] + } + ] + } + } + } + } +} +``` + +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. 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 An Action is an outstanding unit of extension-defined work for a Platform to @@ -2674,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" } } ] }, @@ -2753,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/main.py b/main.py index f8f8a8fea..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: @@ -811,7 +826,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 +852,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 +874,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']}`") @@ -892,7 +909,7 @@ def _render_table_from_schema( 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 41ba8c97a..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,13 +16,20 @@ }, "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": "#"}, {"$ref": "#/$defs/value_constraint"} ] } + }, + "anyOf": { + "type": "array", + "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": "#", "minProperties": 1} } }, "additionalProperties": false, diff --git a/source/schemas/common/types/request_constraints.json b/source/schemas/common/types/request_constraints.json index 26bca43ee..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,13 +20,20 @@ }, "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"}, {"$ref": "constraint_expression.json#/$defs/value_constraint"} ] } + }, + "anyOf": { + "type": "array", + "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", "minProperties": 1} } }, "additionalProperties": false