From 380659a00b1435983869cb2ee5cb63307439afc1 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Wed, 10 Jun 2026 23:35:08 -0600 Subject: [PATCH 1/2] fulfillment methods on catalog (method-first discovery) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend dev.ucp.shopping.fulfillment to compose onto catalog (search, lookup, get_product) as well as checkout, so a variant advertises how it can be received. The extension adds a `fulfillment` object to whatever it extends — on a variant that's `fulfillment.methods[]`, the same shape as `checkout.fulfillment.methods[]`: "fulfillment": { "methods": [ { "type": "shipping", "destinations": [ { "id": "address", "availability": { "available": true, "status": "in_stock" } } ] }, { "type": "pickup", "destinations": [ { "id": "loc_downtown", "name": "Downtown Store", "address": { "address_country": "CA", "postal_code": "M5B 2H1" }, "availability": { "available": true, "status": "in_stock" } } ] } ] } Key decisions: - Method-first. `methods[]` are peers (shipping, pickup, …), symmetric with checkout, so a fulfillment method is one concept across discovery and checkout. - Place-kind on `type`; monomorphic destination. The kind of place (store, locker, the buyer's address) is the method `type`, not a destination type — so every destination is one shape, `{ id, name?, address?, availability }`, and a new kind is a new `type` value, not a new schema. This avoids a polymorphic destination `oneOf`, which is ambiguous under `additionalProperties: true` (a store also validates as a bare shipping address). For shipping, `id` is the reserved value `address`; for pickup it's the store/location id. - `type` is an open string; well-known values `shipping`, `pickup`; consumers ignore unknowns. This also opens checkout's previously-closed `type` enum, unifying one method-type vocabulary across both surfaces. - Two availability layers: variant.availability -> is the variant purchasable? (source of truth) destination.availability -> gettable via this method, here? (e.g. in stock at a store) A method is available iff it has an available destination. - context vs filters.fulfills_to — distinct location inputs: context.postal_code -> "given where I am, what's available?" hint; never removes results filters.fulfills_to -> "only what I can get at this address" filter; restricts; wins when both present - Advisory: catalog advertises; the buyer selects at checkout, where a discovered destination `id` can become `selected_destination_id`. Schema surface: - fulfillment.json — composes onto catalog.search/lookup (incl. get_product); adds catalog_fulfillment / catalog_fulfillment_method; adds `fulfills_to`. - types/catalog_destination.json (new); types/availability.json (new, shared by variant + destination). - types/fulfillment_method.json — open-string `type` (was a closed enum). - types/variant.json — references the shared availability type. - docs/specification/fulfillment.md — Catalog Discovery, Method Types, example. Direction: this is the discovery contract — cost/time estimates, a store-finder capability, and post-purchase lifecycle build on this same shape additively. --- docs/specification/fulfillment.md | 200 ++++++++++++++++-- source/schemas/shopping/fulfillment.json | 129 +++++++++++ .../schemas/shopping/types/availability.json | 17 ++ .../shopping/types/catalog_destination.json | 27 +++ .../shopping/types/fulfillment_method.json | 5 +- source/schemas/shopping/types/variant.json | 14 +- 6 files changed, 363 insertions(+), 29 deletions(-) create mode 100644 source/schemas/shopping/types/availability.json create mode 100644 source/schemas/shopping/types/catalog_destination.json diff --git a/docs/specification/fulfillment.md b/docs/specification/fulfillment.md index 195b8c309..fad70a599 100644 --- a/docs/specification/fulfillment.md +++ b/docs/specification/fulfillment.md @@ -21,7 +21,16 @@ The fulfillment extension enables businesses to advertise support for physical goods fulfillment (shipping, pickup, etc). -This extension adds a `fulfillment` field to Checkout containing: +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. +* **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). + +On Checkout, the `fulfillment` field contains: * `methods[]` — fulfillment methods applicable to cart items (shipping, pickup, etc.) * `line_item_ids` — which items this method fulfills @@ -280,11 +289,153 @@ If the buyer chooses pickup but the platform doesn't support split fulfillment, the platform **SHOULD** use `continue_url` to hand off to the business's checkout. +## Catalog Discovery + +When the fulfillment extension extends the Catalog capability, each variant +in a catalog response carries a `fulfillment` object listing the fulfillment +methods available for that variant and where each can be received — so a +buyer browsing the catalog can see how an item can be received before +reaching checkout. + +### Methods and destinations + +`fulfillment.methods[]` lists the methods available for a variant. Each +method has: + +* `type` — the fulfillment method (e.g. `shipping`, `pickup`); see + [Method Types](#method-types). +* `destinations[]` — where the item is received via this method, each with + its own `availability`. + +Each destination has: + +* `id` — the destination identifier. For `shipping` it is `address` (the + buyer's address, supplied on the request); otherwise it is the store or + location id. +* `name` and `address` — the location's name and address, when it has one. +* `availability` — whether the variant is available via this method at this + destination. + +A method's `type` tells you what kind of destination it carries. Catalog +only advertises these options; the buyer commits at checkout. A destination +`id` found here can be passed to cart or checkout as +`selected_destination_id`, though checkout can also present its own +destinations to choose from. + +### Shapes + +#### Catalog Fulfillment Method + +{{ extension_schema_fields('fulfillment.json#/$defs/catalog_fulfillment_method', 'fulfillment') }} + +#### Catalog Destination + +{{ schema_fields('types/catalog_destination', 'fulfillment') }} + +#### Availability + +{{ schema_fields('types/availability', 'fulfillment') }} + +### Location: `context` and `filters.fulfills_to` + +The buyer's location can reach a catalog request two ways. They are distinct +use cases: + +* **`context`** (the buyer's coarse address — `address_country` / + `address_region` / `postal_code`) is a non-binding hint. The business + uses it to report `availability` on the methods it returns: *"given + roughly where I am, where is this available?"* It does not remove + results — you still see the variant, annotated with availability. +* **`filters.fulfills_to`** (a postal address) is a filter. It *restricts* + results to variants the business can fulfill to that destination: + *"only show me what I can actually get here."* Like any filter, it + narrows the result set. + +When both are present, `fulfills_to` is authoritative and wins; `context` +is the fallback. + +`fulfills_to` takes an address, not a store id; filtering by a specific +store or locker is not part of this capability. + +### Example + +A variant exposes two fulfillment methods: shipping to the buyer's ship-to +and pickup today at a named store. Each method carries its own destinations, +each with its own availability. + + +```json +{ + "ucp": { "version": "{{ ucp_version }}" }, + "products": [ + { + "id": "prod_kettle", + "title": "Electric Kettle", + "description": { "plain": "1.7L electric kettle." }, + "price_range": { + "min": { "amount": 4999, "currency": "USD" }, + "max": { "amount": 4999, "currency": "USD" } + }, + "variants": [ + { + "id": "var_ss", + "title": "Stainless Steel", + "description": { "plain": "Stainless steel finish." }, + "price": { "amount": 4999, "currency": "USD" }, + "availability": { "available": true, "status": "in_stock" }, + "fulfillment": { + "methods": [ + { + "type": "shipping", + "description": { "plain": "Ships to your address" }, + "destinations": [ + { + "id": "address", + "availability": { "available": true, "status": "in_stock" } + } + ] + }, + { + "type": "pickup", + "description": { "plain": "Pickup today at Downtown Store" }, + "destinations": [ + { + "id": "loc_downtown", + "name": "Downtown Store", + "address": { + "address_locality": "Toronto", + "address_region": "ON", + "address_country": "CA", + "postal_code": "M5B 2H1" + }, + "availability": { "available": true, "status": "in_stock" } + } + ] + } + ] + } + } + ] + } + ] +} +``` + +Each method gives the buyer a way to receive the item, with its own +`availability`. Each method's `description` is directly renderable, so a +platform can present it without recognizing the `type` (see +[Rendering](#rendering)). + ## Configuration 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. + ### Platform Profile Platforms declare their rendering capabilities using `platform_schema`: @@ -296,7 +447,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): +Default declaration (single group per method; fulfillment surfaced on +checkout and on catalog discovery): ```json @@ -306,12 +458,19 @@ Default declaration (single group per method): "version": "{{ ucp_version }}", "spec": "https://ucp.dev/{{ ucp_version }}/specification/fulfillment", "schema": "https://ucp.dev/{{ ucp_version }}/schemas/shopping/fulfillment.json", - "extends": "dev.ucp.shopping.checkout" + "extends": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.catalog.search", + "dev.ucp.shopping.catalog.lookup" + ] } ] } ``` +A party that does not expose catalog discovery MAY narrow `extends` to +`"dev.ucp.shopping.checkout"` (string form) or to a single-element array. + Opt-in declaration (business MAY return multiple groups per method): @@ -322,7 +481,11 @@ Opt-in declaration (business MAY return multiple groups per method): "version": "{{ ucp_version }}", "spec": "https://ucp.dev/{{ ucp_version }}/specification/fulfillment", "schema": "https://ucp.dev/{{ ucp_version }}/schemas/shopping/fulfillment.json", - "extends": "dev.ucp.shopping.checkout", + "extends": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.catalog.search", + "dev.ucp.shopping.catalog.lookup" + ], "config": { "supports_multi_group": true } } ] @@ -344,7 +507,11 @@ Businesses declare what fulfillment configurations they support using "version": "{{ ucp_version }}", "spec": "https://ucp.dev/{{ ucp_version }}/specification/fulfillment", "schema": "https://ucp.dev/{{ ucp_version }}/schemas/shopping/fulfillment.json", - "extends": "dev.ucp.shopping.checkout", + "extends": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.catalog.search", + "dev.ucp.shopping.catalog.lookup" + ], "config": { "allows_multi_destination": { "shipping": true @@ -375,18 +542,23 @@ shipping+pickup. * Platform is responsible for rendering group selection UI (e.g., choose shipping speed per package) -### Adding New Methods +### Method Types + +`fulfillment_method.type` (checkout) and `catalog_fulfillment_method.type` +(catalog) share one vocabulary of fulfillment method types. The field is an +open string: consumers ignore values they do not recognize. -Extensions that extend fulfillment with new method types (e.g., -`local_delivery`) **MUST** add an extension schema that: +**Well-known values:** -1. Adds the method to the `type` enum in `fulfillment_method` -2. Adds corresponding business config options: - * `allows_multi_destination.local_delivery: boolean` - * `allows_method_combinations` items enum (includes `"local_delivery"`) +| Value | Meaning | +| --- | --- | +| `shipping` | Carrier ships to the buyer's address. | +| `pickup` | Buyer picks up at a named location. | -Note: Platform's `supports_multi_group` is method-agnostic (single boolean), -so no extension needed. +**Adding method types.** `type` is an open string: businesses MAY use +additional values, and consumers ignore values they do not recognize. New +well-known values are added via a UCP RFC and a `dev.ucp.shopping.fulfillment` +version bump. ## Examples diff --git a/source/schemas/shopping/fulfillment.json b/source/schemas/shopping/fulfillment.json index f2440168c..2d24b43d4 100644 --- a/source/schemas/shopping/fulfillment.json +++ b/source/schemas/shopping/fulfillment.json @@ -20,6 +20,135 @@ "fulfillment": { "$ref": "types/fulfillment.json" }, + "catalog_fulfillment_method": { + "title": "Catalog Fulfillment Method", + "description": "A fulfillment method on a catalog variant: how the variant can be received, and where.", + "type": "object", + "additionalProperties": true, + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "Fulfillment method type. Well-known values: `shipping`, `pickup`. Businesses MAY use additional values." + }, + "description": { + "$ref": "../common/types/description.json", + "description": "Short buyer-facing summary (e.g. 'Ships in 2–4 business days')." + }, + "destinations": { + "type": "array", + "description": "Buyer-visible destinations for this method, each with availability.", + "items": { "$ref": "types/catalog_destination.json" } + } + } + }, + "catalog_fulfillment": { + "title": "Catalog Fulfillment", + "description": "How a catalog variant can be received. Mirrors checkout `fulfillment`.", + "type": "object", + "additionalProperties": true, + "properties": { + "methods": { + "type": "array", + "description": "Fulfillment methods for this variant.", + "items": { "$ref": "#/$defs/catalog_fulfillment_method" } + } + } + }, + "fulfillment_variant": { + "description": "A catalog variant with fulfillment.", + "allOf": [ + { "$ref": "types/variant.json" }, + { + "type": "object", + "properties": { + "fulfillment": { "$ref": "#/$defs/catalog_fulfillment" } + } + } + ] + }, + "fulfillment_product": { + "description": "A catalog product whose variants are fulfillment-enriched.", + "allOf": [ + { "$ref": "types/product.json" }, + { + "type": "object", + "properties": { + "variants": { + "type": "array", + "items": { "$ref": "#/$defs/fulfillment_variant" } + } + } + } + ] + }, + "fulfillment_search_filters": { + "description": "Catalog filters extended with a fulfillment destination filter.", + "allOf": [ + { "$ref": "types/search_filters.json" }, + { + "type": "object", + "properties": { + "fulfills_to": { + "$ref": "../common/types/postal_address.json", + "description": "Where the buyer wants to receive the item, as a postal address (coarse country/region/postal is sufficient). Narrows results and seeds per-destination availability. Authoritative counterpart to `context` address hints (filter wins; falls back to `context` when absent)." + } + } + } + ] + }, + "fulfillment_search_request": { + "allOf": [ + { "$ref": "catalog_search.json#/$defs/search_request" }, + { "type": "object", "properties": { "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" } } } } + ] + }, + "fulfillment_lookup_request": { + "allOf": [ + { "$ref": "catalog_lookup.json#/$defs/lookup_request" }, + { "type": "object", "properties": { "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_product" } } } } + ] + }, + "fulfillment_get_product_request": { + "allOf": [ + { "$ref": "catalog_lookup.json#/$defs/get_product_request" }, + { "type": "object", "properties": { "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_product" } } } + ] + }, + "dev.ucp.shopping.catalog.search": { + "description": "Catalog search composition with fulfillment (extends dev.ucp.shopping.catalog.search).", + "$defs": { + "search_request": { "$ref": "#/$defs/fulfillment_search_request" }, + "search_response": { "$ref": "#/$defs/fulfillment_search_response" } + } + }, + "dev.ucp.shopping.catalog.lookup": { + "description": "Catalog lookup composition with fulfillment (extends dev.ucp.shopping.catalog.lookup).", + "$defs": { + "lookup_request": { "$ref": "#/$defs/fulfillment_lookup_request" }, + "lookup_response": { "$ref": "#/$defs/fulfillment_lookup_response" }, + "get_product_request": { "$ref": "#/$defs/fulfillment_get_product_request" }, + "get_product_response": { "$ref": "#/$defs/fulfillment_get_product_response" } + } + }, "dev.ucp.shopping.checkout": { "title": "Checkout with Fulfillment", "description": "Checkout extended with hierarchical fulfillment.", diff --git a/source/schemas/shopping/types/availability.json b/source/schemas/shopping/types/availability.json new file mode 100644 index 000000000..2d8159ca0 --- /dev/null +++ b/source/schemas/shopping/types/availability.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/availability.json", + "title": "Availability", + "description": "Availability of an item: whether it can be obtained, and a qualifying status.", + "type": "object", + "properties": { + "available": { + "type": "boolean", + "description": "Whether this can be obtained. See status for fulfillment details." + }, + "status": { + "type": "string", + "description": "Qualifies available with fulfillment state. Well-known values: `in_stock`, `backorder`, `preorder`, `out_of_stock`, `discontinued`." + } + } +} diff --git a/source/schemas/shopping/types/catalog_destination.json b/source/schemas/shopping/types/catalog_destination.json new file mode 100644 index 000000000..036c8f601 --- /dev/null +++ b/source/schemas/shopping/types/catalog_destination.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/catalog_destination.json", + "title": "Catalog Destination", + "description": "A buyer-visible destination for a fulfillment method, with availability.", + "type": "object", + "required": ["id"], + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "description": "Destination identifier. `address` denotes the buyer's address; otherwise a location id." + }, + "name": { + "type": "string", + "description": "Location name (e.g., store name)." + }, + "address": { + "$ref": "../../common/types/postal_address.json", + "description": "Physical address of the location." + }, + "availability": { + "$ref": "availability.json", + "description": "Availability of the variant via this method at this destination." + } + } +} diff --git a/source/schemas/shopping/types/fulfillment_method.json b/source/schemas/shopping/types/fulfillment_method.json index 92e02e40a..c6d649256 100644 --- a/source/schemas/shopping/types/fulfillment_method.json +++ b/source/schemas/shopping/types/fulfillment_method.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://ucp.dev/schemas/shopping/types/fulfillment_method.json", "title": "Fulfillment Method", - "description": "A fulfillment method (shipping or pickup) with destinations and groups.", + "description": "A fulfillment method with destinations and groups.", "type": "object", "required": ["id", "type", "line_item_ids"], "additionalProperties": true, @@ -14,8 +14,7 @@ }, "type": { "type": "string", - "enum": ["shipping", "pickup"], - "description": "Fulfillment method type.", + "description": "Fulfillment method type. Well-known values: `shipping`, `pickup`. Businesses MAY use additional values.", "ucp_request": {"create": "required", "update": "optional"} }, "line_item_ids": { diff --git a/source/schemas/shopping/types/variant.json b/source/schemas/shopping/types/variant.json index d0033e7c3..9355b5f1a 100644 --- a/source/schemas/shopping/types/variant.json +++ b/source/schemas/shopping/types/variant.json @@ -104,18 +104,8 @@ } }, "availability": { - "type": "object", - "description": "Variant availability for purchase.", - "properties": { - "available": { - "type": "boolean", - "description": "Whether this variant can be purchased. See status for fulfillment details." - }, - "status": { - "type": "string", - "description": "Qualifies available with fulfillment state. Well-known values: `in_stock`, `backorder`, `preorder`, `out_of_stock`, `discontinued`." - } - } + "$ref": "availability.json", + "description": "Variant availability for purchase." }, "options": { "type": "array", From f607ce2c6e3eceaa6c150d91abb1567c99650636 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Fri, 12 Jun 2026 07:34:31 -0700 Subject: [PATCH 2/2] reshape catalog discovery per TC review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following TC review of the catalog-fulfillment draft (RFC #375), pivot the discovery shape from per-method destination lists to a slim, method-first model, and DRY the request-side location inputs. Key pivots: * Drop per-method destinations[]. A catalog method now carries method-level `availability` plus an optional `location` id. Catalog reports availability for a single specified-or-inferred location; location facts and the cross-store / multi-item matrix are deferred to a future locations capability — catalog answers "available here", not "available where". Deletes catalog_destination.json. * Keep method `type` flat. We did not introduce a delivery-vs-pickup class hierarchy: a flat `type` is what a client filters and renders on directly, and nesting would push variant filtering down into metadata. * Right-size the request. `fulfills_to` no longer takes a full postal address; it takes a location id or a coarse address (new fulfillment_destination_filter). Add a `method` type filter. `context` is where the buyer is; `fulfills_to` is where the order is fulfilled to (may differ, e.g. a gift) and supersedes context. * Extract a shared `locality` type (coarse country/region/postal). Both `context` and `fulfills_to` compose it; redundant per-field hint clauses were removed from `context` (its object-level "provisional / superseded" framing already covers them). * Preserve per-operation contracts. The get_product and lookup responses now layer fulfillment onto their specialized bases — detail_product's `selected`/`options` and lookup_variant's required `inputs` — instead of the bare product, so PDP variant narrowing and input correlation survive enrichment. * Back the location round-trip. A business that advertises pickup at a `location` MUST accept the same id as `selected_destination_id` in cart and checkout, so a discovered location can be used downstream. Catalog method — before: "methods": [ { "type": "pickup", "destinations": [ { "id": "loc_downtown", "name": "Downtown Store", "address": { ... }, "availability": { "available": true, "status": "in_stock" } } ] } ] after: "methods": [ { "type": "pickup", "location": "loc_downtown", "availability": { "available": true, "status": "in_stock" } } ] Request filters: "filters": { "fulfills_to": { "location": "loc_downtown" }, "method": ["pickup"] } --- docs/specification/fulfillment.md | 105 +++++++----------- source/schemas/common/types/locality.json | 22 ++++ source/schemas/shopping/fulfillment.json | 74 ++++++++++-- .../shopping/types/catalog_destination.json | 27 ----- source/schemas/shopping/types/context.json | 59 +++++----- .../types/fulfillment_destination_filter.json | 23 ++++ 6 files changed, 178 insertions(+), 132 deletions(-) create mode 100644 source/schemas/common/types/locality.json delete mode 100644 source/schemas/shopping/types/catalog_destination.json create mode 100644 source/schemas/shopping/types/fulfillment_destination_filter.json diff --git a/docs/specification/fulfillment.md b/docs/specification/fulfillment.md index fad70a599..6b470dd95 100644 --- a/docs/specification/fulfillment.md +++ b/docs/specification/fulfillment.md @@ -293,75 +293,72 @@ business's checkout. When the fulfillment extension extends the Catalog capability, each variant in a catalog response carries a `fulfillment` object listing the fulfillment -methods available for that variant and where each can be received — so a -buyer browsing the catalog can see how an item can be received before -reaching checkout. +methods available for that variant and their availability — so a buyer +browsing the catalog can see how an item can be fulfilled. -### Methods and destinations +### Methods `fulfillment.methods[]` lists the methods available for a variant. Each method has: * `type` — the fulfillment method (e.g. `shipping`, `pickup`); see [Method Types](#method-types). -* `destinations[]` — where the item is received via this method, each with - its own `availability`. +* `description` — short, buyer-facing summary of how the variant is + fulfilled via this method (e.g. "Ships in 2–4 business days"). Directly + 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. + +Catalog reports availability for a single location per method — the one +specified via `fulfills_to` or inferred from `context`; discovering and +comparing other locations is handled separately. -Each destination has: - -* `id` — the destination identifier. For `shipping` it is `address` (the - buyer's address, supplied on the request); otherwise it is the store or - location id. -* `name` and `address` — the location's name and address, when it has one. -* `availability` — whether the variant is available via this method at this - destination. +### Shapes -A method's `type` tells you what kind of destination it carries. Catalog -only advertises these options; the buyer commits at checkout. A destination -`id` found here can be passed to cart or checkout as -`selected_destination_id`, though checkout can also present its own -destinations to choose from. +#### Catalog Fulfillment -### Shapes +{{ extension_schema_fields('fulfillment.json#/$defs/catalog_fulfillment', 'fulfillment') }} #### Catalog Fulfillment Method {{ extension_schema_fields('fulfillment.json#/$defs/catalog_fulfillment_method', 'fulfillment') }} -#### Catalog Destination - -{{ schema_fields('types/catalog_destination', 'fulfillment') }} - #### Availability {{ schema_fields('types/availability', 'fulfillment') }} -### Location: `context` and `filters.fulfills_to` +#### Fulfillment Destination Filter -The buyer's location can reach a catalog request two ways. They are distinct -use cases: +{{ schema_fields('types/fulfillment_destination_filter', 'fulfillment') }} -* **`context`** (the buyer's coarse address — `address_country` / - `address_region` / `postal_code`) is a non-binding hint. The business - uses it to report `availability` on the methods it returns: *"given - roughly where I am, where is this available?"* It does not remove - results — you still see the variant, annotated with availability. -* **`filters.fulfills_to`** (a postal address) is a filter. It *restricts* - results to variants the business can fulfill to that destination: - *"only show me what I can actually get here."* Like any filter, it - narrows the result set. +### Location and method: `context` and `filters` -When both are present, `fulfills_to` is authoritative and wins; `context` -is the fallback. +* **`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. +* **`filters.fulfills_to`** is where the order is *fulfilled to* — a + `location` id, or a coarse address (`address_country` / `address_region` + / `postal_code`). It restricts results to what can be fulfilled there + and seeds method `availability`. It may differ from `context` (e.g. a + gift). +* **`filters.method`** restricts results to specific method types (e.g. + `["pickup"]`). -`fulfills_to` takes an address, not a store id; filtering by a specific -store or locker is not part of this capability. +Provide location once: `context` for where the buyer is, `fulfills_to` for +an explicit destination. When both are present, `fulfills_to` supersedes +`context`. ### Example A variant exposes two fulfillment methods: shipping to the buyer's ship-to -and pickup today at a named store. Each method carries its own destinations, -each with its own availability. +and pickup today at a named store. Each method carries its own availability, +and `pickup` references the resolved location by id. ```json @@ -388,29 +385,13 @@ each with its own availability. { "type": "shipping", "description": { "plain": "Ships to your address" }, - "destinations": [ - { - "id": "address", - "availability": { "available": true, "status": "in_stock" } - } - ] + "availability": { "available": true, "status": "in_stock" } }, { "type": "pickup", "description": { "plain": "Pickup today at Downtown Store" }, - "destinations": [ - { - "id": "loc_downtown", - "name": "Downtown Store", - "address": { - "address_locality": "Toronto", - "address_region": "ON", - "address_country": "CA", - "postal_code": "M5B 2H1" - }, - "availability": { "available": true, "status": "in_stock" } - } - ] + "location": "loc_downtown", + "availability": { "available": true, "status": "in_stock" } } ] } @@ -421,7 +402,7 @@ each with its own availability. } ``` -Each method gives the buyer a way to receive the item, with its own +Each method is a way the variant can be fulfilled, with its own `availability`. Each method's `description` is directly renderable, so a platform can present it without recognizing the `type` (see [Rendering](#rendering)). diff --git a/source/schemas/common/types/locality.json b/source/schemas/common/types/locality.json new file mode 100644 index 000000000..daa47a58e --- /dev/null +++ b/source/schemas/common/types/locality.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/locality.json", + "title": "Locality", + "description": "A coarse geographic location — country, region, and postal code. A lightweight alternative to a full postal address.", + "type": "object", + "additionalProperties": true, + "properties": { + "address_country": { + "type": "string", + "description": "The country, as a 2-letter ISO 3166-1 alpha-2 code (e.g. \"US\"). A 3-letter alpha-3 code or full country name MAY also be used." + }, + "address_region": { + "type": "string", + "description": "The first-level administrative region within the country (e.g. a state or province such as California)." + }, + "postal_code": { + "type": "string", + "description": "The postal code (e.g. \"94043\")." + } + } +} diff --git a/source/schemas/shopping/fulfillment.json b/source/schemas/shopping/fulfillment.json index 2d24b43d4..0de78c891 100644 --- a/source/schemas/shopping/fulfillment.json +++ b/source/schemas/shopping/fulfillment.json @@ -22,7 +22,7 @@ }, "catalog_fulfillment_method": { "title": "Catalog Fulfillment Method", - "description": "A fulfillment method on a catalog variant: how the variant can be received, and where.", + "description": "A fulfillment method on a catalog variant: how the variant can be fulfilled, and its availability.", "type": "object", "additionalProperties": true, "required": ["type"], @@ -35,16 +35,19 @@ "$ref": "../common/types/description.json", "description": "Short buyer-facing summary (e.g. 'Ships in 2–4 business days')." }, - "destinations": { - "type": "array", - "description": "Buyer-visible destinations for this method, each with availability.", - "items": { "$ref": "types/catalog_destination.json" } + "availability": { + "$ref": "types/availability.json", + "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." } } }, "catalog_fulfillment": { "title": "Catalog Fulfillment", - "description": "How a catalog variant can be received. Mirrors checkout `fulfillment`.", + "description": "How a catalog variant can be fulfilled. Mirrors checkout `fulfillment`.", "type": "object", "additionalProperties": true, "properties": { @@ -68,9 +71,51 @@ ] }, "fulfillment_product": { - "description": "A catalog product whose variants are fulfillment-enriched.", + "description": "A catalog product whose variants are fulfillment-enriched. Used by search.", + "allOf": [ + { "$ref": "types/product.json" }, + { + "type": "object", + "properties": { + "variants": { + "type": "array", + "items": { "$ref": "#/$defs/fulfillment_variant" } + } + } + } + ] + }, + "fulfillment_lookup_variant": { + "description": "A lookup variant (carrying input correlation) enriched with fulfillment.", + "allOf": [ + { "$ref": "catalog_lookup.json#/$defs/lookup_variant" }, + { + "type": "object", + "properties": { + "fulfillment": { "$ref": "#/$defs/catalog_fulfillment" } + } + } + ] + }, + "fulfillment_lookup_product": { + "description": "A lookup product whose variants are fulfillment-enriched, preserving input correlation. Used by lookup.", "allOf": [ { "$ref": "types/product.json" }, + { + "type": "object", + "properties": { + "variants": { + "type": "array", + "items": { "$ref": "#/$defs/fulfillment_lookup_variant" } + } + } + } + ] + }, + "fulfillment_detail_product": { + "description": "A get_product detail product (carrying selected/options availability signals) whose variants are fulfillment-enriched. Used by get_product.", + "allOf": [ + { "$ref": "catalog_lookup.json#/$defs/detail_product" }, { "type": "object", "properties": { @@ -83,15 +128,20 @@ ] }, "fulfillment_search_filters": { - "description": "Catalog filters extended with a fulfillment destination filter.", + "description": "Catalog filters extended with a fulfillment destination filter and a method-type filter.", "allOf": [ { "$ref": "types/search_filters.json" }, { "type": "object", "properties": { "fulfills_to": { - "$ref": "../common/types/postal_address.json", - "description": "Where the buyer wants to receive the item, as a postal address (coarse country/region/postal is sufficient). Narrows results and seeds per-destination availability. Authoritative counterpart to `context` address hints (filter wins; falls back to `context` when absent)." + "$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`." + }, + "method": { + "type": "array", + "items": { "type": "string" }, + "description": "Restrict results to these fulfillment method types (e.g. [\"pickup\"]). Well-known values: `shipping`, `pickup`." } } } @@ -118,7 +168,7 @@ "fulfillment_lookup_response": { "allOf": [ { "$ref": "catalog_lookup.json#/$defs/lookup_response" }, - { "type": "object", "properties": { "products": { "type": "array", "items": { "$ref": "#/$defs/fulfillment_product" } } } } + { "type": "object", "properties": { "products": { "type": "array", "items": { "$ref": "#/$defs/fulfillment_lookup_product" } } } } ] }, "fulfillment_get_product_request": { @@ -130,7 +180,7 @@ "fulfillment_get_product_response": { "allOf": [ { "$ref": "catalog_lookup.json#/$defs/get_product_response" }, - { "type": "object", "properties": { "product": { "$ref": "#/$defs/fulfillment_product" } } } + { "type": "object", "properties": { "product": { "$ref": "#/$defs/fulfillment_detail_product" } } } ] }, "dev.ucp.shopping.catalog.search": { diff --git a/source/schemas/shopping/types/catalog_destination.json b/source/schemas/shopping/types/catalog_destination.json deleted file mode 100644 index 036c8f601..000000000 --- a/source/schemas/shopping/types/catalog_destination.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://ucp.dev/schemas/shopping/types/catalog_destination.json", - "title": "Catalog Destination", - "description": "A buyer-visible destination for a fulfillment method, with availability.", - "type": "object", - "required": ["id"], - "additionalProperties": true, - "properties": { - "id": { - "type": "string", - "description": "Destination identifier. `address` denotes the buyer's address; otherwise a location id." - }, - "name": { - "type": "string", - "description": "Location name (e.g., store name)." - }, - "address": { - "$ref": "../../common/types/postal_address.json", - "description": "Physical address of the location." - }, - "availability": { - "$ref": "availability.json", - "description": "Availability of the variant via this method at this destination." - } - } -} diff --git a/source/schemas/shopping/types/context.json b/source/schemas/shopping/types/context.json index a140c67f2..9d7f51207 100644 --- a/source/schemas/shopping/types/context.json +++ b/source/schemas/shopping/types/context.json @@ -5,38 +5,35 @@ "description": "Provisional buyer signals for relevance and localization—not authoritative data. Businesses SHOULD use these values when verified inputs (e.g., shipping address) are absent, and MAY ignore or down-rank them if inconsistent with higher-confidence signals (authenticated account, risk detection) or regulatory constraints (export controls). Eligibility and policy enforcement MUST occur at checkout time using binding transaction data. Context SHOULD be non-identifying and can be disclosed progressively—coarse signals early, finer resolution as the session progresses. Higher-resolution data (shipping address, billing address) supersedes context.", "type": "object", "additionalProperties": true, - "properties": { - "address_country": { - "type": "string", - "description": "The country. Recommended to be in 2-letter ISO 3166-1 alpha-2 format, for example \"US\". For backward compatibility, a 3-letter ISO 3166-1 alpha-3 country code such as \"SGP\" or a full country name such as \"Singapore\" can also be used. Optional hint for market context (currency, availability, pricing)—higher-resolution data (e.g., shipping address) supersedes this value." + "allOf": [ + { + "$ref": "../../common/types/locality.json" }, - "address_region": { - "type": "string", - "description": "The region in which the locality is, and which is in the country. For example, California or another appropriate first-level Administrative division. Optional hint for progressive localization—higher-resolution data (e.g., shipping address) supersedes this value." - }, - "postal_code": { - "type": "string", - "description": "The postal code. For example, 94043. Optional hint for regional refinement—higher-resolution data (e.g., shipping address) supersedes this value." - }, - "intent": { - "type": "string", - "description": "Background context describing buyer's intent (e.g., 'looking for a gift under $50', 'need something durable for outdoor use'). Informs relevance, recommendations, and personalization." - }, - "language": { - "type": "string", - "description": "Preferred language for content. Use IETF BCP 47 language tags (e.g., 'en', 'fr-CA', 'zh-Hans'). For REST, equivalent to Accept-Language header—platforms SHOULD fall back to Accept-Language when this field is absent; when provided, overrides Accept-Language. Businesses MAY return content in a different language if unavailable." - }, - "currency": { - "type": "string", - "description": "Preferred currency (ISO 4217, e.g., 'EUR', 'USD'). Businesses determine presentment currency from context and authoritative signals; this hint MAY inform selection in multi-currency markets. Also serves as the denomination for price filter values — platforms SHOULD include this field when sending price filters. Response prices include explicit currency confirming the resolution." - }, - "eligibility": { - "type": "array", - "description": "Buyer claims about eligible benefits such as loyalty membership, payment instrument perks, and similar. Recognized claims MAY inform the Business response (e.g., member-only product availability, adjusted pricing in catalog, provisional discounts at cart or checkout). Businesses MUST ignore unrecognized values without error. Values MUST use reverse-domain naming (e.g., 'com.example.loyalty_gold', 'org.school.student') and MUST be non-identifying.", - "uniqueItems": true, - "items": { - "$ref": "../../common/types/reverse_domain_name.json" + { + "type": "object", + "additionalProperties": true, + "properties": { + "intent": { + "type": "string", + "description": "Background context describing buyer's intent (e.g., 'looking for a gift under $50', 'need something durable for outdoor use'). Informs relevance, recommendations, and personalization." + }, + "language": { + "type": "string", + "description": "Preferred language for content. Use IETF BCP 47 language tags (e.g., 'en', 'fr-CA', 'zh-Hans'). For REST, equivalent to Accept-Language header—platforms SHOULD fall back to Accept-Language when this field is absent; when provided, overrides Accept-Language. Businesses MAY return content in a different language if unavailable." + }, + "currency": { + "type": "string", + "description": "Preferred currency (ISO 4217, e.g., 'EUR', 'USD'). Businesses determine presentment currency from context and authoritative signals; this hint MAY inform selection in multi-currency markets. Also serves as the denomination for price filter values — platforms SHOULD include this field when sending price filters. Response prices include explicit currency confirming the resolution." + }, + "eligibility": { + "type": "array", + "description": "Buyer claims about eligible benefits such as loyalty membership, payment instrument perks, and similar. Recognized claims MAY inform the Business response (e.g., member-only product availability, adjusted pricing in catalog, provisional discounts at cart or checkout). Businesses MUST ignore unrecognized values without error. Values MUST use reverse-domain naming (e.g., 'com.example.loyalty_gold', 'org.school.student') and MUST be non-identifying.", + "uniqueItems": true, + "items": { + "$ref": "../../common/types/reverse_domain_name.json" + } + } } } - } + ] } diff --git a/source/schemas/shopping/types/fulfillment_destination_filter.json b/source/schemas/shopping/types/fulfillment_destination_filter.json new file mode 100644 index 000000000..730631b64 --- /dev/null +++ b/source/schemas/shopping/types/fulfillment_destination_filter.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/fulfillment_destination_filter.json", + "title": "Fulfillment Destination Filter", + "description": "A specific destination to check fulfillment against: a location id, or a coarse locality. Used as a request filter; a full street address is not required.", + "type": "object", + "additionalProperties": true, + "allOf": [ + { + "$ref": "../../common/types/locality.json" + }, + { + "type": "object", + "additionalProperties": true, + "properties": { + "location": { + "type": "string", + "description": "A specific location id (e.g. a store) to check." + } + } + } + ] +}