From 28ad3ab40ecdaf8898affe8f146e1ecf71d34164 Mon Sep 17 00:00:00 2001 From: Lee Richmond Date: Wed, 5 Aug 2026 12:24:13 -0400 Subject: [PATCH 1/2] 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 --- docs/specification/checkout-mcp.md | 1 - docs/specification/checkout-rest.md | 2 - docs/specification/embedded-checkout.md | 2 - docs/specification/fulfillment.md | 91 +++++++++++++---- 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, 165 insertions(+), 47 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..958749c06 100644 --- a/docs/specification/fulfillment.md +++ b/docs/specification/fulfillment.md @@ -182,7 +182,27 @@ 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: +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)). | +| extension-defined | The defining extension specifies the destination shape and whether it is Platform-writable. | + +Destination `type` is **required in responses and optional in requests**. When +a request destination omits `type`, the method's `type` implies its shape: an +untyped destination under a `shipping` method validates as a Shipping +Destination, and destinations under an extension-defined method type are +validated by the negotiated extension's schema. A Platform **MAY** include +`type` to disambiguate a destination reference when more than one kind can +appear under a method — for example, an `id`-only destination that references +a Business-managed mailing address versus an entry in an identity provider's +customer address book. In responses, every destination carries a required, +open `type`, so destinations remain self-describing wherever they appear. A +request that includes `destinations[]` MUST also include the method's `type`. +The well-known values are: | Value | Meaning | | --- | --- | @@ -195,27 +215,57 @@ 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. + +#### 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 +290,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..2f8557d22 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": "Fulfillment destination type. Required in responses; optional in requests. When a request destination omits `type`, the enclosing fulfillment method's `type` implies its shape. A Platform MAY include `type` to disambiguate a destination reference when more than one kind can appear under a method. Well-known values: `shipping_address`, `business_location`. Additional values are extension-defined and active only when their defining extension is negotiated.", + "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 47b8edaf90e7e3f5e86a84c9510f4faa34d763fd Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Wed, 5 Aug 2026 14:48:39 -0700 Subject: [PATCH 2/2] 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. --- docs/specification/fulfillment.md | 45 ++++++++++++------- .../types/fulfillment_destination.json | 2 +- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/docs/specification/fulfillment.md b/docs/specification/fulfillment.md index 958749c06..b6c0b7cb8 100644 --- a/docs/specification/fulfillment.md +++ b/docs/specification/fulfillment.md @@ -179,8 +179,12 @@ 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. Destination authorship — whether the Platform may write entries into a method's `destinations[]` — is keyed by the method's `type`: @@ -189,20 +193,25 @@ method's `destinations[]` — is keyed by the method's `type`: | --- | --- | | `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)). | -| extension-defined | The defining extension specifies the destination shape and whether it is Platform-writable. | - -Destination `type` is **required in responses and optional in requests**. When -a request destination omits `type`, the method's `type` implies its shape: an -untyped destination under a `shipping` method validates as a Shipping -Destination, and destinations under an extension-defined method type are -validated by the negotiated extension's schema. A Platform **MAY** include -`type` to disambiguate a destination reference when more than one kind can -appear under a method — for example, an `id`-only destination that references -a Business-managed mailing address versus an entry in an identity provider's -customer address book. In responses, every destination carries a required, -open `type`, so destinations remain self-describing wherever they appear. A -request that includes `destinations[]` MUST also include the method's `type`. -The well-known values are: +| 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 | | --- | --- | @@ -218,7 +227,9 @@ For a Shipping Destination, the Platform supplies shipping-address facts as 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. +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 diff --git a/source/schemas/shopping/types/fulfillment_destination.json b/source/schemas/shopping/types/fulfillment_destination.json index 2f8557d22..1a1108e32 100644 --- a/source/schemas/shopping/types/fulfillment_destination.json +++ b/source/schemas/shopping/types/fulfillment_destination.json @@ -9,7 +9,7 @@ "properties": { "type": { "type": "string", - "description": "Fulfillment destination type. Required in responses; optional in requests. When a request destination omits `type`, the enclosing fulfillment method's `type` implies its shape. A Platform MAY include `type` to disambiguate a destination reference when more than one kind can appear under a method. Well-known values: `shipping_address`, `business_location`. Additional values are extension-defined and active only when their defining extension is negotiated.", + "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": {