From 4089b120e08a3dea65f8523ba234d26d47490e0e Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Wed, 5 Aug 2026 07:54:43 -0700 Subject: [PATCH 1/5] fix!: require destination type Fulfillment originally paired a closed shipping/pickup method enum with an untagged oneOf between Shipping Destination and Retail Location. Humans could infer the intended shape from the enclosing method, but the schema did not condition destinations[] on method.type. Every destination was validated against both branches. The branches are not structurally disjoint. Shipping Destination is open and requires only id in responses, so every Retail Location also matches it. Strict oneOf validation rejects intended pickup destinations. Request resolution has the same defect: it omits the stable Location id and requires Business-owned name/address fields, which the open Shipping branch also accepts. PR #507 opened the method-type vocabulary but left this inherited destination debt unchanged. PR #589 exposes it again and adds a second boundary problem: using the full Location Search/Lookup entity in Checkout would couple Fulfillment to a separately negotiated capability's schema and lifecycle. Checkout and Location Lookup need different projections: - Checkout request: stable type-plus-id Location reference - Checkout response: bounded id/name/address rendering summary - Location Search/Lookup: richer discovery entity with geo, hours, amenities, service areas, and future Location fields Platforms must be able to negotiate and render Checkout without supporting or invoking Location Lookup. This change: - Introduces a bounded Location Summary with stable Business-scoped id, Buyer-facing name, and optional address. PR #589 can compose its richer Location entity on top without leaking discovery fields into Checkout. - Replaces structural destination inference with a required, open type discriminator. Well-known values are shipping_address and business_location; negotiated extensions may define additional values. - Preserves flat Platform-owned shipping-address fields. Shipping requests keep destination id optional; Business responses continue assigning the id. - Changes Business Location requests to type-plus-id references. The Business owns the Location name/address and returns those facts in the response summary. - Keeps Catalog's scalar Location id and selected_destination_id semantics. Location recognition is method-scoped and does not reserve inventory or guarantee eligibility; the Business revalidates current terms through normal Fulfillment responses and messages. - Generalizes the active pickup destination from Retail Location to Business Location Destination. Retail stores remain supported as Business Locations, alongside other Business-scoped places such as lockers and partner pickup points. - Updates all destination examples and documents the new authority, identity, and extension boundaries. BREAKING CHANGE: every active Fulfillment Destination now requires type. Shipping-address producers must add type: shipping_address. Retail Location destinations migrate to type: business_location; requests send the stable Location id instead of name/address. --- docs/index.md | 1 + docs/specification/checkout-mcp.md | 3 + docs/specification/checkout-rest.md | 14 ++- docs/specification/embedded-checkout.md | 2 + docs/specification/fulfillment.md | 97 +++++++++++++++++-- docs/specification/playground.md | 4 +- .../common/types/location_summary.json | 26 +++++ source/schemas/shopping/fulfillment.json | 2 +- .../types/fulfillment_destination.json | 31 +++++- .../shopping/types/fulfillment_method.json | 2 +- .../shopping/types/location_destination.json | 25 +++++ .../shopping/types/retail_location.json | 25 ----- .../shopping/types/shipping_destination.json | 9 +- 13 files changed, 198 insertions(+), 43 deletions(-) create mode 100644 source/schemas/common/types/location_summary.json create mode 100644 source/schemas/shopping/types/location_destination.json delete mode 100644 source/schemas/shopping/types/retail_location.json diff --git a/docs/index.md b/docs/index.md index b10d056c1..9c2f1de5d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -355,6 +355,7 @@ image: assets/banner.png "selected_destination_id": "dest_1", "destinations": [ { + "type": "shipping_address", "id": "dest_1", "first_name": "Elisa", "last_name": "Beckett", diff --git a/docs/specification/checkout-mcp.md b/docs/specification/checkout-mcp.md index 9477c0cd8..5e126da8a 100644 --- a/docs/specification/checkout-mcp.md +++ b/docs/specification/checkout-mcp.md @@ -190,6 +190,7 @@ Maps to the [Create Checkout](checkout.md#create-checkout) operation. "type": "shipping", "destinations": [ { + "type": "shipping_address", "street_address": "123 Main St", "address_locality": "Springfield", "address_region": "IL", @@ -278,6 +279,7 @@ Maps to the [Create Checkout](checkout.md#create-checkout) operation. "selected_destination_id": "dest_home", "destinations": [ { + "type": "shipping_address", "id": "dest_home", "street_address": "123 Main St", "address_locality": "Springfield", @@ -536,6 +538,7 @@ unchanged and return the current Checkout with a recoverable error Message. "selected_destination_id": "dest_home", "destinations": [ { + "type": "shipping_address", "id": "dest_home", "street_address": "123 Main St", "address_locality": "Springfield", diff --git a/docs/specification/checkout-rest.md b/docs/specification/checkout-rest.md index 653851c97..809fb01a6 100644 --- a/docs/specification/checkout-rest.md +++ b/docs/specification/checkout-rest.md @@ -371,9 +371,10 @@ include all previously set fields it intends to retain. #### Update Fulfillment -Fulfillment is an extension to the checkout capability. Most fields are provided -by the business based on buyer inputs, which includes desired fulfillment -type & addresses. +Fulfillment is an extension to the checkout capability. The Business provides +most fields based on Buyer inputs, including desired fulfillment method types +and typed destinations such as shipping addresses and Business Location +references. === "Request" @@ -405,6 +406,7 @@ type & addresses. "type": "shipping", "destinations": [ { + "type": "shipping_address", "street_address": "123 Main St", "address_locality": "Springfield", "address_region": "IL", @@ -513,6 +515,7 @@ type & addresses. "selected_destination_id": "dest_home", "destinations": [ { + "type": "shipping_address", "id": "dest_home", "street_address": "123 Main St", "address_locality": "Springfield", @@ -610,6 +613,7 @@ Follow-up calls after initial `fulfillment` data to update selection. "selected_destination_id": "dest_home", "destinations": [ { + "type": "shipping_address", "id": "dest_home", "street_address": "123 Main St", "address_locality": "Springfield", @@ -712,6 +716,7 @@ Follow-up calls after initial `fulfillment` data to update selection. "selected_destination_id": "dest_home", "destinations": [ { + "type": "shipping_address", "id": "dest_home", "street_address": "123 Main St", "address_locality": "Springfield", @@ -913,6 +918,7 @@ for status and `order` semantics. "selected_destination_id": "dest_home", "destinations": [ { + "type": "shipping_address", "id": "dest_home", "street_address": "123 Main St", "address_locality": "Springfield", @@ -1072,6 +1078,7 @@ for status and `order` semantics. "selected_destination_id": "dest_home", "destinations": [ { + "type": "shipping_address", "id": "dest_home", "street_address": "123 Main St", "address_locality": "Springfield", @@ -1229,6 +1236,7 @@ for status and `order` semantics. "selected_destination_id": "dest_home", "destinations": [ { + "type": "shipping_address", "id": "dest_home", "street_address": "123 Main St", "address_locality": "Springfield", diff --git a/docs/specification/embedded-checkout.md b/docs/specification/embedded-checkout.md index ef9ef07a1..6deeeb1ef 100644 --- a/docs/specification/embedded-checkout.md +++ b/docs/specification/embedded-checkout.md @@ -1306,6 +1306,7 @@ method. "selected_destination_id": "address_123", "destinations": [ { + "type": "shipping_address", "id": "address_123", "street_address": "456 Old Street" // ... @@ -1353,6 +1354,7 @@ rather than attempting to merge the new data with existing state. "selected_destination_id": "address_789", "destinations": [ { + "type": "shipping_address", "id": "address_789", "first_name": "John", "last_name": "Doe", diff --git a/docs/specification/fulfillment.md b/docs/specification/fulfillment.md index ffb5658e8..8e4c61ace 100644 --- a/docs/specification/fulfillment.md +++ b/docs/specification/fulfillment.md @@ -81,9 +81,13 @@ method. {{ schema_fields('types/shipping_destination_resp', 'fulfillment') }} -#### Retail Location +#### Business Location Destination -{{ schema_fields('types/retail_location_resp', 'fulfillment') }} +{{ schema_fields('types/location_destination_resp', 'fulfillment') }} + +#### Location Summary + +{{ schema_fields('location_summary', 'fulfillment') }} #### Fulfillment Group @@ -126,6 +130,7 @@ method. "selected_destination_id": "dest_1", "destinations": [ { + "type": "shipping_address", "id": "dest_1", "street_address": "123 Main St", "address_locality": "Springfield", @@ -171,6 +176,77 @@ method. } ``` +## Destinations + +A fulfillment method's `type` describes how items are fulfilled, while each +destination's `type` describes where fulfillment occurs. These discriminators +are independent; neither implies the other. + +Every destination has a required, open `type`. The well-known values are: + +| Value | Meaning | +| --- | --- | +| `shipping_address` | A Shipping Destination with flat Postal Address fields. | +| `business_location` | A Business Location Destination identified by a Business-scoped `id`. | + +Additional values are defined by negotiated extensions. Destination fields +specific to such a value are validated by the negotiated extension's schema. + +### Shipping Destination + +For a Shipping Destination, the Platform supplies shipping-address facts as +flat Postal Address fields directly on the destination. The Platform **MUST** +include `type: "shipping_address"` and **MAY** include `id` in its request. The +Business **MUST** include `type: "shipping_address"` and assign `id` in its +response. + +### Business Location Destination + +For a Business Location Destination, the Platform selects the location with a +stable, opaque, Business-scoped `id`. The Platform **MUST** include +`type: "business_location"` and `id` in its request and **MUST NOT** include the +Business-owned `name` or `address`. The Business **MUST** return +`type: "business_location"`, `id`, and its Buyer-facing `name`, and **MAY** +return its Postal Address in `address`. + +#### Platform Request + + +```json +{ + "type": "business_location", + "id": "loc_downtown" +} +``` + +#### Business Response + + +```json +{ + "type": "business_location", + "id": "loc_downtown", + "name": "Downtown Store", + "address": { + "street_address": "123 Main St", + "address_locality": "Springfield", + "address_region": "IL", + "postal_code": "62701", + "address_country": "US" + } +} +``` + +### Selection and Location Identity + +`selected_destination_id` identifies the selected destination in a method by +its `id`. When Catalog represents a location as applicable to a particular +fulfillment method, the Business **MUST** recognize the same Business-scoped ID +when the Platform submits it in Checkout for that method, including as +`selected_destination_id`. The Business **MUST** revalidate current availability +and terms during Checkout; recognition does not reserve inventory or guarantee +eligibility. + ## Rendering Fulfillment options are designed for **method-agnostic rendering**. Platforms @@ -311,11 +387,14 @@ method has: renderable; see [Rendering](#rendering). * `availability` — whether the variant is available via this method at the specified or inferred location. -* `location` — for place-based methods (e.g. `pickup`), the resolved - location id, and the business's stable identifier for that location. A - business that advertises pickup at a `location` MUST accept the same id - as `selected_destination_id` for that method, so a discovered location - can be used in cart and checkout. +* `location` — when the method is scoped to a specific Business Location, + that location's stable, opaque, Business-scoped identifier. When Catalog + represents the location as applicable to a particular method, the Business + **MUST** recognize the same ID when the Platform submits it for that + method, including as `selected_destination_id`. Recognition is + method-scoped and does not reserve inventory or guarantee eligibility; the + Business revalidates current availability and terms during Checkout. See + [Selection and Location Identity](#selection-and-location-identity). * `options` — concrete fulfillment choices within this method (e.g. Standard, Express); see [Options](#options). Optional. @@ -652,6 +731,7 @@ so a cart can mix shipped and installed items (see "selected_destination_id": "dest_1", "destinations": [ { + "type": "shipping_address", "id": "dest_1", "street_address": "123 Main St", "address_locality": "Springfield", @@ -723,6 +803,7 @@ package. "selected_destination_id": "dest_1", "destinations": [ { + "type": "shipping_address", "id": "dest_1", "street_address": "123 Main St", "address_locality": "Springfield", @@ -799,6 +880,7 @@ same type, each with its own destination. "selected_destination_id": "dest_mom", "destinations": [ { + "type": "shipping_address", "id": "dest_mom", "street_address": "123 Mom St", "address_locality": "Springfield", @@ -844,6 +926,7 @@ same type, each with its own destination. "selected_destination_id": "dest_grandma", "destinations": [ { + "type": "shipping_address", "id": "dest_grandma", "street_address": "88 Queensway", "address_locality": "Hong Kong", diff --git a/docs/specification/playground.md b/docs/specification/playground.md index 0af984c1c..266800fa9 100644 --- a/docs/specification/playground.md +++ b/docs/specification/playground.md @@ -971,7 +971,7 @@ class UcpApp { req.buyer.email = "test@example.com"; req.buyer.name = "Test User"; } else { - req.fulfillment.methods = [{ type: 'shipping', destinations: [{ id: "addr_1", street: "123 Main St", city: "Tech City", country: "US", postal_code: "94103" }] }]; + req.fulfillment.methods = [{ type: 'shipping', destinations: [{ type: "shipping_address", id: "addr_1", street_address: "123 Main St", address_locality: "Tech City", address_country: "US", postal_code: "94103" }] }]; } } @@ -998,7 +998,7 @@ class UcpApp { patch.fulfillment = { methods: [{ type: "shipping", - destinations: [{ id: "addr_1", street: "123 Main St", city: "Tech City", country: "US", postal_code: "94103" }] + destinations: [{ type: "shipping_address", id: "addr_1", street_address: "123 Main St", address_locality: "Tech City", address_country: "US", postal_code: "94103" }] }] }; } diff --git a/source/schemas/common/types/location_summary.json b/source/schemas/common/types/location_summary.json new file mode 100644 index 000000000..97b026264 --- /dev/null +++ b/source/schemas/common/types/location_summary.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/location_summary.json", + "title": "Location Summary", + "description": "A summary of a business location.", + "type": "object", + "ucp_shared_request": true, + "required": ["id", "name"], + "properties": { + "id": { + "type": "string", + "description": "Stable, opaque, Business-scoped Location identifier.", + "ucp_request": "required" + }, + "name": { + "type": "string", + "description": "Buyer-facing, Business-owned display name.", + "ucp_request": "omit" + }, + "address": { + "$ref": "postal_address.json", + "description": "Physical address of the location.", + "ucp_request": "omit" + } + } +} diff --git a/source/schemas/shopping/fulfillment.json b/source/schemas/shopping/fulfillment.json index af6ca1e93..c1101ef6d 100644 --- a/source/schemas/shopping/fulfillment.json +++ b/source/schemas/shopping/fulfillment.json @@ -41,7 +41,7 @@ }, "location": { "type": "string", - "description": "The location resolved for this method, where appropriate (e.g. the pickup store or location), as a location id." + "description": "Stable, opaque, Business-scoped Location ID. The Business recognizes this ID when the Platform submits it for this method. Recognition does not reserve inventory or guarantee eligibility, and current terms are revalidated." }, "options": { "type": "array", diff --git a/source/schemas/shopping/types/fulfillment_destination.json b/source/schemas/shopping/types/fulfillment_destination.json index cc6708dc9..9ad84a69a 100644 --- a/source/schemas/shopping/types/fulfillment_destination.json +++ b/source/schemas/shopping/types/fulfillment_destination.json @@ -5,12 +5,37 @@ "description": "A destination for fulfillment.", "type": "object", "ucp_shared_request": true, - "oneOf": [ + "required": ["type", "id"], + "properties": { + "type": { + "type": "string", + "description": "Fulfillment destination type. Well-known values: `shipping_address`, `business_location`. Additional values are extension-defined and active only when their defining extension is negotiated.", + "ucp_request": "required" + }, + "id": { + "type": "string", + "description": "Fulfillment destination identifier.", + "ucp_request": "optional" + } + }, + "allOf": [ { - "$ref": "shipping_destination.json" + "if": { + "properties": { "type": { "const": "shipping_address" } }, + "required": ["type"] + }, + "then": { + "$ref": "shipping_destination.json" + } }, { - "$ref": "retail_location.json" + "if": { + "properties": { "type": { "const": "business_location" } }, + "required": ["type"] + }, + "then": { + "$ref": "location_destination.json" + } } ] } diff --git a/source/schemas/shopping/types/fulfillment_method.json b/source/schemas/shopping/types/fulfillment_method.json index c6d649256..90a2750a4 100644 --- a/source/schemas/shopping/types/fulfillment_method.json +++ b/source/schemas/shopping/types/fulfillment_method.json @@ -25,7 +25,7 @@ }, "destinations": { "type": "array", - "description": "Available destinations. For shipping: addresses. For pickup: retail locations.", + "description": "Available destinations. For shipping: shipping addresses. For pickup: business locations.", "items": { "$ref": "fulfillment_destination.json" } }, "selected_destination_id": { diff --git a/source/schemas/shopping/types/location_destination.json b/source/schemas/shopping/types/location_destination.json new file mode 100644 index 000000000..85e2fa891 --- /dev/null +++ b/source/schemas/shopping/types/location_destination.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/location_destination.json", + "title": "Business Location Destination", + "description": "A business location fulfillment destination.", + "type": "object", + "ucp_shared_request": true, + "allOf": [ + { + "$ref": "../../common/types/location_summary.json" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "const": "business_location", + "description": "Destination type discriminator.", + "ucp_request": "required" + } + } + } + ] +} diff --git a/source/schemas/shopping/types/retail_location.json b/source/schemas/shopping/types/retail_location.json deleted file mode 100644 index 2e9d5d88f..000000000 --- a/source/schemas/shopping/types/retail_location.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ucp.dev/schemas/shopping/types/retail_location.json", - "title": "Retail Location", - "description": "A pickup location (retail store, locker, etc.).", - "type": "object", - "ucp_shared_request": true, - "required": ["id", "name"], - "additionalProperties": true, - "properties": { - "id": { - "type": "string", - "description": "Unique location identifier.", - "ucp_request": "omit" - }, - "name": { - "type": "string", - "description": "Location name (e.g., store name)." - }, - "address": { - "$ref": "../../common/types/postal_address.json", - "description": "Physical address of the location." - } - } -} diff --git a/source/schemas/shopping/types/shipping_destination.json b/source/schemas/shopping/types/shipping_destination.json index ec77b9789..f7d565c34 100644 --- a/source/schemas/shopping/types/shipping_destination.json +++ b/source/schemas/shopping/types/shipping_destination.json @@ -16,10 +16,17 @@ "type": "string", "description": "ID specific to this shipping destination.", "ucp_request": "optional" + }, + "type": { + "type": "string", + "const": "shipping_address", + "description": "Destination type discriminator.", + "ucp_request": "required" } }, "required": [ - "id" + "id", + "type" ] } ] From 455ab3217ac33c66ce47a07f348a41d145b901f5 Mon Sep 17 00:00:00 2001 From: Lee Richmond Date: Thu, 6 Aug 2026 00:34:06 -0400 Subject: [PATCH 2/5] fix!: discriminate destinations at the method level (#689) * fix!: discriminate destinations at the method level; type response-only in requests A fulfillment method's type selects the shape of its entire subtree, destinations included: a shipping method has shipping-address destinations, a pickup method has business-location destinations, and extension-defined method types define their own. Polymorphism is resolved at the parent, so request destinations need no per-object discriminator. - fulfillment_method branches per method type; the generic fulfillment_destination union is no longer referenced by schemas (kept for response documentation). - Destination type is required in responses, optional in requests. - destinations under pickup is response-only (ucp_request omit inside the pickup branch): under strict resolution the Platform cannot write business locations. selected_destination_id is the sole selection channel and accepts any Business-scoped Location ID the Business recognizes for the method, including IDs not yet enumerated (#589 handoff). - dependentRequired: a request that writes destinations[] must carry the method's type. - Removed explicit additionalProperties:true from fulfillment_method (behavior-neutral in open validation; lets strict sealing work). - Existing request wire shapes are unchanged; responses gain the required type field. Assisted-By: devx/296664b9-53b6-409a-989a-ace9d3348247 * clarify directional destination typing Fulfillment responses always self-describe with a required destination type, while Platform requests follow the enclosing method's authorship contract. Clarify that untyped destinations under the well-known shipping method default to Shipping Destination, pickup destinations are Business-authored and selected through selected_destination_id, and other method types define their own request shape and Platform writability. Remove the unsupported suggestion that an alternate destination type can be selected under core shipping. An ID-only saved or provider-held address remains a Shipping Destination; provider provenance or additional fields require a negotiated extension contract. --------- Co-authored-by: Ilya Grigorik --- docs/specification/checkout-mcp.md | 1 - docs/specification/checkout-rest.md | 2 - docs/specification/embedded-checkout.md | 2 - docs/specification/fulfillment.md | 106 ++++++++++++++---- docs/specification/playground.md | 4 +- .../types/fulfillment_destination.json | 4 +- .../shopping/types/fulfillment_method.json | 98 +++++++++++++--- .../shopping/types/location_destination.json | 6 +- .../shopping/types/shipping_destination.json | 4 +- 9 files changed, 178 insertions(+), 49 deletions(-) diff --git a/docs/specification/checkout-mcp.md b/docs/specification/checkout-mcp.md index 5e126da8a..c306afab3 100644 --- a/docs/specification/checkout-mcp.md +++ b/docs/specification/checkout-mcp.md @@ -190,7 +190,6 @@ Maps to the [Create Checkout](checkout.md#create-checkout) operation. "type": "shipping", "destinations": [ { - "type": "shipping_address", "street_address": "123 Main St", "address_locality": "Springfield", "address_region": "IL", diff --git a/docs/specification/checkout-rest.md b/docs/specification/checkout-rest.md index 809fb01a6..d4a8616ef 100644 --- a/docs/specification/checkout-rest.md +++ b/docs/specification/checkout-rest.md @@ -406,7 +406,6 @@ references. "type": "shipping", "destinations": [ { - "type": "shipping_address", "street_address": "123 Main St", "address_locality": "Springfield", "address_region": "IL", @@ -613,7 +612,6 @@ Follow-up calls after initial `fulfillment` data to update selection. "selected_destination_id": "dest_home", "destinations": [ { - "type": "shipping_address", "id": "dest_home", "street_address": "123 Main St", "address_locality": "Springfield", diff --git a/docs/specification/embedded-checkout.md b/docs/specification/embedded-checkout.md index 6deeeb1ef..ef9ef07a1 100644 --- a/docs/specification/embedded-checkout.md +++ b/docs/specification/embedded-checkout.md @@ -1306,7 +1306,6 @@ method. "selected_destination_id": "address_123", "destinations": [ { - "type": "shipping_address", "id": "address_123", "street_address": "456 Old Street" // ... @@ -1354,7 +1353,6 @@ rather than attempting to merge the new data with existing state. "selected_destination_id": "address_789", "destinations": [ { - "type": "shipping_address", "id": "address_789", "first_name": "John", "last_name": "Doe", diff --git a/docs/specification/fulfillment.md b/docs/specification/fulfillment.md index 8e4c61ace..b6c0b7cb8 100644 --- a/docs/specification/fulfillment.md +++ b/docs/specification/fulfillment.md @@ -179,10 +179,39 @@ method. ## Destinations A fulfillment method's `type` describes how items are fulfilled, while each -destination's `type` describes where fulfillment occurs. These discriminators -are independent; neither implies the other. +destination's `type` describes where fulfillment occurs. The two +discriminators play different roles by direction: in Business responses, +every destination carries a required `type`, so responses are +self-describing; in Platform requests, the method's contract determines who +authors `destinations[]` and MAY define a default destination shape when +`type` is omitted. -Every destination has a required, open `type`. The well-known values are: +Destination authorship — whether the Platform may write entries into a +method's `destinations[]` — is keyed by the method's `type`: + +| Method `type` | Request `destinations[]` | +| --- | --- | +| `shipping` | Platform-writable. The Platform writes the Buyer's shipping-address facts. | +| `pickup` | Not Platform-writable (schema-enforced). The Business enumerates locations in its response; the Platform selects one by ID (see [Selection and Location Identity](#selection-and-location-identity)). | +| other method type | The method's defining contract specifies the destination shape and whether it is Platform-writable. | + +Destination `type` is **required in responses and optional in requests**. In +responses, every destination carries a required, open `type`, so destinations +remain self-describing wherever they appear. In requests, the method's +contract defines the destination shape: + +* Under a well-known `shipping` method, every destination is a Shipping + Destination: a destination that omits `type` defaults to + `shipping_address`. +* Under a well-known `pickup` method, destinations are response-only; the + Platform selects a location via `selected_destination_id` (see + [Selection and Location Identity](#selection-and-location-identity)). +* Under any other method type, the method's defining contract — a future + revision of this specification or a negotiated extension — specifies the + request destination shape and whether it is Platform-writable. + +A request that includes `destinations[]` MUST also include the method's +`type`. The well-known values are: | Value | Meaning | | --- | --- | @@ -195,27 +224,59 @@ specific to such a value are validated by the negotiated extension's schema. ### Shipping Destination For a Shipping Destination, the Platform supplies shipping-address facts as -flat Postal Address fields directly on the destination. The Platform **MUST** -include `type: "shipping_address"` and **MAY** include `id` in its request. The -Business **MUST** include `type: "shipping_address"` and assign `id` in its -response. +flat Postal Address fields directly on the destination. The Platform **MAY** +include `id` and **MAY** include `type: "shipping_address"` in its request. +The Business **MUST** include `type: "shipping_address"` and assign `id` in +its response. An `id`-only destination that references a saved or +provider-held address is still a Shipping Destination; conveying provider +provenance or additional fields requires a negotiated extension contract. + +#### Platform Request + + +```json +{ + "street_address": "123 Main St", + "address_locality": "Springfield", + "address_region": "IL", + "postal_code": "62701", + "address_country": "US" +} +``` + +#### Business Response + + +```json +{ + "type": "shipping_address", + "id": "dest_1", + "street_address": "123 Main St", + "address_locality": "Springfield", + "address_region": "IL", + "postal_code": "62701", + "address_country": "US" +} +``` ### Business Location Destination -For a Business Location Destination, the Platform selects the location with a -stable, opaque, Business-scoped `id`. The Platform **MUST** include -`type: "business_location"` and `id` in its request and **MUST NOT** include the -Business-owned `name` or `address`. The Business **MUST** return +Business Location Destinations appear only in responses; `destinations[]` on a +`pickup` method is not a request field. The Platform **MUST NOT** write +Business Location Destinations into `destinations[]`; it selects a location by +submitting its stable, opaque, Business-scoped ID as +`selected_destination_id`. The Business **MUST** return `type: "business_location"`, `id`, and its Buyer-facing `name`, and **MAY** return its Postal Address in `address`. #### Platform Request - + ```json { - "type": "business_location", - "id": "loc_downtown" + "type": "pickup", + "line_item_ids": ["shirt", "pants"], + "selected_destination_id": "loc_downtown" } ``` @@ -240,12 +301,17 @@ return its Postal Address in `address`. ### Selection and Location Identity `selected_destination_id` identifies the selected destination in a method by -its `id`. When Catalog represents a location as applicable to a particular -fulfillment method, the Business **MUST** recognize the same Business-scoped ID -when the Platform submits it in Checkout for that method, including as -`selected_destination_id`. The Business **MUST** revalidate current availability -and terms during Checkout; recognition does not reserve inventory or guarantee -eligibility. +its `id`, and is the sole channel for selecting a Business Location. It accepts +any stable, Business-scoped Location ID the Business recognizes for that +method, including IDs the Business has not (yet) enumerated in that method's +`destinations[]` — for example an ID discovered through Catalog or a separately +negotiated Location capability. When Catalog represents a location as +applicable to a particular fulfillment method, the Business **MUST** recognize +the same Business-scoped ID when the Platform submits it in Checkout for that +method, including as `selected_destination_id`, and **MUST** return the +corresponding typed destination in `destinations[]` in its response. The +Business **MUST** revalidate current availability and terms during Checkout; +recognition does not reserve inventory or guarantee eligibility. ## Rendering diff --git a/docs/specification/playground.md b/docs/specification/playground.md index 266800fa9..38d154c9d 100644 --- a/docs/specification/playground.md +++ b/docs/specification/playground.md @@ -971,7 +971,7 @@ class UcpApp { req.buyer.email = "test@example.com"; req.buyer.name = "Test User"; } else { - req.fulfillment.methods = [{ type: 'shipping', destinations: [{ type: "shipping_address", id: "addr_1", street_address: "123 Main St", address_locality: "Tech City", address_country: "US", postal_code: "94103" }] }]; + req.fulfillment.methods = [{ type: 'shipping', destinations: [{ id: "addr_1", street_address: "123 Main St", address_locality: "Tech City", address_country: "US", postal_code: "94103" }] }]; } } @@ -998,7 +998,7 @@ class UcpApp { patch.fulfillment = { methods: [{ type: "shipping", - destinations: [{ type: "shipping_address", id: "addr_1", street_address: "123 Main St", address_locality: "Tech City", address_country: "US", postal_code: "94103" }] + destinations: [{ id: "addr_1", street_address: "123 Main St", address_locality: "Tech City", address_country: "US", postal_code: "94103" }] }] }; } diff --git a/source/schemas/shopping/types/fulfillment_destination.json b/source/schemas/shopping/types/fulfillment_destination.json index 9ad84a69a..1a1108e32 100644 --- a/source/schemas/shopping/types/fulfillment_destination.json +++ b/source/schemas/shopping/types/fulfillment_destination.json @@ -9,8 +9,8 @@ "properties": { "type": { "type": "string", - "description": "Fulfillment destination type. Well-known values: `shipping_address`, `business_location`. Additional values are extension-defined and active only when their defining extension is negotiated.", - "ucp_request": "required" + "description": "Destination contract discriminator. Required in Business responses and optional in Platform requests. Well-known values: `shipping_address`, `business_location`. The enclosing method contract defines request defaults and which fields the Platform may write; negotiated extensions define additional values.", + "ucp_request": "optional" }, "id": { "type": "string", diff --git a/source/schemas/shopping/types/fulfillment_method.json b/source/schemas/shopping/types/fulfillment_method.json index 90a2750a4..f19336624 100644 --- a/source/schemas/shopping/types/fulfillment_method.json +++ b/source/schemas/shopping/types/fulfillment_method.json @@ -4,38 +4,106 @@ "title": "Fulfillment Method", "description": "A fulfillment method with destinations and groups.", "type": "object", - "required": ["id", "type", "line_item_ids"], - "additionalProperties": true, + "required": [ + "id", + "type", + "line_item_ids" + ], "properties": { "id": { "type": "string", "description": "Unique fulfillment method identifier.", - "ucp_request": {"create": "omit", "update": "optional"} + "ucp_request": { + "create": "omit", + "update": "optional" + } }, "type": { "type": "string", "description": "Fulfillment method type. Well-known values: `shipping`, `pickup`. Businesses MAY use additional values.", - "ucp_request": {"create": "required", "update": "optional"} + "ucp_request": { + "create": "required", + "update": "optional" + } }, "line_item_ids": { "type": "array", "description": "Line item IDs fulfilled via this method.", - "items": { "type": "string" }, - "ucp_request": {"create": "optional", "update": "required"} - }, - "destinations": { - "type": "array", - "description": "Available destinations. For shipping: shipping addresses. For pickup: business locations.", - "items": { "$ref": "fulfillment_destination.json" } + "items": { + "type": "string" + }, + "ucp_request": { + "create": "optional", + "update": "required" + } }, "selected_destination_id": { - "type": ["string", "null"], - "description": "ID of the selected destination." + "type": [ + "string", + "null" + ], + "description": "ID of the selected destination. Accepts any stable, Business-scoped ID the Business recognizes for this method, including Location IDs not yet enumerated in `destinations`." }, "groups": { "type": "array", "description": "Fulfillment groups for selecting options. Agent sets selected_option_id on groups to choose shipping method.", - "items": { "$ref": "fulfillment_group.json" } + "items": { + "$ref": "fulfillment_group.json" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "shipping" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "destinations": { + "type": "array", + "description": "Platform-authored shipping addresses for this method.", + "items": { + "$ref": "shipping_destination.json" + } + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "pickup" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "destinations": { + "type": "array", + "description": "Business-authored business locations able to fulfill this method. Response-only: the Platform selects one via `selected_destination_id`, never writes one.", + "ucp_request": "omit", + "items": { + "$ref": "location_destination.json" + } + } + } + } } + ], + "dependentRequired": { + "destinations": [ + "type" + ] } -} +} \ No newline at end of file diff --git a/source/schemas/shopping/types/location_destination.json b/source/schemas/shopping/types/location_destination.json index 85e2fa891..1c4c7501e 100644 --- a/source/schemas/shopping/types/location_destination.json +++ b/source/schemas/shopping/types/location_destination.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://ucp.dev/schemas/shopping/types/location_destination.json", "title": "Business Location Destination", - "description": "A business location fulfillment destination.", + "description": "A business location fulfillment destination. Business-authored and response-only: the Platform selects a location via `selected_destination_id` rather than writing destinations.", "type": "object", "ucp_shared_request": true, "allOf": [ @@ -16,8 +16,8 @@ "type": { "type": "string", "const": "business_location", - "description": "Destination type discriminator.", - "ucp_request": "required" + "description": "Destination type discriminator. Response-only.", + "ucp_request": "omit" } } } diff --git a/source/schemas/shopping/types/shipping_destination.json b/source/schemas/shopping/types/shipping_destination.json index f7d565c34..3b572441d 100644 --- a/source/schemas/shopping/types/shipping_destination.json +++ b/source/schemas/shopping/types/shipping_destination.json @@ -20,8 +20,8 @@ "type": { "type": "string", "const": "shipping_address", - "description": "Destination type discriminator.", - "ucp_request": "required" + "description": "Destination type discriminator. Required in responses; optional in requests.", + "ucp_request": "optional" } }, "required": [ From 435a2ff5eaf11f9491a977443632e79729db9d91 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Wed, 5 Aug 2026 21:59:22 -0700 Subject: [PATCH 3/5] fix eof --- source/schemas/shopping/types/fulfillment_method.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/schemas/shopping/types/fulfillment_method.json b/source/schemas/shopping/types/fulfillment_method.json index f19336624..ef1dc335d 100644 --- a/source/schemas/shopping/types/fulfillment_method.json +++ b/source/schemas/shopping/types/fulfillment_method.json @@ -106,4 +106,4 @@ "type" ] } -} \ No newline at end of file +} From 2a6eb252e245079339a5d6a1a07b6c60aaad3485 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Thu, 6 Aug 2026 09:16:18 -0700 Subject: [PATCH 4/5] restore generic destination response Moving destinations entirely into method-specific conditionals orphaned the Fulfillment Destination schema and removed destinations from generic method models and generated documentation. It also left extension-defined method responses without the shared type/id destination contract. Restore response-only destinations on the base Fulfillment Method using the generic Fulfillment Destination schema. Known method branches refine that base: shipping re-enables Platform-writable request destinations, while pickup destinations remain response-only. This keeps extension-defined method responses typed, restores destinations to generated models and field tables, and makes the generic schema normative instead of documentation-only. --- source/schemas/shopping/types/fulfillment_method.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/source/schemas/shopping/types/fulfillment_method.json b/source/schemas/shopping/types/fulfillment_method.json index ef1dc335d..4a33c7d9f 100644 --- a/source/schemas/shopping/types/fulfillment_method.json +++ b/source/schemas/shopping/types/fulfillment_method.json @@ -37,6 +37,14 @@ "update": "required" } }, + "destinations": { + "type": "array", + "description": "Available destinations for this method. In Business responses, each destination carries a `type` and `id`.", + "ucp_request": "omit", + "items": { + "$ref": "fulfillment_destination.json" + } + }, "selected_destination_id": { "type": [ "string", @@ -69,6 +77,7 @@ "destinations": { "type": "array", "description": "Platform-authored shipping addresses for this method.", + "ucp_request": "optional", "items": { "$ref": "shipping_destination.json" } From 373b20d4ba9c22b986e5e1d6544cafe6d6e03697 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Mon, 10 Aug 2026 08:14:51 -0700 Subject: [PATCH 5/5] style(schema): remove redundant destination optionality text --- source/schemas/shopping/types/shipping_destination.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/schemas/shopping/types/shipping_destination.json b/source/schemas/shopping/types/shipping_destination.json index 3b572441d..b66288309 100644 --- a/source/schemas/shopping/types/shipping_destination.json +++ b/source/schemas/shopping/types/shipping_destination.json @@ -20,7 +20,7 @@ "type": { "type": "string", "const": "shipping_address", - "description": "Destination type discriminator. Required in responses; optional in requests.", + "description": "Destination type discriminator.", "ucp_request": "optional" } },