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..c306afab3 100644 --- a/docs/specification/checkout-mcp.md +++ b/docs/specification/checkout-mcp.md @@ -278,6 +278,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 +537,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..d4a8616ef 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" @@ -513,6 +514,7 @@ type & addresses. "selected_destination_id": "dest_home", "destinations": [ { + "type": "shipping_address", "id": "dest_home", "street_address": "123 Main St", "address_locality": "Springfield", @@ -712,6 +714,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 +916,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 +1076,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 +1234,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/fulfillment.md b/docs/specification/fulfillment.md index ffb5658e8..b6c0b7cb8 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,143 @@ method. } ``` +## Destinations + +A fulfillment method's `type` describes how items are fulfilled, while each +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`: + +| 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 | +| --- | --- | +| `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 **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 + +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": "pickup", + "line_item_ids": ["shirt", "pants"], + "selected_destination_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`, 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 Fulfillment options are designed for **method-agnostic rendering**. Platforms @@ -311,11 +453,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 +797,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 +869,7 @@ package. "selected_destination_id": "dest_1", "destinations": [ { + "type": "shipping_address", "id": "dest_1", "street_address": "123 Main St", "address_locality": "Springfield", @@ -799,6 +946,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 +992,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..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: [{ id: "addr_1", street: "123 Main St", city: "Tech City", 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: [{ id: "addr_1", street: "123 Main St", city: "Tech City", 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/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..1a1108e32 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": "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", + "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..4a33c7d9f 100644 --- a/source/schemas/shopping/types/fulfillment_method.json +++ b/source/schemas/shopping/types/fulfillment_method.json @@ -4,38 +4,115 @@ "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"} + "items": { + "type": "string" + }, + "ucp_request": { + "create": "optional", + "update": "required" + } }, "destinations": { "type": "array", - "description": "Available destinations. For shipping: addresses. For pickup: retail locations.", - "items": { "$ref": "fulfillment_destination.json" } + "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", "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.", + "ucp_request": "optional", + "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" + ] } } diff --git a/source/schemas/shopping/types/location_destination.json b/source/schemas/shopping/types/location_destination.json new file mode 100644 index 000000000..1c4c7501e --- /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. 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": [ + { + "$ref": "../../common/types/location_summary.json" + }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "const": "business_location", + "description": "Destination type discriminator. Response-only.", + "ucp_request": "omit" + } + } + } + ] +} 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..b66288309 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": "optional" } }, "required": [ - "id" + "id", + "type" ] } ]