From 58366a6b7b279983a7c008aa718fc0c1522bd481 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Fri, 31 Jul 2026 21:52:53 -0700 Subject: [PATCH] add negotiated fulfillment location context Store-scoped Catalog results had no advertised way to preserve the Business location through Cart and into Checkout. Defining the field on base Context would make every Shopping implementation accept an opaque Business-scoped identifier without advertising the behavior that produces or consumes it. Have Fulfillment contribute `context.location` to Catalog requests, Cart, and Checkout through explicit parent composition. Cart receives only the location context, and implementations supporting both Catalog and Cart should extend both to preserve continuity. For example, `loc_123` can scope Catalog availability, continue through Cart, and become explicit only when selected as a Checkout destination. Introduce a shared Location Identifier and reuse it for Fulfillment context, Catalog method locations, destination filters, and retail locations. This preserves one identity across surfaces while each containing field defines its role. Keep `selected_destination_id` generic because it may also reference non-location destinations. --- docs/specification/fulfillment.md | 150 +++++++++++++----- docs/specification/glossary.md | 13 +- source/schemas/common/types/location_id.json | 7 + source/schemas/shopping/fulfillment.json | 105 ++++++++++-- .../types/fulfillment_destination_filter.json | 2 +- .../shopping/types/retail_location.json | 2 +- 6 files changed, 221 insertions(+), 58 deletions(-) create mode 100644 source/schemas/common/types/location_id.json 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" },