diff --git a/docs/documentation/schema-authoring.md b/docs/documentation/schema-authoring.md index d04efe168..747dc52c6 100644 --- a/docs/documentation/schema-authoring.md +++ b/docs/documentation/schema-authoring.md @@ -156,6 +156,7 @@ Examples: `ucp.json` (entity base), `capability.json`, `service.json`, `payment_ UCP organizes capabilities, services, and handlers in **registries**—objects keyed by `name` rather than arrays of objects with `name` fields. + ```json { "capabilities": { @@ -202,6 +203,7 @@ Each entity type defines **three variants** for different contexts: **`platform_schema`** — Full declarations for discovery + ```json { "dev.ucp.shopping.fulfillment": [{ @@ -217,6 +219,7 @@ Each entity type defines **three variants** for different contexts: **`business_schema`** — Business-specific overrides + ```json { "dev.ucp.shopping.fulfillment": [{ @@ -230,6 +233,7 @@ Each entity type defines **three variants** for different contexts: **`response_schema`** — Minimal references in API responses + ```json { "ucp": { @@ -242,6 +246,7 @@ Each entity type defines **three variants** for different contexts: Define all three in your schema's `$defs`: + ```json "$defs": { "platform_schema": { @@ -262,6 +267,7 @@ Prefer **open string vocabularies** with documented well-known values over close `enum` arrays. Enums are a one-way door: adding a new value is a breaking change for strict validators, and removing one breaks existing producers. + ```json // PREFER: open vocabulary — extensible without schema changes "type": { @@ -292,6 +298,7 @@ capabilities **may** version independently when needed. Capabilities outside `dev.ucp.*` version fully independently: + ```json { "name": "com.shopify.loyalty", @@ -307,6 +314,7 @@ Vendor schemas follow the same self-describing requirements. A capability schema defines both payload structure and declaration variants: + ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/docs/specification/ap2-mandates.md b/docs/specification/ap2-mandates.md index b23ef7be7..ec88a04f1 100644 --- a/docs/specification/ap2-mandates.md +++ b/docs/specification/ap2-mandates.md @@ -65,6 +65,7 @@ Businesses declare support by adding `dev.ucp.shopping.ap2_mandate` to their **Business Profile Example:** + ```json { "capabilities": { @@ -143,6 +144,7 @@ Businesses **MUST** embed their signature in the checkout response body under **Checkout Response with Embedded Signature:** + ```json { "id": "chk_abc123", @@ -252,8 +254,10 @@ with `ap2.merchant_authorization` embedded in the response body. **Example Response:** + ```json { + "ucp": { ... }, "id": "chk_abc123", "status": "ready_for_complete", "currency": "USD", @@ -273,6 +277,7 @@ with `ap2.merchant_authorization` embedded in the response body. {"type": "tax", "amount": 400}, {"type": "total", "amount": 5400} ], + "links": [ ... ], "ap2": { "merchant_authorization": "eyJhbGciOiJFUzI1NiIsImtpZCI6Im1lcmNoYW50XzIwMjUifQ.." } @@ -337,6 +342,7 @@ request: {{ extension_schema_fields('ap2_mandate.json#/$defs/ap2_with_checkout_mandate', 'ap2-mandates') }} + ```json { "payment": { diff --git a/docs/specification/buyer-consent.md b/docs/specification/buyer-consent.md index 8d82eb9fc..07fa8c8a9 100644 --- a/docs/specification/buyer-consent.md +++ b/docs/specification/buyer-consent.md @@ -34,6 +34,7 @@ operations. Businesses advertise consent support in their profile: + ```json { "capabilities": { @@ -67,6 +68,7 @@ The platform includes consent within the `buyer` object in checkout operations: ### Example: Create Checkout with Consent + ```json POST /checkouts @@ -98,6 +100,7 @@ POST /checkouts ### Example: Checkout Response with Consent + ```json { "id": "checkout_456", diff --git a/docs/specification/cart-mcp.md b/docs/specification/cart-mcp.md index b537883c6..b48c0c5fa 100644 --- a/docs/specification/cart-mcp.md +++ b/docs/specification/cart-mcp.md @@ -26,6 +26,7 @@ This document specifies the Model Context Protocol (MCP) binding for the Businesses advertise MCP transport availability through their UCP profile at `/.well-known/ucp`. + ```json { "ucp": { @@ -66,6 +67,7 @@ Businesses advertise MCP transport availability through their UCP profile at MCP clients **MUST** include a `meta` object in every request containing protocol metadata: + ```json { "jsonrpc": "2.0", @@ -127,6 +129,7 @@ Maps to the [Create Cart](cart.md#create-cart) operation. === "Request" + ```json { "jsonrpc": "2.0", @@ -162,6 +165,7 @@ Maps to the [Create Cart](cart.md#create-cart) operation. === "Response" + ```json { "jsonrpc": "2.0", @@ -221,6 +225,7 @@ Maps to the [Create Cart](cart.md#create-cart) operation. All items out of stock — no cart resource is created: + ```json { "jsonrpc": "2.0", @@ -261,6 +266,7 @@ Maps to the [Get Cart](cart.md#get-cart) operation. === "Request" + ```json { "jsonrpc": "2.0", @@ -282,6 +288,7 @@ Maps to the [Get Cart](cart.md#get-cart) operation. === "Response" + ```json { "jsonrpc": "2.0", @@ -339,6 +346,7 @@ Maps to the [Get Cart](cart.md#get-cart) operation. === "Not Found" + ```json { "jsonrpc": "2.0", @@ -391,6 +399,7 @@ Maps to the [Update Cart](cart.md#update-cart) operation. === "Request" + ```json { "jsonrpc": "2.0", @@ -433,6 +442,7 @@ Maps to the [Update Cart](cart.md#update-cart) operation. === "Response" + ```json { "jsonrpc": "2.0", @@ -517,6 +527,7 @@ Maps to the [Cancel Cart](cart.md#cancel-cart) operation. === "Request" + ```json { "jsonrpc": "2.0", @@ -539,6 +550,7 @@ Maps to the [Cancel Cart](cart.md#cancel-cart) operation. === "Response" + ```json { "jsonrpc": "2.0", @@ -611,6 +623,7 @@ Business outcomes (including not found and validation errors) are returned as JSON-RPC `result` with `structuredContent` containing the UCP envelope and `messages`: + ```json { "jsonrpc": "2.0", diff --git a/docs/specification/cart-rest.md b/docs/specification/cart-rest.md index c85baa291..8d3e60dd1 100644 --- a/docs/specification/cart-rest.md +++ b/docs/specification/cart-rest.md @@ -25,6 +25,7 @@ This document specifies the REST binding for the [Cart Capability](cart.md). Businesses advertise REST transport availability through their UCP profile at `/.well-known/ucp`. + ```json { "ucp": { @@ -101,6 +102,7 @@ All REST endpoints **MUST** be served over HTTPS with minimum TLS version 1.3. === "Request" + ```json POST /carts HTTP/1.1 UCP-Agent: profile="https://platform.example/profile" @@ -125,6 +127,7 @@ All REST endpoints **MUST** be served over HTTPS with minimum TLS version 1.3. === "Response" + ```json HTTP/1.1 201 Created Content-Type: application/json @@ -174,6 +177,7 @@ All REST endpoints **MUST** be served over HTTPS with minimum TLS version 1.3. All items out of stock — no cart resource is created: + ```json HTTP/1.1 200 OK Content-Type: application/json @@ -206,6 +210,7 @@ All REST endpoints **MUST** be served over HTTPS with minimum TLS version 1.3. === "Request" + ```json GET /carts/{id} HTTP/1.1 UCP-Agent: profile="https://platform.example/profile" @@ -213,6 +218,7 @@ All REST endpoints **MUST** be served over HTTPS with minimum TLS version 1.3. === "Response" + ```json HTTP/1.1 200 OK Content-Type: application/json @@ -259,6 +265,7 @@ All REST endpoints **MUST** be served over HTTPS with minimum TLS version 1.3. === "Not Found" + ```json HTTP/1.1 200 OK Content-Type: application/json @@ -299,6 +306,7 @@ All REST endpoints **MUST** be served over HTTPS with minimum TLS version 1.3. === "Request" + ```json PUT /carts/{id} HTTP/1.1 UCP-Agent: profile="https://platform.example/profile" @@ -332,6 +340,7 @@ All REST endpoints **MUST** be served over HTTPS with minimum TLS version 1.3. === "Response" + ```json HTTP/1.1 200 OK Content-Type: application/json @@ -403,6 +412,7 @@ All REST endpoints **MUST** be served over HTTPS with minimum TLS version 1.3. === "Request" + ```json POST /carts/{id}/cancel HTTP/1.1 UCP-Agent: profile="https://platform.example/profile" @@ -413,6 +423,7 @@ All REST endpoints **MUST** be served over HTTPS with minimum TLS version 1.3. === "Response" + ```json HTTP/1.1 200 OK Content-Type: application/json @@ -507,6 +518,7 @@ code registry and transport binding examples. Business outcomes (including not found and validation errors) are returned with HTTP 200 and the UCP envelope containing `messages`: + ```json { "ucp": { diff --git a/docs/specification/cart.md b/docs/specification/cart.md index 08748a40d..97372d95b 100644 --- a/docs/specification/cart.md +++ b/docs/specification/cart.md @@ -56,6 +56,7 @@ When the cart capability is negotiated, platforms can convert a cart to checkout by providing `cart_id` in the Create Checkout request. The cart contents (`line_items`, `context`, `buyer`) initialize the checkout session. + ```json { "cart_id": "cart_abc123", @@ -133,6 +134,7 @@ error response instead of creating a cart resource. `ucp.status` is the primary discriminator; the absence of `id` is a consistent secondary indicator: + ```json { "ucp": { "version": "2026-01-15", "status": "error" }, diff --git a/docs/specification/catalog/index.md b/docs/specification/catalog/index.md index f600f1f9c..a28cf85e6 100644 --- a/docs/specification/catalog/index.md +++ b/docs/specification/catalog/index.md @@ -181,6 +181,7 @@ rendering contract. When search finds no matches, return an empty array without messages. + ```json { "ucp": {...}, @@ -195,6 +196,7 @@ This is not an error - the query was valid but returned no results. When a product is available but has delayed fulfillment, return the product with a warning message. Use the `path` field to target specific variants. + ```json { "ucp": {...}, @@ -238,6 +240,7 @@ When requested identifiers don't exist, return success with the found products (if any). The response MAY include informational messages indicating which identifiers were not found. + ```json { "ucp": {...}, @@ -262,6 +265,7 @@ return it as a warning with `presentation: "disclosure"`. The `path` field targe relevant component in the response — when it targets a product, the disclosure applies to all of its variants. + ```json { "ucp": {...}, diff --git a/docs/specification/catalog/mcp.md b/docs/specification/catalog/mcp.md index 3fa331709..19cf264fe 100644 --- a/docs/specification/catalog/mcp.md +++ b/docs/specification/catalog/mcp.md @@ -26,6 +26,7 @@ This document specifies the Model Context Protocol (MCP) binding for the Businesses advertise MCP transport availability through their UCP profile at `/.well-known/ucp`. + ```json { "ucp": { @@ -62,6 +63,7 @@ Businesses advertise MCP transport availability through their UCP profile at MCP clients **MUST** include a `meta` object in every request containing protocol metadata: + ```json { "jsonrpc": "2.0", @@ -118,6 +120,7 @@ Maps to the [Catalog Search](search.md) capability. === "Request" + ```json { "jsonrpc": "2.0", @@ -155,6 +158,7 @@ Maps to the [Catalog Search](search.md) capability. === "Response" + ```json { "jsonrpc": "2.0", @@ -276,6 +280,7 @@ The `catalog.ids` parameter accepts an array of identifiers and optional context === "Request" + ```json { "jsonrpc": "2.0", @@ -302,6 +307,7 @@ The `catalog.ids` parameter accepts an array of identifiers and optional context === "Response" + ```json { "jsonrpc": "2.0", @@ -397,6 +403,7 @@ The `catalog.ids` parameter accepts an array of identifiers and optional context When some identifiers are not found, the response includes the found products. The response MAY include informational messages indicating which identifiers were not found. + ```json { "jsonrpc": "2.0", @@ -460,6 +467,7 @@ Maps to the [Catalog Lookup](lookup.md#get-product-get_product) capability. Retu === "Request" + ```json { "jsonrpc": "2.0", @@ -490,6 +498,7 @@ Maps to the [Catalog Lookup](lookup.md#get-product-get_product) capability. Retu === "Response" + ```json { "jsonrpc": "2.0", @@ -589,6 +598,7 @@ When the identifier does not resolve to a product, the server returns a successful JSON-RPC result with `ucp.status: "error"` and a descriptive message. This is an application outcome, not a transport error. + ```json { "jsonrpc": "2.0", @@ -640,6 +650,7 @@ When all requested identifiers fail to resolve, the response contains an empty ` array. The response MAY include informational messages indicating which identifiers were not found. + ```json { "jsonrpc": "2.0", diff --git a/docs/specification/catalog/rest.md b/docs/specification/catalog/rest.md index 84c792897..6d0ed03bf 100644 --- a/docs/specification/catalog/rest.md +++ b/docs/specification/catalog/rest.md @@ -26,6 +26,7 @@ This document specifies the HTTP/REST binding for the Businesses advertise REST transport availability through their UCP profile at `/.well-known/ucp`. + ```json { "ucp": { @@ -75,6 +76,7 @@ Maps to the [Catalog Search](search.md) capability. === "Request" + ```json { "query": "blue running shoes", @@ -97,6 +99,7 @@ Maps to the [Catalog Search](search.md) capability. === "Response" + ```json { "ucp": { @@ -203,6 +206,7 @@ applies to all lookups in the batch. === "Request" + ```json POST /catalog/lookup HTTP/1.1 Host: business.example.com @@ -219,6 +223,7 @@ applies to all lookups in the batch. === "Response" + ```json { "ucp": { @@ -289,6 +294,7 @@ messages indicating which identifiers were not found. === "Request" + ```json { "ids": ["prod_abc123", "prod_invalid", "prod_def456"] @@ -297,6 +303,7 @@ messages indicating which identifiers were not found. === "Response" + ```json { "ucp": { @@ -349,6 +356,7 @@ on option values and returns variants matching the selection. === "Request" + ```json POST /catalog/product HTTP/1.1 Host: business.example.com @@ -368,6 +376,7 @@ on option values and returns variants matching the selection. === "Response" + ```json { "ucp": { @@ -466,6 +475,7 @@ with `ucp.status: "error"` and a descriptive message. This is an application outcome, not a transport error — the handler executed and reports its result via the UCP envelope. + ```json { "ucp": { @@ -519,6 +529,7 @@ for message semantics and common scenarios. When all requested identifiers fail lookup, the `products` array is empty. The response MAY include informational messages indicating which identifiers were not found. + ```json { "ucp": { diff --git a/docs/specification/checkout-a2a.md b/docs/specification/checkout-a2a.md index b6f7d6a27..e890d2dbb 100644 --- a/docs/specification/checkout-a2a.md +++ b/docs/specification/checkout-a2a.md @@ -25,6 +25,7 @@ Businesses that support A2A transport must specify the agent card endpoint as part of `services` in UCP Profile at `/.well-known/ucp`. This allows capable platforms to interact with the business services over A2A Protocol. + ```json { "ucp": { @@ -48,6 +49,7 @@ platforms to interact with the business services over A2A Protocol. Shopping platforms interacting with the business agent must send their profile URI as `UCP-Agent` request headers with every request. + ```json UCP-Agent: profile="https://agent.example/profiles/v2025-11/shopping-agent.json" Content-Type: application/json @@ -80,6 +82,7 @@ extension. An example: + ```json { "extensions": [ @@ -154,6 +157,7 @@ Examples: - Natural language input + ```json { "message": { @@ -173,6 +177,7 @@ Examples: - Structured inputs on user actions + ```json { "message": { @@ -197,6 +202,7 @@ Examples: **Response format:** Following is an example response from a business agent implementing Checkout functionality: + ```json { "id": 33, @@ -232,6 +238,7 @@ checkout object containing an `order` attribute with `id` and `permalink_url`. ### Request format + ```json { "message": { @@ -265,6 +272,7 @@ checkout object containing an `order` attribute with `id` and `permalink_url`. **Response format:** Following is an example response from a business agent implementing Checkout functionality: + ```json { "id": 33, @@ -302,6 +310,7 @@ part of the `DataPart` as `ap2.merchant_authorization`. This will allow the platform to cryptographically verify the checkout payload against the business's public keys. + ```json { "id": 33, @@ -340,6 +349,7 @@ verification and processing of the mandates to complete the checkout. ### Request format + ```json { "message": { diff --git a/docs/specification/checkout-mcp.md b/docs/specification/checkout-mcp.md index 3fcea71a8..ca415621a 100644 --- a/docs/specification/checkout-mcp.md +++ b/docs/specification/checkout-mcp.md @@ -26,6 +26,7 @@ This document specifies the Model Context Protocol (MCP) binding for the Businesses advertise MCP transport availability through their UCP profile at `/.well-known/ucp`. + ```json { "ucp": { @@ -81,6 +82,7 @@ Businesses advertise MCP transport availability through their UCP profile at MCP clients **MUST** include a `meta` object in every request containing protocol metadata: + ```json { "jsonrpc": "2.0", @@ -152,6 +154,7 @@ Maps to the [Create Checkout](checkout.md#create-checkout) operation. === "Request" + ```json { "jsonrpc": "2.0", @@ -204,6 +207,7 @@ Maps to the [Create Checkout](checkout.md#create-checkout) operation. === "Response" + ```json { "jsonrpc": "2.0", @@ -344,6 +348,7 @@ Maps to the [Create Checkout](checkout.md#create-checkout) operation. All items out of stock — no checkout resource is created: + ```json { "jsonrpc": "2.0", @@ -403,6 +408,7 @@ Maps to the [Update Checkout](checkout.md#update-checkout) operation. === "Request" + ```json { "jsonrpc": "2.0", @@ -454,6 +460,7 @@ Maps to the [Update Checkout](checkout.md#update-checkout) operation. === "Response" + ```json { "jsonrpc": "2.0", @@ -641,6 +648,7 @@ Business outcomes (including errors like unavailable merchandise) are returned as JSON-RPC `result` with `structuredContent` containing the UCP envelope and `messages`: + ```json { "jsonrpc": "2.0", @@ -684,6 +692,7 @@ as JSON-RPC `result` with `structuredContent` containing the UCP envelope and For `create_checkout`, when all items unavailable and no checkout can be created, JSON-RPC `result` with `structuredContent` containing the UCP envelope and `messages`: + ```json { "jsonrpc": "2.0", @@ -815,6 +824,7 @@ transformation: **Example:** Given the `complete_checkout` operation defined in OpenRPC: + ```json { "method": "complete_checkout", @@ -831,6 +841,7 @@ transformation: Implementers **MUST** expose this as an MCP `tools/call` endpoint: + ```json { "jsonrpc": "2.0", diff --git a/docs/specification/checkout-rest.md b/docs/specification/checkout-rest.md index f1c34134e..bf57d61b8 100644 --- a/docs/specification/checkout-rest.md +++ b/docs/specification/checkout-rest.md @@ -57,6 +57,7 @@ All REST endpoints **MUST** be served over HTTPS with minimum TLS version === "Request" + ```json POST /checkout-sessions HTTP/1.1 UCP-Agent: profile="https://platform.example/profile" @@ -76,6 +77,7 @@ All REST endpoints **MUST** be served over HTTPS with minimum TLS version === "Response" + ```json HTTP/1.1 201 Created Content-Type: application/json @@ -170,6 +172,7 @@ All REST endpoints **MUST** be served over HTTPS with minimum TLS version All items out of stock — no checkout resource is created: + ```json HTTP/1.1 200 OK Content-Type: application/json @@ -198,6 +201,7 @@ so clients must include all previously set fields they wish to retain. === "Request" + ```json PUT /checkout-sessions/{id} HTTP/1.1 UCP-Agent: profile="https://platform.example/profile" @@ -224,6 +228,7 @@ so clients must include all previously set fields they wish to retain. === "Response" + ```json HTTP/1.1 200 OK Content-Type: application/json @@ -327,6 +332,7 @@ type & addresses. === "Request" + ```json PUT /checkout-sessions/{id} HTTP/1.1 UCP-Agent: profile="https://platform.example/profile" @@ -369,6 +375,7 @@ type & addresses. === "Response" + ```json HTTP/1.1 200 OK Content-Type: application/json @@ -527,6 +534,7 @@ Follow-up calls after initial `fulfillment` data to update selection. === "Request" + ```json PUT /checkout-sessions/{id} HTTP/1.1 UCP-Agent: profile="https://platform.example/profile" @@ -579,6 +587,7 @@ Follow-up calls after initial `fulfillment` data to update selection. === "Response" + ```json HTTP/1.1 200 OK Content-Type: application/json @@ -724,6 +733,7 @@ place to set these expectations via `messages`. === "Request" + ```json POST /checkout-sessions/{id}/complete UCP-Agent: profile="https://platform.example/profile" @@ -766,6 +776,7 @@ place to set these expectations via `messages`. === "Response" + ```json HTTP/1.1 200 OK Content-Type: application/json @@ -917,6 +928,7 @@ place to set these expectations via `messages`. === "Request" + ```json GET /checkout-sessions/{id} UCP-Agent: profile="https://platform.example/profile" @@ -927,6 +939,7 @@ place to set these expectations via `messages`. === "Response" + ```json HTTP/1.1 200 OK Content-Type: application/json @@ -1072,6 +1085,7 @@ place to set these expectations via `messages`. === "Request" + ```json POST /checkout-sessions/{id}/cancel UCP-Agent: profile="https://platform.example/profile" @@ -1082,6 +1096,7 @@ place to set these expectations via `messages`. === "Response" + ```json HTTP/1.1 200 OK Content-Type: application/json @@ -1279,23 +1294,23 @@ code registry and transport binding examples. Business outcomes (including errors like unavailable merchandise) are returned with HTTP 200 and the UCP envelope containing `messages`: + ```json { "ucp": { "version": "{{ ucp_version }}", + "status": "success", + "payment_handlers": { ... }, "capabilities": { "dev.ucp.shopping.checkout": [{"version": "{{ ucp_version }}"}] } }, "id": "checkout_abc123", "status": "incomplete", - "line_items": [ - { - "id": "item_456", - "quantity": 100, - "available_quantity": 12 - } - ], + "currency": "...", + "line_items": [ ... ], + "totals": [ ... ], + "links": [ ... ], "messages": [ { "type": "warning", @@ -1311,6 +1326,7 @@ with HTTP 200 and the UCP envelope containing `messages`: For `create_checkout`, when all items unavailable and no checkout can be created, returns HTTP 200 and the UCP envelope containing `messages` + ```json { "ucp": { "version": "2026-01-11", "status": "error" }, diff --git a/docs/specification/checkout.md b/docs/specification/checkout.md index 18cb2d134..e152e80e4 100644 --- a/docs/specification/checkout.md +++ b/docs/specification/checkout.md @@ -146,6 +146,7 @@ response body. When no resource exists to act on, messages SHOULD use For example, a business may reject a create checkout request where all items are unavailable: + ```json { "ucp": { "version": "2026-01-11", "status": "error" }, @@ -174,30 +175,28 @@ The latter two require handoff and serve as explicit signals to the platform. Businesses **SHOULD** surface such messages as early as possible, and platforms **SHOULD** prioritize resolving recoverable errors before initiating handoff. + ```json -{ - "status": "requires_escalation", - "messages": [ - { - "type": "error", - "code": "invalid_phone", - "severity": "recoverable", - "content": "Phone number format is invalid" - }, - { - "type": "error", - "code": "schedule_delivery", - "severity": "requires_buyer_input", - "content": "Select delivery window for your purchase" - }, - { - "type": "error", - "code": "high_value_order", - "severity": "requires_buyer_review", - "content": "Orders over $500 require additional verification" - } - ] -} +[ + { + "type": "error", + "code": "invalid_phone", + "severity": "recoverable", + "content": "Phone number format is invalid" + }, + { + "type": "error", + "code": "schedule_delivery", + "severity": "requires_buyer_input", + "content": "Select delivery window for your purchase" + }, + { + "type": "error", + "code": "high_value_order", + "severity": "requires_buyer_review", + "content": "Orders over $500 require additional verification" + } +] ``` Example error processing algorithm: @@ -302,13 +301,16 @@ For example, the Platform claims a store card benefit via `context.eligibility`. The Business applies member pricing during the session. At completion, the payment credential does not match the claimed instrument: + ```json { - "ucp": { "version": "2026-01-11", "status": "success" }, + "ucp": { "version": "2026-01-11", "status": "success", "payment_handlers": { ... } }, "id": "checkout_abc", "status": "ready_for_complete", - "line_items": [ "..." ], - "totals": [ "..." ], + "currency": "...", + "line_items": [ ... ], + "totals": [ ... ], + "links": [ ... ], "messages": [ { "type": "error", @@ -409,21 +411,28 @@ what they receive from the business. A checkout response containing both a recoverable error and a disclosure warning on a line item: + ```json { - "ucp": { "version": "{{ ucp_version }}", "status": "success" }, + "ucp": { "version": "{{ ucp_version }}", "status": "success", "payment_handlers": { ... } }, "id": "chk_abc123", "status": "incomplete", "currency": "USD", "line_items": [ { "id": "li_1", - "item": { "id": "item_456", "title": "Artisan Nut Butter Collection", "image_url": "https://merchant.com/nut-butter.jpg" }, + "item": { "id": "item_456", "title": "Artisan Nut Butter Collection", "price": 1299, "image_url": "https://merchant.com/nut-butter.jpg" }, "quantity": 1, - "totals": [{ "type": "subtotal", "amount": 1299 }] + "totals": [ + { "type": "subtotal", "amount": 1299 }, + { "type": "total", "amount": 1299 } + ] } ], - "totals": [{ "type": "total", "amount": 1299 }], + "totals": [ + { "type": "subtotal", "amount": 1299 }, + { "type": "total", "amount": 1299 } + ], "messages": [ { "type": "error", @@ -834,8 +843,9 @@ when provided. **Split tax, itemized at top-level:** + ```json -"totals": [ +[ { "type": "subtotal", "display_text": "Subtotal", "amount": 5750 }, { "type": "fulfillment", "display_text": "Shipping", "amount": 899 }, { "type": "tax", "display_text": "Federal Tax", "amount": 332 }, @@ -846,8 +856,9 @@ when provided. **Collapsed fees with optional breakdown:** + ```json -"totals": [ +[ { "type": "subtotal", "display_text": "Subtotal", "amount": 4999 }, { "type": "fee", "display_text": "Fees", "amount": 549, @@ -863,8 +874,9 @@ when provided. **Discount and account credit — negative amounts:** + ```json -"totals": [ +[ { "type": "subtotal", "display_text": "Subtotal", "amount": 10000 }, { "type": "discount", "display_text": "Summer Sale", "amount": -1500 }, { "type": "tax", "display_text": "Tax", "amount": 680 }, diff --git a/docs/specification/discount.md b/docs/specification/discount.md index 5dcc976a6..55d810ef5 100644 --- a/docs/specification/discount.md +++ b/docs/specification/discount.md @@ -38,6 +38,7 @@ to be shared between the platform and the business. Businesses advertise discount support in their profile. The capability can extend cart, checkout, or both: + ```json { "ucp": { @@ -151,6 +152,7 @@ standard rejection codes. When a submitted discount code cannot be applied, businesses communicate this via the `messages[]` array: + ```json { "messages": [ @@ -238,6 +240,7 @@ stacking and allocation details: === "Request" + ```json { "context": { @@ -245,11 +248,8 @@ stacking and allocation details: }, "line_items": [ { - "item": { - "id": "prod_shirt", - "quantity": 2, - "price": 2500 - } + "item": { "id": "prod_shirt" }, + "quantity": 2 } ] } @@ -257,8 +257,13 @@ stacking and allocation details: === "Response" + ```json { + "ucp": { ... }, + "id": "...", + "currency": "...", + "line_items": [ ... ], "discounts": { "applied": [ { @@ -325,16 +330,13 @@ proceeding to checkout. === "Request" + ```json { "line_items": [ { - "item": { - "id": "prod_1", - "quantity": 2, - "title": "T-Shirt", - "price": 2000 - } + "item": { "id": "prod_1" }, + "quantity": 2 } ], "discounts": { @@ -345,18 +347,17 @@ proceeding to checkout. === "Response" + ```json { + "ucp": { ... }, "id": "cart_abc123", + "currency": "USD", "line_items": [ { "id": "li_1", - "item": { - "id": "prod_1", - "quantity": 2, - "title": "T-Shirt", - "price": 2000 - }, + "item": { "id": "prod_1", "title": "T-Shirt", "price": 2000 }, + "quantity": 2, "totals": [ {"type": "subtotal", "amount": 4000}, {"type": "items_discount", "amount": -800}, @@ -378,7 +379,6 @@ proceeding to checkout. } ] }, - "currency": "USD", "totals": [ {"type": "subtotal", "display_text": "Subtotal", "amount": 4000}, {"type": "items_discount", "display_text": "Item Discounts", "amount": -800}, @@ -394,8 +394,11 @@ to the order as a whole and uses `type: "discount"` in totals. === "Request" + ```json { + "id": "...", + "line_items": [ ... ], "discounts": { "codes": ["SAVE10"] } @@ -404,8 +407,13 @@ to the order as a whole and uses `type: "discount"` in totals. === "Response" + ```json { + "ucp": { ... }, + "id": "...", + "currency": "...", + "line_items": [ ... ], "discounts": { "codes": ["SAVE10"], "applied": [ @@ -431,8 +439,11 @@ to line items, and an automatic shipping discount at the order level. === "Request" + ```json { + "id": "...", + "line_items": [ ... ], "discounts": { "codes": ["SUMMER20"] } @@ -441,17 +452,17 @@ to line items, and an automatic shipping discount at the order level. === "Response" + ```json { + "ucp": { ... }, + "id": "...", + "currency": "...", "line_items": [ { "id": "li_1", - "item": { - "id": "prod_1", - "quantity": 2, - "title": "T-Shirt", - "price": 2000 - }, + "item": { "id": "prod_1", "title": "T-Shirt", "price": 2000 }, + "quantity": 2, "totals": [ {"type": "subtotal", "amount": 4000}, {"type": "items_discount", "amount": -800}, @@ -495,8 +506,11 @@ but not in `discounts.applied`. === "Request" + ```json { + "id": "...", + "line_items": [ ... ], "discounts": { "codes": ["SAVE10", "EXPIRED50"] } @@ -505,8 +519,13 @@ but not in `discounts.applied`. === "Response" + ```json { + "ucp": { ... }, + "id": "...", + "currency": "...", + "line_items": [ ... ], "discounts": { "codes": ["SAVE10", "EXPIRED50"], "applied": [ @@ -539,15 +558,17 @@ Multiple discounts applied with full allocation breakdown: === "Response" + ```json { + "ucp": { ... }, + "id": "...", + "currency": "...", "line_items": [ { "id": "li_1", - "item": { - "title": "T-Shirt", - "price": 6000 - }, + "item": { "id": "prod_1", "title": "T-Shirt", "price": 6000 }, + "quantity": 1, "totals": [ {"type": "subtotal", "amount": 6000}, {"type": "items_discount", "amount": -1500}, @@ -556,10 +577,8 @@ Multiple discounts applied with full allocation breakdown: }, { "id": "li_2", - "item": { - "title": "Socks", - "price": 4000 - }, + "item": { "id": "prod_2", "title": "Socks", "price": 4000 }, + "quantity": 1, "totals": [ {"type": "subtotal", "amount": 4000}, {"type": "items_discount", "amount": -1000}, diff --git a/docs/specification/embedded-cart.md b/docs/specification/embedded-cart.md index 8ed212e65..6815af46a 100644 --- a/docs/specification/embedded-cart.md +++ b/docs/specification/embedded-cart.md @@ -50,6 +50,7 @@ the `embedded` transport in their `/.well-known/ucp` profile, all cart **Service Discovery Example:** + ```json { "services": { @@ -88,6 +89,7 @@ indicate ECaP availability and allowed delegations for a specific session. **Cart Response Example:** + ```json { "id": "cart_123", @@ -217,6 +219,7 @@ any requested authorization data back to Embedded Cart. **Example Message (no delegations accepted):** + ```json { "jsonrpc": "2.0", @@ -252,6 +255,7 @@ to complete the handshake. **Example Message:** + ```json { "jsonrpc": "2.0", @@ -271,6 +275,7 @@ on the host's `iframe.contentWindow.postMessage()` call): **Example Message:** + ```json { "jsonrpc": "2.0", @@ -330,6 +335,7 @@ Signals that cart is visible and ready for interaction. Sent after a successful **Example Message:** + ```json { "jsonrpc": "2.0", @@ -364,6 +370,7 @@ proceed to initiate a checkout session based on the completed cart by issuing a **Example Message:** + ```json { "jsonrpc": "2.0", @@ -399,6 +406,7 @@ Line items have been modified (quantity changed, items added/removed). **Example Message:** + ```json { "jsonrpc": "2.0", @@ -430,6 +438,7 @@ Buyer information has been updated (email, phone, name). **Example Message:** + ```json { "jsonrpc": "2.0", @@ -459,6 +468,7 @@ informational notices about the cart state. **Example Message:** + ```json { "jsonrpc": "2.0", diff --git a/docs/specification/embedded-checkout.md b/docs/specification/embedded-checkout.md index c3a71cd70..e630c2ede 100644 --- a/docs/specification/embedded-checkout.md +++ b/docs/specification/embedded-checkout.md @@ -90,6 +90,7 @@ profile, they declare support for the Embedded Checkout Protocol. **Service Discovery Example:** + ```json { "services": { @@ -128,6 +129,7 @@ indicate ECP availability and allowed delegations for a specific session. **Checkout Response Example:** + ```json { "id": "checkout_abc123", @@ -214,6 +216,7 @@ parameters from business-specific query parameters: **Example (Informative - JWT-based):** + ```json // One possible implementation using JWT { @@ -483,6 +486,7 @@ checkout that was not communicated over UCP checkout actions. **Example Message (no delegations accepted):** + ```json { "jsonrpc": "2.0", @@ -499,6 +503,7 @@ checkout that was not communicated over UCP checkout actions. **Example Message (delegations accepted):** + ```json { "jsonrpc": "2.0", @@ -542,6 +547,7 @@ to complete the handshake. **Example Message:** + ```json { "jsonrpc": "2.0", @@ -561,6 +567,7 @@ on the host's `iframe.contentWindow.postMessage()` call): **Example Message:** + ```json { "jsonrpc": "2.0", @@ -587,6 +594,7 @@ business. **Example Message: Providing payment instruments, including display information:** + ```json { "jsonrpc": "2.0", @@ -623,6 +631,7 @@ information:** If the host cannot complete the handshake (e.g., origin validation failure or protocol state violation), it **MUST** respond with an `error_response` result: + ```json { "jsonrpc": "2.0", @@ -679,6 +688,7 @@ successful `ec.ready` handshake. **Example Message:** + ```json { "jsonrpc": "2.0", @@ -718,6 +728,7 @@ Indicates successful checkout completion. **Example Message:** + ```json { "jsonrpc": "2.0", @@ -753,6 +764,7 @@ Line items have been modified (quantity changed, items added/removed). **Example Message:** + ```json { "jsonrpc": "2.0", @@ -784,6 +796,7 @@ Buyer information has been updated (email, phone, address). **Example Message:** + ```json { "jsonrpc": "2.0", @@ -813,6 +826,7 @@ informational notices about the checkout state. **Example Message:** + ```json { "jsonrpc": "2.0", @@ -859,6 +873,7 @@ When a change also triggers a domain-specific message (e.g., **Example Message:** + ```json { "jsonrpc": "2.0", @@ -963,6 +978,7 @@ checkout UI, such as a new payment method being selected. **Example Message:** + ```json { "jsonrpc": "2.0", @@ -997,6 +1013,7 @@ Requests the host to present payment instrument selection UI. **Example Message:** + ```json { "jsonrpc": "2.0", @@ -1030,6 +1047,7 @@ existing state. **Example Success Response:** + ```json { "jsonrpc": "2.0", @@ -1064,6 +1082,7 @@ existing state. **Example Error Response:** + ```json { "jsonrpc": "2.0", @@ -1094,6 +1113,7 @@ submission. **Example Message:** + ```json { "jsonrpc": "2.0", @@ -1134,6 +1154,7 @@ new data with existing state. **Example Success Response:** + ```json { "jsonrpc": "2.0", @@ -1171,6 +1192,7 @@ new data with existing state. **Example Error Response:** + ```json { "jsonrpc": "2.0", @@ -1247,6 +1269,7 @@ UI. **Example Message:** + ```json { "jsonrpc": "2.0", @@ -1276,6 +1299,7 @@ method. **Example Message:** + ```json { "jsonrpc": "2.0", @@ -1324,6 +1348,7 @@ rather than attempting to merge the new data with existing state. **Example Success Response:** + ```json { "jsonrpc": "2.0", @@ -1355,6 +1380,7 @@ rather than attempting to merge the new data with existing state. **Example Error Response:** + ```json { "jsonrpc": "2.0", @@ -1444,6 +1470,7 @@ Requests the host to handle a link activated by the buyer within the checkout. **Example Message:** + ```json { "jsonrpc": "2.0", @@ -1462,6 +1489,7 @@ Requests the host to handle a link activated by the buyer within the checkout. **Example Success Response:** + ```json { "jsonrpc": "2.0", @@ -1474,6 +1502,7 @@ Requests the host to handle a link activated by the buyer within the checkout. **Example Error Response:** + ```json { "jsonrpc": "2.0", diff --git a/docs/specification/embedded-protocol.md b/docs/specification/embedded-protocol.md index e8717f124..844dd59aa 100644 --- a/docs/specification/embedded-protocol.md +++ b/docs/specification/embedded-protocol.md @@ -93,6 +93,7 @@ application-level error codes. **Success Response:** + ```json { "jsonrpc": "2.0", @@ -106,6 +107,7 @@ application-level error codes. **Error Response:** + ```json { "jsonrpc": "2.0", @@ -132,6 +134,7 @@ registry. For example, if a request cannot be processed (unknown method, malformed params), the host **MUST** respond with a JSON-RPC `error`: + ```json { "jsonrpc": "2.0", @@ -252,6 +255,7 @@ data or an `error_response`. **Example Success Response:** + ```json { "jsonrpc": "2.0", @@ -265,6 +269,7 @@ data or an `error_response`. **Example Error Response:** + ```json { "jsonrpc": "2.0", @@ -295,6 +300,7 @@ the credential is corrupted). The session error **SHOULD** include a **Example — auth failure escalated to session error:** + ```json { "jsonrpc": "2.0", @@ -335,6 +341,7 @@ continuing. Each capability defines its own session error notification method **Example:** + ```json { "jsonrpc": "2.0", @@ -377,6 +384,7 @@ Both are notifications — the host **MUST NOT** respond. **Example — start notification (cart):** + ```json { "jsonrpc": "2.0", @@ -395,6 +403,7 @@ Both are notifications — the host **MUST NOT** respond. **Example — complete notification (checkout):** + ```json { "jsonrpc": "2.0", @@ -426,6 +435,7 @@ resource, not just the changed fields. **Example — line items changed (checkout):** + ```json { "jsonrpc": "2.0", @@ -443,6 +453,7 @@ resource, not just the changed fields. **Example — messages changed (cart):** + ```json { "jsonrpc": "2.0", diff --git a/docs/specification/examples/encrypted-credential-handler.md b/docs/specification/examples/encrypted-credential-handler.md index 97b175fb5..880f6dccf 100644 --- a/docs/specification/examples/encrypted-credential-handler.md +++ b/docs/specification/examples/encrypted-credential-handler.md @@ -141,6 +141,7 @@ have their own compliance requirements. #### Example Business Handler Declaration + ```json { "ucp": { @@ -185,6 +186,7 @@ The response config includes information about the encryption used. #### Example Response Config + ```json { "id": "platform_encrypted", @@ -251,6 +253,7 @@ registry using `platform_config`. #### Example Platform Handler Declaration + ```json { "ucp": { @@ -302,6 +305,7 @@ access to raw PANs. Platform application submits the checkout with the encrypted credential (received from its vaulting service): + ```json POST /checkout-sessions/{checkout_id}/complete UCP-Agent: profile="https://platform.example/profile" diff --git a/docs/specification/examples/platform-tokenizer-payment-handler.md b/docs/specification/examples/platform-tokenizer-payment-handler.md index 62bbc41e6..663a399ce 100644 --- a/docs/specification/examples/platform-tokenizer-payment-handler.md +++ b/docs/specification/examples/platform-tokenizer-payment-handler.md @@ -192,6 +192,7 @@ credential type (e.g., PCI DSS for cards). #### Example Business Handler Declaration + ```json { "ucp": { @@ -234,6 +235,7 @@ The response config includes runtime token lifecycle information. #### Example Response Config + ```json { "id": "platform_wallet", @@ -268,6 +270,7 @@ For option B, see section [PSP Integration](#psp-integration). #### Detokenize Request Example (Business) + ```json POST https://provider.platform.example.com/ucp/detokenize Content-Type: application/json @@ -323,6 +326,7 @@ registry using `platform_config`. #### Example Platform Handler Declaration + ```json { "ucp": { @@ -373,6 +377,7 @@ access to sensitive instrument details. The platform application submits the checkout with the token (received from its payment credential provider): + ```json POST /checkout-sessions/{checkout_id}/complete Content-Type: application/json @@ -441,6 +446,7 @@ When the business forwards a token to the PSP: #### Detokenize Request Example (PSP) + ```json POST https://provider.platform.example.com/ucp/detokenize Content-Type: application/json diff --git a/docs/specification/examples/processor-tokenizer-payment-handler.md b/docs/specification/examples/processor-tokenizer-payment-handler.md index d48063909..669056e82 100644 --- a/docs/specification/examples/processor-tokenizer-payment-handler.md +++ b/docs/specification/examples/processor-tokenizer-payment-handler.md @@ -107,6 +107,7 @@ The handler's specification (referenced via the `spec` field) documents the #### Example Business Handler Declaration + ```json { "ucp": { @@ -148,6 +149,7 @@ The response config includes runtime information about what's available for this #### Example Response Config + ```json { "id": "processor_tokenizer", @@ -193,6 +195,7 @@ the specific `endpoint` defined in the handler configuration. Platform identifies the processor tokenizer handler and retrieves the business's configuration. + ```json { "ucp": { @@ -230,6 +233,7 @@ credential provider **MUST** inject it into the `binding` object. Response: + ```json { "token": "tok_a1b2c3d4e5f6" @@ -240,6 +244,7 @@ Response: The Platform submits the token. + ```json POST /checkout-sessions/{checkout_id}/complete UCP-Agent: profile="https://platform.example/profile" diff --git a/docs/specification/fulfillment.md b/docs/specification/fulfillment.md index b511f1652..dc0c2b01b 100644 --- a/docs/specification/fulfillment.md +++ b/docs/specification/fulfillment.md @@ -98,8 +98,16 @@ method. ### Example + ```json { + "ucp": { ... }, + "id": "...", + "status": "...", + "currency": "...", + "line_items": [ ... ], + "totals": [ ... ], + "links": [ ... ], "fulfillment": { "methods": [ { @@ -223,8 +231,16 @@ method, and when. Use cases: * **Alternative methods**: "These pants are also available for pickup at Downtown Store" * **Fulfill later**: Preorders, items shipping from a distant warehouse, pickup when store gets inventory + ```json { + "ucp": { ... }, + "id": "...", + "status": "...", + "currency": "...", + "line_items": [ ... ], + "totals": [ ... ], + "links": [ ... ], "fulfillment": { "methods": [ { @@ -280,6 +296,7 @@ single-group responses. The response shape is always `methods[].groups[]`—the difference is whether `groups.length` can exceed 1 within each method. + ```json // Default: single group per method { "dev.ucp.shopping.fulfillment": [{"version": "{{ ucp_version }}"}] } @@ -295,6 +312,7 @@ Businesses declare what fulfillment configurations they support using {{ schema_fields('types/merchant_fulfillment_config', 'fulfillment') }} + ```json { "capabilities": { @@ -351,8 +369,16 @@ so no extension needed. **Config:** None required (default behavior) + ```json { + "ucp": { ... }, + "id": "...", + "status": "...", + "currency": "...", + "line_items": [ ... ], + "totals": [ ... ], + "links": [ ... ], "fulfillment": { "methods": [ { @@ -414,8 +440,16 @@ so no extension needed. Business splits items into multiple packages; buyer selects shipping rate per package. + ```json { + "ucp": { ... }, + "id": "...", + "status": "...", + "currency": "...", + "line_items": [ ... ], + "totals": [ ... ], + "links": [ ... ], "fulfillment": { "methods": [ { @@ -483,8 +517,16 @@ package. Shirt ships to mom (US), pants ship to grandma (Hong Kong). Two methods of the same type, each with its own destination. + ```json { + "ucp": { ... }, + "id": "...", + "status": "...", + "currency": "...", + "line_items": [ ... ], + "totals": [ ... ], + "links": [ ... ], "fulfillment": { "methods": [ { diff --git a/docs/specification/identity-linking.md b/docs/specification/identity-linking.md index c78c7ce30..6188d8d8d 100644 --- a/docs/specification/identity-linking.md +++ b/docs/specification/identity-linking.md @@ -137,6 +137,7 @@ Example of [metadata](https://datatracker.ietf.org/doc/html/rfc8414#section-2){t supposed to be hosted in /.well-known/oauth-authorization-server as per [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414){target="_blank"}: + ```json { "issuer": "https://merchant.example.com", diff --git a/docs/specification/order-mcp.md b/docs/specification/order-mcp.md index 049ed92a1..916523eaa 100644 --- a/docs/specification/order-mcp.md +++ b/docs/specification/order-mcp.md @@ -26,6 +26,7 @@ This document specifies the Model Context Protocol (MCP) binding for the Businesses advertise MCP transport availability through their UCP profile at `/.well-known/ucp`. + ```json { "ucp": { @@ -59,6 +60,7 @@ Businesses advertise MCP transport availability through their UCP profile at MCP clients **MUST** include a `meta` object in every request containing protocol metadata: + ```json { "jsonrpc": "2.0", @@ -108,6 +110,7 @@ current-state snapshot of an order. === "Request" + ```json { "jsonrpc": "2.0", @@ -129,6 +132,7 @@ current-state snapshot of an order. === "Response" + ```json { "jsonrpc": "2.0", @@ -207,6 +211,7 @@ current-state snapshot of an order. === "Not Found" + ```json { "jsonrpc": "2.0", @@ -243,6 +248,7 @@ current-state snapshot of an order. === "Not Authorized" + ```json { "jsonrpc": "2.0", diff --git a/docs/specification/order-rest.md b/docs/specification/order-rest.md index 18f0ad60e..4bfd6b359 100644 --- a/docs/specification/order-rest.md +++ b/docs/specification/order-rest.md @@ -25,6 +25,7 @@ This document specifies the REST binding for the [Order Capability](order.md). Businesses advertise REST transport availability through their UCP profile at `/.well-known/ucp`. + ```json { "ucp": { @@ -95,6 +96,7 @@ Returns the current-state snapshot of an order. === "Request" + ```json GET /orders/order_abc123 HTTP/1.1 UCP-Agent: profile="https://platform.example/.well-known/ucp" @@ -105,6 +107,7 @@ Returns the current-state snapshot of an order. === "Response" + ```json HTTP/1.1 200 OK Content-Type: application/json @@ -172,6 +175,7 @@ Returns the current-state snapshot of an order. === "Not Found" + ```json HTTP/1.1 200 OK Content-Type: application/json @@ -197,6 +201,7 @@ Returns the current-state snapshot of an order. === "Not Authorized" + ```json HTTP/1.1 200 OK Content-Type: application/json diff --git a/docs/specification/order.md b/docs/specification/order.md index 29e2dde5b..7b94ee2b8 100644 --- a/docs/specification/order.md +++ b/docs/specification/order.md @@ -116,6 +116,7 @@ Line items reflect what was purchased at checkout and their current state. **Quantity Structure:** + ```json { "original": 3, // Quantity from the original checkout @@ -165,6 +166,7 @@ Examples: `refund`, `return`, `credit`, `price_adjustment`, `dispute`, ## Example + ```json { "ucp": { @@ -325,6 +327,7 @@ that includes a `messages` array describing the outcome: **Order not found:** + ```json { "ucp": { @@ -347,6 +350,7 @@ that includes a `messages` array describing the outcome: **Not authorized:** + ```json { "ucp": { @@ -420,6 +424,7 @@ platform's profile and uses it to send order lifecycle events. **Example:** + ```json { "dev.ucp.shopping.order": [ diff --git a/docs/specification/overview.md b/docs/specification/overview.md index 1265aef6a..58e7dd24e 100644 --- a/docs/specification/overview.md +++ b/docs/specification/overview.md @@ -127,6 +127,7 @@ appended to this endpoint to form the complete URL. **Example:** + ```json { "version": "{{ ucp_version }}", @@ -164,6 +165,7 @@ functionality is supported and where to find documentation and schemas. An **extension** is an optional module that augments another capability. Extensions use the `extends` field to declare their parent(s): + ```json { "dev.ucp.shopping.fulfillment": [ @@ -181,6 +183,7 @@ Extensions use the `extends` field to declare their parent(s): Extensions **MAY** extend multiple parent capabilities by using an array: + ```json { "dev.ucp.shopping.discount": [ @@ -226,6 +229,7 @@ Extension schemas define composed types using `allOf`. The `$defs` key **MUST** use the full parent capability name (reverse-domain format) to enable deterministic schema resolution: + ```json { "$defs": { @@ -267,6 +271,7 @@ Extension schemas **SHOULD** declare a `requires` object (alongside `name`, `title`, `description`) to indicate the protocol and capability versions required for correct operation: + ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -293,6 +298,7 @@ Each constraint is an object with a required `min` (inclusive) and optional `max` (inclusive) version. When `max` is absent, there is no upper bound: + ```json "requires": { "protocol": { "min": "2026-01-23", "max": "2026-09-01" }, @@ -347,6 +353,7 @@ Platforms **MUST** resolve schemas following this sequence: Businesses publish their profile at `/.well-known/ucp`. An example: + ```json { "ucp": { @@ -466,6 +473,7 @@ requiring cryptographic verification. Capabilities **MAY** include a `config` object for capability-specific settings (e.g., callback URLs, feature flags). An example: + ```json { "ucp": { @@ -574,6 +582,7 @@ Content-Type: application/json **MCP Transport:** Platforms **MUST** include a `meta` object containing request metadata: + ```json { "jsonrpc": "2.0", @@ -800,6 +809,7 @@ task through the standard web interface. **Discovery Failure (JSON-RPC error):** + ```json { "jsonrpc": "2.0", @@ -818,6 +828,7 @@ task through the standard web interface. **Version Unsupported (JSON-RPC error):** + ```json { "jsonrpc": "2.0", @@ -836,6 +847,7 @@ task through the standard web interface. **Capabilities Incompatible (JSON-RPC result):** + ```json { "jsonrpc": "2.0", @@ -865,6 +877,7 @@ task through the standard web interface. **Protocol Error — Rate Limit (JSON-RPC error):** + ```json { "jsonrpc": "2.0", @@ -881,6 +894,7 @@ task through the standard web interface. **Protocol Error — Unauthorized (JSON-RPC error):** + ```json { "jsonrpc": "2.0", @@ -901,6 +915,7 @@ task through the standard web interface. The `capabilities` registry in responses indicates active capabilities: + ```json { "ucp": { @@ -1198,6 +1213,7 @@ an encrypted payment token. ##### 1. Business Advertisement (Response from Create Checkout) + ```json { "ucp": { @@ -1262,6 +1278,7 @@ respective handler API. The handler returns the encrypted token data. The Platform wraps the payment handler response into a payment instrument. + ```json POST /checkout-sessions/{id}/complete @@ -1310,6 +1327,7 @@ request a challenge. ##### 1. Business Advertisement + ```json { "ucp": { @@ -1347,6 +1365,7 @@ previous legal binding connection with them and receives `tok_visa_123` ##### 3. Complete Checkout (Request to Business) + ```json POST /checkout-sessions/{id}/complete @@ -1372,6 +1391,7 @@ POST /checkout-sessions/{id}/complete The business attempts the charge, but the PSP returns a "Soft Decline" requiring 3DS. + ```json HTTP/1.1 200 OK { @@ -1396,6 +1416,7 @@ session token, the agent generates cryptographic mandates. ##### 1. Business Advertisement + ```json { "ucp": { @@ -1423,6 +1444,7 @@ non-agentic surface. ##### 3. Complete Checkout + ```json POST /checkout-sessions/{id}/complete @@ -1577,6 +1599,7 @@ which operates over JSON-RPC. MCP requests use the `tools/call` method with the operation name in `params.name` and UCP payload in `params.arguments`: + ```json { "jsonrpc": "2.0", @@ -1603,6 +1626,7 @@ MCP servers: - **SHOULD** also return serialized JSON in `content[]` for backward compatibility with clients not supporting `structuredContent` + ```json { "jsonrpc": "2.0", @@ -1680,6 +1704,7 @@ prevent collisions when multiple extensions contribute to the shared namespace. Well-known signals use the `dev.ucp` namespace (e.g., `dev.ucp.buyer_ip`); extension signals use their own namespace (e.g., `com.example.device_id`). + ```json { "signals": { @@ -1705,6 +1730,7 @@ data. The `path` field identifies the requested signal; the message `type` determines enforcement. An `error` blocks status progression until the signal is provided; an `info` is advisory and non-blocking. + ```json { "messages": [ @@ -1758,6 +1784,7 @@ Both businesses and platforms declare a single version in their profiles: === "Business Profile" + ```json { "ucp": { @@ -1771,6 +1798,7 @@ Both businesses and platforms declare a single version in their profiles: === "Platform Profile" + ```json { "ucp": { @@ -1806,6 +1834,7 @@ version — including its own capabilities, services, payment handlers, and signing keys. When `supported_versions` is omitted, only `version` is supported. + ```json { "ucp": { @@ -1861,6 +1890,7 @@ every request: Response with version confirmation: + ```json { "ucp": { @@ -1876,6 +1906,7 @@ Response with version confirmation: Version unsupported error — no resource is created: + ```json { "ucp": { "version": "2026-01-11", "status": "error" }, diff --git a/docs/specification/payment-handler-guide.md b/docs/specification/payment-handler-guide.md index 46cafc7df..c186c1f72 100644 --- a/docs/specification/payment-handler-guide.md +++ b/docs/specification/payment-handler-guide.md @@ -168,6 +168,7 @@ schema. The specification **SHOULD** define the available config and instrument schemas, and how to construct each based on the business's prerequisites output and desired configuration. + ```json { "ucp": { @@ -213,6 +214,7 @@ and typically includes different configuration: **Business Schema Example** (business declares handler configuration): + ```json { "id": "processor_tokenizer_1234", @@ -236,6 +238,7 @@ and typically includes different configuration: **Platform Schema Example** (platform declares handler support): + ```json { "id": "platform_tokenizer_2345", // note: ids are for disambiguation, they may differ between business and platform @@ -259,6 +262,7 @@ and typically includes different configuration: **Response Schema Example** (runtime context for checkout): + ```json { "id": "processor_tokenizer_1234", @@ -327,6 +331,7 @@ Authors typically define each shape in its own file and reference them: **Example Handler Schema:** + ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -416,6 +421,7 @@ Each variant has its own config schema tailored to its context: **Example `types/business_config.json`:** + ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -438,6 +444,7 @@ Each variant has its own config schema tailored to its context: **Example `types/platform_config.json`:** + ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -460,6 +467,7 @@ Each variant has its own config schema tailored to its context: **Example `types/response_config.json`:** + ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -522,6 +530,7 @@ constraints are meaningful (e.g., `brands` for cards), and **platforms/businesse **Example `types/tokenizer_instrument.json`**: + ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -578,6 +587,7 @@ constraints are meaningful (e.g., `brands` for cards), and **platforms/businesse **Example `types/tokenizer_alt_instrument.json`:** + ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -629,6 +639,7 @@ refresh credentials. **Example `types/tokenizer_token.json`** (expiring token): + ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -656,6 +667,7 @@ refresh credentials. **Example `types/tokenizer_alt_token.json`** (alt token): + ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/docs/specification/payment-handler-template.md b/docs/specification/payment-handler-template.md index baed124c0..b06a498ac 100644 --- a/docs/specification/payment-handler-template.md +++ b/docs/specification/payment-handler-template.md @@ -133,6 +133,7 @@ for the full pattern. #### Example Handler Declaration + ```json { "ucp": { @@ -207,6 +208,7 @@ Platforms advertise support for this handler in their UCP profile's #### Example Platform Handler Declaration + ```json { "ucp": { @@ -245,6 +247,7 @@ Platforms **MUST** follow this flow to acquire a payment instrument: The Platform identifies `{handler_name}` in the business's UCP profile `payment_handlers` registry (from `/.well-known/ucp`). + ```json { "ucp": { @@ -284,6 +287,7 @@ The Platform identifies `{handler_name}` in the business's UCP profile The Platform submits the checkout with the constructed payment instrument. + ```json POST /checkout-sessions/{checkout_id}/complete Content-Type: application/json diff --git a/docs/specification/signatures.md b/docs/specification/signatures.md index 9705b361c..be6a964a1 100644 --- a/docs/specification/signatures.md +++ b/docs/specification/signatures.md @@ -118,6 +118,7 @@ defined in [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517). **Example:** + ```json { "kid": "key-2024-01-15", @@ -577,6 +578,7 @@ Content-Type: application/json ### MCP Error Response + ```json { "jsonrpc": "2.0", diff --git a/docs/specification/tokenization-guide.md b/docs/specification/tokenization-guide.md index c574b6fbf..c1f64c217 100644 --- a/docs/specification/tokenization-guide.md +++ b/docs/specification/tokenization-guide.md @@ -116,6 +116,7 @@ Converts a raw credential into a token bound to a checkout and identity. **When to implement:** Always, unless you are an agent generating tokens internally. + ```json POST /tokenize Content-Type: application/json @@ -140,6 +141,7 @@ Content-Type: application/json **Response:** + ```json { "token": "tok_abc123xyz789" @@ -153,6 +155,7 @@ Returns the original credential for a valid token. Binding must match. **When to implement:** Always, unless you combine detokenization with processing (see PSP example). + ```json POST /detokenize Content-Type: application/json @@ -168,6 +171,7 @@ Authorization: Bearer {caller_access_token} **Response:** + ```json { "type": "card", diff --git a/scripts/scaffolds/shopping_cart_request_create.json b/scripts/scaffolds/shopping_cart_request_create.json new file mode 100644 index 000000000..7989c045f --- /dev/null +++ b/scripts/scaffolds/shopping_cart_request_create.json @@ -0,0 +1,8 @@ +{ + "line_items": [ + { + "item": { "id": "item_scaffold" }, + "quantity": 1 + } + ] +} diff --git a/scripts/scaffolds/shopping_cart_request_update.json b/scripts/scaffolds/shopping_cart_request_update.json new file mode 100644 index 000000000..b00b687fa --- /dev/null +++ b/scripts/scaffolds/shopping_cart_request_update.json @@ -0,0 +1,9 @@ +{ + "id": "cart_scaffold", + "line_items": [ + { + "item": { "id": "item_scaffold" }, + "quantity": 1 + } + ] +} diff --git a/scripts/scaffolds/shopping_cart_response.json b/scripts/scaffolds/shopping_cart_response.json new file mode 100644 index 000000000..673d970b8 --- /dev/null +++ b/scripts/scaffolds/shopping_cart_response.json @@ -0,0 +1,23 @@ +{ + "ucp": { + "version": "2026-01-01", + "status": "success" + }, + "id": "cart_scaffold", + "currency": "USD", + "line_items": [ + { + "id": "li_scaffold", + "item": { "id": "item_scaffold", "title": "Scaffold Item", "price": 1000 }, + "quantity": 1, + "totals": [ + { "type": "subtotal", "amount": 1000 }, + { "type": "total", "amount": 1000 } + ] + } + ], + "totals": [ + { "type": "subtotal", "amount": 1000 }, + { "type": "total", "amount": 1000 } + ] +} diff --git a/scripts/scaffolds/shopping_checkout_request_complete.json b/scripts/scaffolds/shopping_checkout_request_complete.json new file mode 100644 index 000000000..ce565f917 --- /dev/null +++ b/scripts/scaffolds/shopping_checkout_request_complete.json @@ -0,0 +1,15 @@ +{ + "payment": { + "instruments": [ + { + "id": "instr_scaffold", + "handler_id": "handler_scaffold", + "type": "card", + "credential": { + "type": "token", + "token": "tok_scaffold" + } + } + ] + } +} diff --git a/scripts/scaffolds/shopping_checkout_request_create.json b/scripts/scaffolds/shopping_checkout_request_create.json new file mode 100644 index 000000000..7989c045f --- /dev/null +++ b/scripts/scaffolds/shopping_checkout_request_create.json @@ -0,0 +1,8 @@ +{ + "line_items": [ + { + "item": { "id": "item_scaffold" }, + "quantity": 1 + } + ] +} diff --git a/scripts/scaffolds/shopping_checkout_request_update.json b/scripts/scaffolds/shopping_checkout_request_update.json new file mode 100644 index 000000000..7989c045f --- /dev/null +++ b/scripts/scaffolds/shopping_checkout_request_update.json @@ -0,0 +1,8 @@ +{ + "line_items": [ + { + "item": { "id": "item_scaffold" }, + "quantity": 1 + } + ] +} diff --git a/scripts/scaffolds/shopping_checkout_response.json b/scripts/scaffolds/shopping_checkout_response.json new file mode 100644 index 000000000..c69414306 --- /dev/null +++ b/scripts/scaffolds/shopping_checkout_response.json @@ -0,0 +1,28 @@ +{ + "ucp": { + "version": "2026-01-01", + "status": "success", + "payment_handlers": {} + }, + "id": "chk_scaffold", + "status": "incomplete", + "currency": "USD", + "line_items": [ + { + "id": "li_scaffold", + "item": { "id": "item_scaffold", "title": "Scaffold Item", "price": 1000 }, + "quantity": 1, + "totals": [ + { "type": "subtotal", "amount": 1000 }, + { "type": "total", "amount": 1000 } + ] + } + ], + "totals": [ + { "type": "subtotal", "amount": 1000 }, + { "type": "total", "amount": 1000 } + ], + "links": [ + { "type": "terms_of_service", "url": "https://example.com/terms" } + ] +} diff --git a/scripts/scaffolds/shopping_order_response.json b/scripts/scaffolds/shopping_order_response.json new file mode 100644 index 000000000..4f631403d --- /dev/null +++ b/scripts/scaffolds/shopping_order_response.json @@ -0,0 +1,27 @@ +{ + "ucp": { + "version": "2026-01-01", + "status": "success" + }, + "id": "order_scaffold", + "checkout_id": "chk_scaffold", + "permalink_url": "https://example.com/orders/scaffold", + "currency": "USD", + "line_items": [ + { + "id": "li_scaffold", + "item": { "id": "item_scaffold", "title": "Scaffold Item", "price": 1000 }, + "quantity": { "original": 1, "total": 1, "fulfilled": 0 }, + "totals": [ + { "type": "subtotal", "amount": 1000 }, + { "type": "total", "amount": 1000 } + ], + "status": "processing" + } + ], + "fulfillment": {}, + "totals": [ + { "type": "subtotal", "amount": 1000 }, + { "type": "total", "amount": 1000 } + ] +} diff --git a/scripts/scaffolds/shopping_types_error_response_response.json b/scripts/scaffolds/shopping_types_error_response_response.json new file mode 100644 index 000000000..415b9043d --- /dev/null +++ b/scripts/scaffolds/shopping_types_error_response_response.json @@ -0,0 +1,14 @@ +{ + "ucp": { + "version": "2026-01-01", + "status": "error" + }, + "messages": [ + { + "type": "error", + "code": "scaffold_error", + "content": "Scaffold error message", + "severity": "unrecoverable" + } + ] +} diff --git a/scripts/validate_examples.py b/scripts/validate_examples.py new file mode 100755 index 000000000..d785a5828 --- /dev/null +++ b/scripts/validate_examples.py @@ -0,0 +1,782 @@ +#!/usr/bin/env python3 +"""Validate JSON examples in UCP spec documentation. + +Every ```json code block in the spec docs must be annotated +with: + + + ```json + { ... } + ``` + +Or explicitly skipped: + + + +Unannotated blocks are hard failures. +See artifacts/ucp-testing-proposal.md. +""" + +import argparse +import json +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +# ----------------------------------------------------------- +# Constants +# ----------------------------------------------------------- + +# any valid YYYY-MM-DD satisfies the pattern +UCP_VERSION_PLACEHOLDER = "2026-04-08" + +ANNOTATION_RE = re.compile(r"^(\s*)") +FENCE_OPEN_RE = re.compile(r"^(\s*)```json\s*$") +FENCE_CLOSE_RE = re.compile(r"^(\s*)```\s*$") + +# ----------------------------------------------------------- +# Annotation parsing +# ----------------------------------------------------------- + + +def parse_annotation(text: str) -> dict: + """Parse annotation attributes from the comment body.""" + text = text.strip() + if text.startswith("skip"): + reason_match = re.search(r'reason="([^"]*)"', text) + return { + "skip": True, + "reason": (reason_match.group(1) if reason_match else ""), + } + attrs = {} + for m in re.finditer(r'(\w+)=(?:"([^"]+)"|(\S+))', text): + attrs[m.group(1)] = m.group(2) if m.group(2) is not None else m.group(3) + # Defaults + attrs.setdefault("op", "read") + attrs.setdefault("direction", "response") + return attrs + + +# ----------------------------------------------------------- +# Markdown extraction +# ----------------------------------------------------------- + + +def extract_blocks(filepath: Path) -> list[dict]: + """Extract ```json blocks with their annotations.""" + lines = filepath.read_text().splitlines() + blocks: list[dict] = [] + i = 0 + pending_annotation = None + + while i < len(lines): + line = lines[i] + + # Check for annotation comment + ann_match = ANNOTATION_RE.match(line) + if ann_match: + pending_annotation = parse_annotation(ann_match.group(2)) + i += 1 + continue + + # Check for code fence opening + fence_match = FENCE_OPEN_RE.match(line) + if fence_match: + fence_indent = fence_match.group(1) + # Collect content until closing fence + content_lines: list[str] = [] + start_line = i + 1 + i += 1 + while i < len(lines): + close_match = FENCE_CLOSE_RE.match(lines[i]) + if close_match and len(close_match.group(1)) <= len(fence_indent): + break + # Strip indent prefix from content + content_line = lines[i] + if fence_indent and content_line.startswith(fence_indent): + content_line = content_line[len(fence_indent) :] + content_lines.append(content_line) + i += 1 + + block = { + "file": str(filepath), + "line": start_line, + "content": "\n".join(content_lines), + "annotation": pending_annotation, + } + blocks.append(block) + pending_annotation = None + i += 1 + continue + + # Non-blank, non-annotation line clears pending + if pending_annotation and line.strip(): + pending_annotation = None + + i += 1 + + return blocks + + +# ----------------------------------------------------------- +# HTTP unwrap +# ----------------------------------------------------------- + +HTTP_METHOD_RE = re.compile(r"^(GET|POST|PUT|PATCH|DELETE)\s|^HTTP/") + + +def unwrap_http(content: str) -> str: + """Extract JSON body after blank line in HTTP blocks.""" + first_line = content.lstrip().split("\n")[0] + if HTTP_METHOD_RE.match(first_line): + parts = content.split("\n\n", 1) + if len(parts) == 2: + return parts[1].strip() + return content + + +# ----------------------------------------------------------- +# Template expansion +# ----------------------------------------------------------- + + +def expand_templates(content: str) -> str: + """Replace {{ ucp_version }} with a valid date.""" + return content.replace("{{ ucp_version }}", UCP_VERSION_PLACEHOLDER) + + +# ----------------------------------------------------------- +# Ellipsis handling +# ----------------------------------------------------------- + +# Bare ... inside [] or {} — convert to valid JSON before +# json.loads. Authors write [ ... ] for arrays, { ... } for +# objects. Preprocessing converts these to ["..."] and +# {"...":"..."} respectively. +_BARE_ELLIPSIS_ARRAY = re.compile(r"(\[\s*)\.\.\.(\s*\])") +_BARE_ELLIPSIS_OBJECT = re.compile(r"(\{\s*)\.\.\.(\s*\})") + + +def preprocess_ellipsis(content: str) -> str: + """Convert bare ... to valid JSON placeholders.""" + content = _BARE_ELLIPSIS_ARRAY.sub(r'\1"..."\2', content) + content = _BARE_ELLIPSIS_OBJECT.sub(r'\1"...": "..."\2', content) + return content + + +def _is_ellipsis(value) -> bool: + """Check if a value is a recognized ellipsis marker.""" + return ( + value == "..." + or (isinstance(value, list) and len(value) == 1 and value[0] == "...") + or (isinstance(value, dict) and value == {"...": "..."}) + ) + + +def strip_ellipsis(obj): + """Remove ellipsis markers, returning cleaned object.""" + if isinstance(obj, dict): + return {k: strip_ellipsis(v) for k, v in obj.items() if not _is_ellipsis(v)} + elif isinstance(obj, list): + return [strip_ellipsis(item) for item in obj if item != "..."] + return obj + + +# ----------------------------------------------------------- +# JSONPath navigation (minimal subset) +# ----------------------------------------------------------- + +_SEGMENT_RE = re.compile(r"^(\w+)(?:\[(\d+)\])?$") + + +def jsonpath_set(obj: dict, path: str, value): + """Set a value at a JSONPath. Mutates obj.""" + segments = path.lstrip("$").lstrip(".").split(".") + current = obj + for seg in segments[:-1]: + m = _SEGMENT_RE.match(seg) + name, idx = m.group(1), m.group(2) + current = current[name] + if idx is not None: + current = current[int(idx)] + last = _SEGMENT_RE.match(segments[-1]) + name, idx = last.group(1), last.group(2) + if idx is not None: + current[name][int(idx)] = value + else: + current[name] = value + + +def jsonpath_get_schema(schema: dict, path: str) -> dict: + """Navigate a JSON Schema to the sub-schema at path.""" + segments = path.lstrip("$").lstrip(".").split(".") + current = schema + for seg in segments: + m = _SEGMENT_RE.match(seg) + name, idx = m.group(1), m.group(2) + # Resolve through allOf to find properties + current = _get_property_schema(current, name) + if current is None: + return {} + if idx is not None: + current = current.get("items", {}) + return current + + +# ----------------------------------------------------------- +# Deep merge +# ----------------------------------------------------------- + + +def deep_merge(scaffold: dict, example: dict) -> dict: + """Merge example into scaffold. + + Example fields win. Objects recurse, arrays replace. + """ + if isinstance(scaffold, dict) and isinstance(example, dict): + result = dict(scaffold) + for key, value in example.items(): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): + result[key] = deep_merge(result[key], value) + else: + result[key] = value + return result + return example + + +# ----------------------------------------------------------- +# Coverage walker +# ----------------------------------------------------------- + + +def _collect_required(schema: dict) -> set[str]: + """Collect required fields, merging allOf branches.""" + required = set(schema.get("required", [])) + for branch in schema.get("allOf", []): + required |= set(branch.get("required", [])) + return required + + +def _collect_properties(schema: dict) -> dict: + """Collect properties, merging allOf branches.""" + props = dict(schema.get("properties", {})) + for branch in schema.get("allOf", []): + props.update(branch.get("properties", {})) + return props + + +def _get_property_schema(schema: dict, key: str) -> dict | None: + """Get schema for a property, resolving allOf.""" + props = _collect_properties(schema) + return props.get(key) + + +def _resolve_discriminator(schema: dict, value) -> dict: + """Select matching oneOf branch via discriminator.""" + if not isinstance(value, dict): + return schema + disc = schema.get("discriminator", {}) + disc_key = disc.get("propertyName") + if not disc_key or disc_key not in value: + return schema + disc_val = value[disc_key] + for branch in schema.get("oneOf", []): + branch_props = _collect_properties(branch) + const = branch_props.get(disc_key, {}).get("const") + if const == disc_val: + return branch + return schema + + +def check_coverage(example, schema: dict, path: str = "$") -> list[str]: + """Verify required fields are present or elided.""" + errors: list[str] = [] + + # Guard: skip self-references + if "$ref" in schema and schema["$ref"] == "#": + return errors + + # Object coverage + if isinstance(example, dict): + obj_type = schema.get("type") + # Schemas without explicit "type" but with + # "properties" or "allOf" are still objects. + has_object_shape = ( + obj_type == "object" + or "properties" in schema + or any("properties" in b for b in schema.get("allOf", [])) + ) + if has_object_shape: + required = _collect_required(schema) + present = set(example.keys()) + missing = required - present + for field in sorted(missing): + errors.append(f'{path}: missing required field "{field}"') + + # Recurse into non-ellipsis fields + for key, value in example.items(): + if _is_ellipsis(value): + continue + prop_schema = _get_property_schema(schema, key) + if prop_schema is None: + continue + # Handle oneOf with discriminator + if "oneOf" in prop_schema: + prop_schema = _resolve_discriminator(prop_schema, value) + errors += check_coverage( + value, + prop_schema, + f"{path}.{key}", + ) + + # Array coverage: check each real element + elif isinstance(example, list): + items_schema = schema.get("items", {}) + # Also check allOf for items + for branch in schema.get("allOf", []): + if "items" in branch: + items_schema = branch["items"] + break + for i, item in enumerate(example): + if _is_ellipsis(item): + continue + item_schema = items_schema + # Handle oneOf discriminator on items + if "oneOf" in item_schema: + item_schema = _resolve_discriminator(item_schema, item) + errors += check_coverage( + item, + item_schema, + f"{path}[{i}]", + ) + + return errors + + +# ----------------------------------------------------------- +# Schema resolution (cached) +# ----------------------------------------------------------- + +_schema_cache: dict[tuple, dict] = {} + + +def resolve_schema( + schema_path: str, + direction: str, + op: str, + schema_base: Path, +) -> dict: + """Resolve a schema via ucp-schema, with caching.""" + key = (schema_path, direction, op) + if key in _schema_cache: + return _schema_cache[key] + + full_path = schema_base / f"{schema_path}.json" + result = subprocess.run( + [ + "ucp-schema", + "resolve", + str(full_path), + f"--{direction}", + "--op", + op, + "--bundle", + "--pretty", + ], + capture_output=True, + text=True, + cwd=str(schema_base.parent), + ) + if result.returncode != 0: + raise RuntimeError( + f"ucp-schema resolve failed for" + f" {schema_path} ({direction}/{op}):" + f" {result.stderr.strip()}" + ) + schema = json.loads(result.stdout) + _schema_cache[key] = schema + return schema + + +# ----------------------------------------------------------- +# Payload validation via ucp-schema +# ----------------------------------------------------------- + + +def validate_payload( + payload: dict, + schema_path: str, + direction: str, + op: str, + schema_base: Path, +) -> tuple[bool, list[dict]]: + """Validate a payload via ucp-schema validate.""" + full_schema = schema_base / f"{schema_path}.json" + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(payload, f) + tmp_path = f.name + + try: + result = subprocess.run( + [ + "ucp-schema", + "validate", + tmp_path, + "--schema", + str(full_schema), + f"--{direction}", + "--op", + op, + "--json", + ], + capture_output=True, + text=True, + cwd=str(schema_base.parent), + ) + if result.stdout.strip(): + output = json.loads(result.stdout) + return ( + output.get("valid", False), + output.get("errors", []), + ) + # No JSON output — non-zero exit is an error + if result.returncode != 0: + return False, [ + { + "path": "", + "message": result.stderr.strip(), + } + ] + return True, [] + finally: + Path(tmp_path).unlink() + + +# ----------------------------------------------------------- +# Scaffold loading +# ----------------------------------------------------------- + + +def load_scaffold( + schema_path: str, + direction: str, + op: str, + scaffolds_dir: Path, +) -> dict | None: + """Load scaffold fixture for a schema+direction+op.""" + # Try specific: checkout_request_create.json + name = schema_path.replace("/", "_") + specific = scaffolds_dir / f"{name}_{direction}_{op}.json" + if specific.exists(): + return json.loads(specific.read_text()) + + # Try direction-only: checkout_response.json + dir_only = scaffolds_dir / f"{name}_{direction}.json" + if dir_only.exists(): + return json.loads(dir_only.read_text()) + + # Try generic: checkout.json + generic = scaffolds_dir / f"{name}.json" + if generic.exists(): + return json.loads(generic.read_text()) + + return None + + +# ----------------------------------------------------------- +# Main pipeline +# ----------------------------------------------------------- + + +class Result: + """Outcome of validating a single JSON block.""" + + def __init__( + self, + file: str, + line: int, + status: str, + message: str = "", + annotation: dict | None = None, + ) -> None: + """Initialize a validation result.""" + self.file = file + self.line = line + self.status = status + self.message = message + self.annotation = annotation or {} + + def __str__(self) -> str: + """Format result as a human-readable line.""" + rel = self.file + schema_info = "" + if self.annotation: + parts: list[str] = [] + if "schema" in self.annotation: + parts.append(f"schema={self.annotation['schema']}") + if self.annotation.get("path"): + parts.append(f"path={self.annotation['path']}") + parts.append(f"op={self.annotation.get('op', 'read')}") + schema_info = f" [{' '.join(parts)}]" + + prefix = { + "ok": "OK ", + "fail": "FAIL ", + "skip": "SKIP ", + "error": "ERR ", + }[self.status] + + line = f"{prefix} {rel}:{self.line}{schema_info}" + if self.message: + line += f"\n {self.message}" + return line + + +def process_block( + block: dict, + schema_base: Path, + scaffolds_dir: Path, +) -> Result: + """Run the validation pipeline on one block.""" + file, line = block["file"], block["line"] + annotation = block["annotation"] + + # Unannotated block + if annotation is None: + return Result(file, line, "error", "unannotated JSON block") + + # Skip + if annotation.get("skip"): + reason = annotation.get("reason", "") + return Result(file, line, "skip", reason, annotation) + + # Must have schema + schema_path = annotation.get("schema") + if not schema_path: + return Result( + file, + line, + "error", + 'annotation missing "schema" attribute', + annotation, + ) + + op = annotation["op"] + direction = annotation["direction"] + subtree_path = annotation.get("path") + + # 1. Unwrap HTTP + content = unwrap_http(block["content"]) + + # 2. Expand templates + content = expand_templates(content) + + # 3. Preprocess bare ... into valid JSON + content = preprocess_ellipsis(content) + + # 4. Parse JSON + try: + example = json.loads(content) + except json.JSONDecodeError as e: + return Result( + file, + line, + "fail", + f"invalid JSON: {e}", + annotation, + ) + + # 5. Resolve schema + try: + resolved = resolve_schema(schema_path, direction, op, schema_base) + except RuntimeError as e: + return Result(file, line, "error", str(e), annotation) + + # 6. Coverage check + if subtree_path: + coverage_schema = jsonpath_get_schema(resolved, subtree_path) + else: + coverage_schema = resolved + + coverage_errors = check_coverage(example, coverage_schema) + + # 7. Strip ellipsis + stripped = strip_ellipsis(example) + + # 8. Load scaffold and merge + scaffold = load_scaffold(schema_path, direction, op, scaffolds_dir) + if scaffold is None: + return Result( + file, + line, + "error", + f"no scaffold for {schema_path} ({direction}/{op})", + annotation, + ) + + if subtree_path: + # deep copy + merged = json.loads(json.dumps(scaffold)) + try: + jsonpath_set(merged, subtree_path, stripped) + except ( + KeyError, + IndexError, + TypeError, + ) as e: + return Result( + file, + line, + "error", + f"scaffold navigation failed at {subtree_path}: {e}", + annotation, + ) + else: + merged = deep_merge(scaffold, stripped) + + # 9. Validate + valid, val_errors = validate_payload( + merged, + schema_path, + direction, + op, + schema_base, + ) + + # Collect all failures + messages: list[str] = [] + for ce in coverage_errors: + messages.append(f"coverage: {ce}") + for ve in val_errors: + messages.append( + f"validation: {ve.get('path', '')} \u2014 {ve.get('message', '')}" + ) + + if messages: + return Result( + file, + line, + "fail", + "\n ".join(messages), + annotation, + ) + + return Result(file, line, "ok", annotation=annotation) + + +# ----------------------------------------------------------- +# CLI +# ----------------------------------------------------------- + + +def main() -> int: + """Run example validation across spec docs.""" + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=(argparse.RawDescriptionHelpFormatter), + ) + parser.add_argument( + "--schema-base", + type=Path, + required=True, + help="Path to source/schemas/ directory", + ) + parser.add_argument( + "--scaffolds", + type=Path, + default=None, + help=("Path to scaffolds directory (default: scripts/scaffolds/)"), + ) + parser.add_argument( + "--docs", + type=Path, + default=None, + help=("Path to docs/ directory (default: docs/)"), + ) + parser.add_argument( + "--file", + type=Path, + default=None, + help="Validate a single file instead of all", + ) + parser.add_argument( + "--audit", + action="store_true", + help="Just list blocks without validating", + ) + args = parser.parse_args() + + # Resolve paths relative to script location + script_dir = Path(__file__).parent + repo_root = script_dir.parent + + schema_base = args.schema_base + if not schema_base.is_absolute(): + schema_base = repo_root / schema_base + + scaffolds_dir = args.scaffolds or script_dir / "scaffolds" + docs_dir = args.docs or repo_root / "docs" + + # Collect markdown files + md_files = [args.file] if args.file else sorted(docs_dir.rglob("*.md")) + + # Extract all blocks + all_blocks: list[dict] = [] + for md_file in md_files: + blocks = extract_blocks(md_file) + all_blocks.extend(blocks) + + if args.audit: + # Audit mode: just report what we found + annotated = sum(1 for b in all_blocks if b["annotation"] is not None) + skipped = sum( + 1 for b in all_blocks if b["annotation"] and b["annotation"].get("skip") + ) + unannotated = sum(1 for b in all_blocks if b["annotation"] is None) + print(f"Found {len(all_blocks)} JSON blocks across {len(md_files)} files") + print(f" annotated: {annotated} ({skipped} skip)") + print(f" unannotated: {unannotated}") + if unannotated: + print("\nUnannotated blocks:") + for b in all_blocks: + if b["annotation"] is None: + print(f" {b['file']}:{b['line']}") + return 1 if unannotated else 0 + + # Validate + results: list[Result] = [] + for block in all_blocks: + result = process_block(block, schema_base, scaffolds_dir) + results.append(result) + + # Report + passed = sum(1 for r in results if r.status == "ok") + failed = sum(1 for r in results if r.status == "fail") + errors = sum(1 for r in results if r.status == "error") + skipped = sum(1 for r in results if r.status == "skip") + + # Print failures and errors first + for r in results: + if r.status in ("fail", "error"): + print(r) + for r in results: + if r.status == "skip": + print(r) + + print( + f"\n{passed} passed, {failed} failed, {errors} errors, {skipped} skipped" + ) + + return 0 if (failed == 0 and errors == 0) else 1 + + +if __name__ == "__main__": + sys.exit(main())