diff --git a/docs/specification/fulfillment.md b/docs/specification/fulfillment.md index ffb5658e8..8797c3758 100644 --- a/docs/specification/fulfillment.md +++ b/docs/specification/fulfillment.md @@ -19,20 +19,22 @@ ## Overview The fulfillment extension enables businesses to advertise support for physical -goods fulfillment (shipping, pickup, etc). - -This extension adds a `fulfillment` field to Checkout and/or Catalog: - -* **Checkout** (`dev.ucp.shopping.checkout`) — selection and cost: which - items go where, by which method, at what price and ETA. +goods fulfillment (shipping, pickup, etc). Each parent capability opts in +independently, with a parent-specific projection: + +* **Checkout** (`dev.ucp.shopping.checkout`) — location-aware context plus + selection and cost: which items go where, by which method, at what price + and ETA. +* **Cart** (`dev.ucp.shopping.cart`) — location-aware context only; it does not + add methods, destinations, groups, options, or selection IDs. * **Catalog** (`dev.ucp.shopping.catalog.search` and - `dev.ucp.shopping.catalog.lookup`) — discovery: a variant advertises the - fulfillment options available for it, based on the provided buyer - context. See [Catalog Discovery](#catalog-discovery). + `dev.ucp.shopping.catalog.lookup`) — location-aware request context and + discovery: a variant advertises the fulfillment options available for it. + See [Catalog Discovery](#catalog-discovery). On Checkout, the `fulfillment` field contains: -* `methods[]` — fulfillment methods applicable to cart items (shipping, pickup, etc.) +* `methods[]` — fulfillment methods applicable to line items (shipping, pickup, etc.) * `line_item_ids` — which items this method fulfills * `destinations[]` — where to fulfill (address, store location) * `groups[]` — business-generated packages, each with selectable `options[]` @@ -53,6 +55,55 @@ On Checkout, the `fulfillment` field contains: * `selected_option_id` = `options[0].id` 🔘✅ In-Store Pickup * `options[1]` 🔘 Curbside Pickup +### Location Context + +Fulfillment adds an optional `location` to the parent `context`. `location` is +accepted on Catalog requests and on Cart and Checkout Create and Update +requests. + +These fields have distinct semantic roles; they are not precision levels of the +same location: + +| Field | Role | +| --- | --- | +| `context.location` | Provisional, non-binding Business-location anchor for location-scoped pricing, availability, estimates, or initial options—roughly, “evaluate this experience at this Business location.” | +| Catalog `filters.fulfills_to` | Explicit fulfillment destination and filter: “can this be fulfilled to here?” | +| Catalog response `variants[].fulfillment.methods[].location` | Business location for which the availability of a place-based method was resolved. | +| Checkout `selected_destination_id` or authoritative fulfillment address | Explicit destination for the affected fulfillment scope; it supersedes `context.location` there. | + +**Continuity example.** The same Business-scoped Location Identifier can appear +in different fields without collapsing their roles. Catalog may report a +place-based method at `loc_123`. A Platform continuing that location-scoped +experience can carry `loc_123` as Cart `context.location`. Cart-to-Checkout +conversion carries `context.location` into Checkout, allowing the Business to +surface that location as the initial fulfillment choice. Once the Platform +selects it through `selected_destination_id`, it becomes the explicit +destination for the affected fulfillment scope. + +#### Platform + +* A Platform **MUST NOT** send a location ID established by one Business to a + different Business. It **MAY** provide an ID learned from a Fulfillment + response or another mechanism operated by the same Business. +* A Platform **MUST NOT** treat `context.location` as a binding fulfillment + destination. + +#### Business + +* A Business **MUST** ignore an unknown or inapplicable `context.location` and + return a successful response for an otherwise valid request. It **MAY** + report the ignored value through a warning in `messages[]`. +* Catalog confirms resolution through the applicable + `variants[].fulfillment.methods[].location`; top-level response context is + not part of this extension's Catalog response contract. +* For Catalog fulfillment destination and availability resolution, a Business + **MUST** use explicit `filters.fulfills_to` instead of + `context.address_country`, `context.address_region`, `context.postal_code`, + or `context.location`. For the Checkout scope governed by an + explicit `selected_destination_id` or authoritative fulfillment address, + the Business **MUST** use that destination or address instead of + `context.location`. + ## Schema Fulfillment applies only to items requiring physical delivery. Items not @@ -69,6 +120,10 @@ method. {{ schema_fields('types/fulfillment_resp', 'fulfillment') }} +#### Fulfillment Context + +{{ extension_schema_fields('fulfillment.json#/$defs/fulfillment_context', 'fulfillment') }} + #### Fulfillment Method {{ schema_fields('types/fulfillment_method_resp', 'fulfillment') }} @@ -312,10 +367,11 @@ method has: * `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 Identifier](glossary.md#commerce) for that method. Across UCP + surfaces exposed by the same Business, the same value identifies the same + location; the containing field defines that location's role. A Business + that advertises pickup at a `location` **MUST** accept that same ID as + `selected_destination_id` for that method in Checkout. * `options` — concrete fulfillment choices within this method (e.g. Standard, Express); see [Options](#options). Optional. @@ -334,8 +390,8 @@ variant-level value. A method MAY carry `options[]`, a representative subset of its fulfillment options — not an exhaustive list. Without a destination or full cart, catalog SHOULD preview meaningful boundary options for the buyer (e.g. -cheapest, fastest); the full, high-resolution set is negotiated in cart and -checkout once those are known. +cheapest, fastest); the full, high-resolution set is negotiated in checkout +once the line items and destination are known. Each option carries an `id` and a `title` (a short label distinguishing it from siblings), plus an optional renderable `description` for context. These @@ -346,11 +402,11 @@ none, surfacing only `type`, `description`, and `availability`; options are nested directly under the method, with no group layer (unlike checkout `methods[].groups[].options[]`). -A discovered option `id` lets a buyer's choice carry forward: a business -SHOULD accept the same id as `selected_option_id` in cart and checkout. -The id is a best-effort handle, not a guaranteed match — an option -discovered for a single product may differ in a cart, where other -products, quantities, and combined fulfillment modify the options. +A discovered option `id` lets a buyer's choice carry forward to Checkout: a +Business **SHOULD** accept the same ID as `selected_option_id` unless other +products, quantities, or combined fulfillment change the available options. +The ID is a best-effort handle, not a guaranteed match. Cart does not carry +option IDs or other full Fulfillment structures. ### Shapes @@ -376,10 +432,14 @@ products, quantities, and combined fulfillment modify the options. ### Location and method: `context` and `filters` -* **`context`** (`address_country` / `address_region` / `postal_code`) is - where the *buyer* is — a non-binding hint the business uses to report - `availability`. On a market-scoped catalog it MAY narrow results; - otherwise it annotates rather than removes them. +* **`context`** provides non-binding hints that a Business uses to report + `availability`. Its coarse locality fields (`address_country` / + `address_region` / `postal_code`) describe the Buyer's locality. Its + `location` is a provisional Business-location anchor for store-scoped + pricing, availability, or initial fulfillment options; it need not be the + Buyer's physical location. On a market-scoped Catalog, the Business + **MAY** use context to narrow results; otherwise, context annotates rather + than removes them. * **`filters.fulfills_to`** is where the order is *fulfilled to* — a single destination, named by value (a coarse address: `address_country` / `address_region` / `postal_code`) or by reference (a `location` id — a @@ -391,9 +451,11 @@ products, quantities, and combined fulfillment modify the options. * **`filters.methods`** restricts results to specific method types (e.g. `["pickup"]`). -Provide location once: `context` for where the buyer is, `fulfills_to` for -an explicit destination. When both are present, `fulfills_to` supersedes -`context`. +`context` carries non-binding Buyer locality or a provisional +Business-location anchor; `fulfills_to` names an explicit destination. When +both are present, `fulfills_to` supersedes `context.address_country`, +`context.address_region`, `context.postal_code`, or `context.location` for +fulfillment destination and availability resolution. ### Example @@ -467,10 +529,17 @@ carries none — `options` is optional. Businesses and platforms declare fulfillment constraints in their profiles. Businesses fetch platform profiles to adapt responses accordingly. -The `extends` array lists the capabilities this extension adds fulfillment -to. Checkout is the authoritative, transactional surface; catalog is for -discovery. A business lists the catalog capabilities in `extends` to expose -fulfillment on catalog, or omits them to scope itself to checkout only. +The `extends` array lists the parent capabilities to which this extension +applies. Checkout is the authoritative, transactional surface; catalog is for +discovery; Cart receives only location-aware context. Each parent is an +independent opt-in, so advertising fulfillment for one parent does not activate +it for another. + +A Platform or Business that advertises Cart and extends either Catalog +capability with Fulfillment **SHOULD** also extend Cart with Fulfillment to +preserve `context.location` across the Catalog → Cart → Checkout journey. +Omitting the Cart projection means location-scoped Cart continuity is not +provided. ### Platform Profile @@ -483,8 +552,8 @@ single-group responses. The response shape is always `methods[].groups[]`—the difference is whether `groups.length` can exceed 1 within each method. -Default declaration (single group per method; fulfillment surfaced on -checkout and on catalog discovery): +Default declaration (single group per Checkout method; fulfillment surfaced on +Checkout and Catalog, with location context on Cart): ```json @@ -496,6 +565,7 @@ checkout and on catalog discovery): "schema": "https://ucp.dev/{{ ucp_version }}/schemas/shopping/fulfillment.json", "extends": [ "dev.ucp.shopping.checkout", + "dev.ucp.shopping.cart", "dev.ucp.shopping.catalog.search", "dev.ucp.shopping.catalog.lookup" ] @@ -504,8 +574,8 @@ checkout and on catalog discovery): } ``` -A party that does not expose catalog discovery MAY narrow `extends` to -`"dev.ucp.shopping.checkout"` (string form) or to a single-element array. +A Platform or Business **MAY** narrow `extends` to any parent or set of parents +it supports, using the string form for one parent or an array for several. Opt-in declaration (business MAY return multiple groups per method): @@ -519,6 +589,7 @@ Opt-in declaration (business MAY return multiple groups per method): "schema": "https://ucp.dev/{{ ucp_version }}/schemas/shopping/fulfillment.json", "extends": [ "dev.ucp.shopping.checkout", + "dev.ucp.shopping.cart", "dev.ucp.shopping.catalog.search", "dev.ucp.shopping.catalog.lookup" ], @@ -545,6 +616,7 @@ Businesses declare what fulfillment configurations they support using "schema": "https://ucp.dev/{{ ucp_version }}/schemas/shopping/fulfillment.json", "extends": [ "dev.ucp.shopping.checkout", + "dev.ucp.shopping.cart", "dev.ucp.shopping.catalog.search", "dev.ucp.shopping.catalog.lookup" ], @@ -559,7 +631,7 @@ Businesses declare what fulfillment configurations they support using } ``` -This example says: shipping can go to multiple addresses, and carts can mix +This example says: shipping can go to multiple addresses, and checkout can mix shipping+pickup. ### Business Response Behavior @@ -610,10 +682,10 @@ like any other method. **Example — adding `home_installation`.** No schema change or registration is needed. Emit the value directly as the `type` on catalog and checkout, and -filter with `filters.methods: ["home_installation"]`. For cart and checkout +filter with `filters.methods: ["home_installation"]`. For checkout negotiation, declare its behavior in the business profile `config` — e.g. include `["shipping", "home_installation"]` in `method_combinations` -so a cart can mix shipped and installed items (see +so a checkout can mix shipped and installed items (see [Business Profile](#business-profile)). On a catalog variant's method: diff --git a/docs/specification/glossary.md b/docs/specification/glossary.md index 716e41f7f..83abb4b43 100644 --- a/docs/specification/glossary.md +++ b/docs/specification/glossary.md @@ -40,12 +40,13 @@ acronym in each specification Markdown file spells out the full term (e.g., ## Commerce -| Term | Acronym | Definition | -| :--------------------------- | :------ | :---------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Business** | - | The entity selling goods or services. In UCP, they act as the **Merchant of Record (MoR)**, retaining financial liability and ownership of the order. | -| **Merchant of Record** | MoR | The legal entity responsible for the sale, including financial liability and order ownership. | -| **Payment Service Provider** | PSP | The financial infrastructure provider that processes payments, authorizations, and settlements on behalf of the business. | -| **Platform** | - | The consumer-facing surface (AI agent, app, website) acting on behalf of the user to discover businesses and facilitate commerce. | +| Term | Acronym | Definition | +| :--------------------------- | :------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Business** | - | The entity selling goods or services. In UCP, they act as the **Merchant of Record (MoR)**, retaining financial liability and ownership of the order. | +| **Location Identifier** | ID | An opaque, stable string assigned within a Business namespace. Across UCP surfaces exposed by that Business, the same value identifies the same location; the containing field defines that location's role. | +| **Merchant of Record** | MoR | The legal entity responsible for the sale, including financial liability and order ownership. | +| **Payment Service Provider** | PSP | The financial infrastructure provider that processes payments, authorizations, and settlements on behalf of the business. | +| **Platform** | - | The consumer-facing surface (AI agent, app, website) acting on behalf of the user to discover businesses and facilitate commerce. | ## Payments diff --git a/source/schemas/common/types/location_id.json b/source/schemas/common/types/location_id.json new file mode 100644 index 000000000..d71b60474 --- /dev/null +++ b/source/schemas/common/types/location_id.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/location_id.json", + "title": "Location ID", + "description": "Opaque, stable identifier for a location assigned within a Business namespace. Across UCP surfaces exposed by that Business, the same value identifies the same location; the containing field defines the role of that location.", + "type": "string" +} diff --git a/source/schemas/shopping/fulfillment.json b/source/schemas/shopping/fulfillment.json index af6ca1e93..418aa9296 100644 --- a/source/schemas/shopping/fulfillment.json +++ b/source/schemas/shopping/fulfillment.json @@ -3,7 +3,7 @@ "$id": "https://ucp.dev/schemas/shopping/fulfillment.json", "name": "dev.ucp.shopping.fulfillment", "title": "Fulfillment Extension", - "description": "Extends Checkout with fulfillment support using methods, destinations, and groups.", + "description": "Extends Catalog with fulfillment discovery, Cart with location-aware context, and Checkout with hierarchical fulfillment.", "$defs": { "fulfillment_option": { "$ref": "types/fulfillment_option.json" @@ -20,6 +20,27 @@ "fulfillment": { "$ref": "types/fulfillment.json" }, + "fulfillment_context": { + "title": "Fulfillment Context", + "description": "Provisional Business location for location-scoped pricing, availability, and initial fulfillment-option generation.", + "allOf": [ + { + "$ref": "types/context.json" + }, + { + "type": "object", + "additionalProperties": true, + "properties": { + "location": { + "$ref": "../common/types/location_id.json", + "description": "Provisional Business location for pricing, availability, and initial fulfillment-option generation. Explicit fulfillment destinations, selections, and authoritative fulfillment addresses supersede this context.", + "ucp_request": "optional", + "ucp_response": "optional" + } + } + } + ] + }, "catalog_fulfillment_method": { "title": "Catalog Fulfillment Method", "description": "A fulfillment method on a catalog variant: how the variant can be fulfilled, and its availability.", @@ -40,12 +61,12 @@ "description": "Availability of this variant via this method at the specified or inferred location." }, "location": { - "type": "string", - "description": "The location resolved for this method, where appropriate (e.g. the pickup store or location), as a location id." + "$ref": "../common/types/location_id.json", + "description": "The location resolved for this method, where appropriate (e.g. the pickup store or location), as a location ID." }, "options": { "type": "array", - "description": "Fulfillment options for this method (e.g. Standard, Express) to describe per-choice details such as cost, time estimates, etc. Optional and selective: without a destination or full cart, a business SHOULD preview meaningful boundary options (e.g. cheapest, fastest); the full, high-resolution set is negotiated in cart and checkout.", + "description": "Fulfillment options for this method (e.g. Standard, Express) to describe per-choice details such as cost, time estimates, etc. Optional and selective: without a destination or full cart, a business SHOULD preview meaningful boundary options (e.g. cheapest, fastest); the full, high-resolution set is negotiated in Checkout.", "items": { "$ref": "types/fulfillment_option_base.json" } } } @@ -141,7 +162,7 @@ "properties": { "fulfills_to": { "$ref": "types/fulfillment_destination_filter.json", - "description": "Where the order is fulfilled to — may differ from the buyer's `context` location (e.g. a gift). A `location` id, or a coarse address (`address_country`/`address_region`/`postal_code`). Restricts results to what can be fulfilled there and seeds method `availability`. Supersedes `context`." + "description": "Where the order is fulfilled to — may differ from the buyer's `context` location (e.g. a gift). A `location` id, or a coarse address (`address_country`/`address_region`/`postal_code`). Restricts results to what can be fulfilled there and seeds method `availability`. Supersedes `context.address_country`, `context.address_region`, `context.postal_code`, and `context.location` only for fulfillment destination and availability resolution." }, "methods": { "type": "array", @@ -155,37 +176,70 @@ "fulfillment_search_request": { "allOf": [ { "$ref": "catalog_search.json#/$defs/search_request" }, - { "type": "object", "properties": { "filters": { "$ref": "#/$defs/fulfillment_search_filters" } } } + { + "type": "object", + "properties": { + "context": { "$ref": "#/$defs/fulfillment_context" }, + "filters": { "$ref": "#/$defs/fulfillment_search_filters" } + } + } ] }, "fulfillment_search_response": { "allOf": [ { "$ref": "catalog_search.json#/$defs/search_response" }, - { "type": "object", "properties": { "products": { "type": "array", "items": { "$ref": "#/$defs/fulfillment_product" } } } } + { + "type": "object", + "properties": { + "products": { "type": "array", "items": { "$ref": "#/$defs/fulfillment_product" } } + } + } ] }, "fulfillment_lookup_request": { "allOf": [ { "$ref": "catalog_lookup.json#/$defs/lookup_request" }, - { "type": "object", "properties": { "filters": { "$ref": "#/$defs/fulfillment_search_filters" } } } + { + "type": "object", + "properties": { + "context": { "$ref": "#/$defs/fulfillment_context" }, + "filters": { "$ref": "#/$defs/fulfillment_search_filters" } + } + } ] }, "fulfillment_lookup_response": { "allOf": [ { "$ref": "catalog_lookup.json#/$defs/lookup_response" }, - { "type": "object", "properties": { "products": { "type": "array", "items": { "$ref": "#/$defs/fulfillment_lookup_product" } } } } + { + "type": "object", + "properties": { + "products": { "type": "array", "items": { "$ref": "#/$defs/fulfillment_lookup_product" } } + } + } ] }, "fulfillment_get_product_request": { "allOf": [ { "$ref": "catalog_lookup.json#/$defs/get_product_request" }, - { "type": "object", "properties": { "filters": { "$ref": "#/$defs/fulfillment_search_filters" } } } + { + "type": "object", + "properties": { + "context": { "$ref": "#/$defs/fulfillment_context" }, + "filters": { "$ref": "#/$defs/fulfillment_search_filters" } + } + } ] }, "fulfillment_get_product_response": { "allOf": [ { "$ref": "catalog_lookup.json#/$defs/get_product_response" }, - { "type": "object", "properties": { "product": { "$ref": "#/$defs/fulfillment_detail_product" } } } + { + "type": "object", + "properties": { + "product": { "$ref": "#/$defs/fulfillment_detail_product" } + } + } ] }, "dev.ucp.shopping.catalog.search": { @@ -204,6 +258,27 @@ "get_product_response": { "$ref": "#/$defs/fulfillment_get_product_response" } } }, + "dev.ucp.shopping.cart": { + "title": "Cart with Fulfillment", + "description": "Cart extended with location-aware fulfillment context only.", + "allOf": [ + { + "$ref": "cart.json" + }, + { + "type": "object", + "properties": { + "context": { + "$ref": "#/$defs/fulfillment_context", + "ucp_request": { + "create": "optional", + "update": "optional" + } + } + } + } + ] + }, "dev.ucp.shopping.checkout": { "title": "Checkout with Fulfillment", "description": "Checkout extended with hierarchical fulfillment.", @@ -214,6 +289,14 @@ { "type": "object", "properties": { + "context": { + "$ref": "#/$defs/fulfillment_context", + "ucp_request": { + "create": "optional", + "update": "optional", + "complete": "omit" + } + }, "fulfillment": { "$ref": "#/$defs/fulfillment", "description": "Fulfillment details.", diff --git a/source/schemas/shopping/types/fulfillment_destination_filter.json b/source/schemas/shopping/types/fulfillment_destination_filter.json index 4c96ec11b..a230ce28e 100644 --- a/source/schemas/shopping/types/fulfillment_destination_filter.json +++ b/source/schemas/shopping/types/fulfillment_destination_filter.json @@ -14,7 +14,7 @@ "additionalProperties": true, "properties": { "location": { - "type": "string", + "$ref": "../../common/types/location_id.json", "description": "A reference to the destination (e.g. store, pickup location, saved address)." } } diff --git a/source/schemas/shopping/types/retail_location.json b/source/schemas/shopping/types/retail_location.json index 2e9d5d88f..bbfdca9db 100644 --- a/source/schemas/shopping/types/retail_location.json +++ b/source/schemas/shopping/types/retail_location.json @@ -9,7 +9,7 @@ "additionalProperties": true, "properties": { "id": { - "type": "string", + "$ref": "../../common/types/location_id.json", "description": "Unique location identifier.", "ucp_request": "omit" },