feat!: [DRAFT] fulfillment methods on catalog#507
Conversation
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.
| "type": "object", | ||
| "required": ["id"], | ||
| "additionalProperties": true, | ||
| "properties": { |
There was a problem hiding this comment.
The one area where I think this gets a little constrained is that the “how” of fulfillment — the method — and the “where-kind” of fulfillment — store, locker, partner counter, or the buyer’s own address — can vary independently.
One possible lightweight option is to add a single optional destination_type field:
"destination_type": {
"type": "string",
"description": "Kind of place this destination is. Open enum; well-known values: `store`, `locker`, `pickup_point`, `address`. Consumers MAY ignore unknown values.",
"enum": ["store", "locker", "pickup_point", "address"]
}The nice thing about this is that simpler platforms can ignore it and continue to work with a single destination shape. At the same time, it gives us a clean way to distinguish a locker handoff from a store counter or partner pickup point, which is important for flows like Drive Up, in-store pickup, and partner pickup.
As a bonus, this could let us retire the id == "address" sentinel, since destination_type: "address" would carry the meaning currently being overloaded into the magic ID.
| "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.", | ||
| "allOf": [ | ||
| { | ||
| "$ref": "checkout.json" | ||
| }, | ||
| { | ||
| "type": "object", | ||
| "properties": { | ||
| "fulfillment": { | ||
| "$ref": "#/$defs/fulfillment", | ||
| "description": "Fulfillment details.", | ||
| "ucp_request": { | ||
| "create": "optional", | ||
| "update": "optional", | ||
| "complete": "omit" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ] | ||
| }, | ||
| "dev.ucp.shopping.fulfillment": { |
There was a problem hiding this comment.
One concrete use case I want to make sure this design still supports is the Target.com experience today, where Pickup and Drive Up are search filters. A guest can narrow the entire result set to items they can collect this afternoon. I think there are two gaps in the current shape that make that hard to express on this surface.
First, fulfills_to filters by where — an address — but we don’t currently have a way to filter by how. In other words, there isn’t a server-side way to ask for “only items available for pickup.” The response does advertise fulfillment methods per variant, so a platform could theoretically over-fetch and post-filter client-side. But at search scale, that is not really a substitute for letting the business restrict and rank the result set server-side.
Second, Drive Up is effectively curbside fulfillment, and curbside is not currently part of the well-known vocabulary. Under the “consumers may ignore unknown values” rule, if we mint that value ourselves, it may disappear on platforms that don’t recognize it. That would make one of our highest-volume fulfillment methods invisible at discovery time.
I think the fix can be small and consistent with the pattern this PR already establishes: add an optional method_types[] array to fulfillment_search_filters, next to fulfills_to. That, combined with promoting curbside to a well-known value through the RFC path, would make today’s Target.com filter rail expressible end to end. Happy to bring the exact schema text if helpful.
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"] }
Currently fulfillment extension (
dev.ucp.shopping.fulfillment) describes how an order is fulfilled at checkout. This PR extends it to also compose onto catalog capability (catalog.search,catalog.lookup,get_product), so a catalog variant can advertise how it can be received — shipping, pickup, etc. It's the same extension applied to a second surface. On a variant it exposesfulfillment.methods[], the same shape ascheckout.fulfillment.methods[], so a fulfillment method is one concept a buyer/agent can reason about at discovery time and at checkout time.Proposed shape follows the method-first direction discussed in RFC #375: catalog answers "how can I receive this product?" with methods as peers, and buyer-visible destinations as details of location-bound methods.
One notable departure: #375 sketched polymorphic destinations -- distinct destination types as peers. This PR instead treats the kind of place as a property of the method
type, leaving the destination a single (monomorphic) shape. tl;dr: a polymorphic destination union is ambiguous under open (additionalProperties: true) schemas, and moving place-kind onto the method dissolves that rather than patching it. Same goal, but via different / simpler mechanism.Request —
contextandfilters.fulfills_toare distinct{ "query": "electric kettle", "context": { "address_country": "CA", "address_region": "ON" }, "filters": { "fulfills_to": { "address_country": "CA", "address_region": "ON", "postal_code": "M5B 2H1" } } }context(coarse locale) is a non-binding hint: the business uses it to report availability — "given roughly where I am, what's available?" — and never removes results.filters.fulfills_to(a postal address) is a filter: it restricts results to what the business can fulfill to that destination.fulfills_tois authoritative and wins;contextis the fallback.fulfills_totakes an address, not a store id — discovering a specific store is a separate capability (see Direction).Response —
fulfillment.methods[]on the variant{ "products": [ { "id": "prod_kettle", "title": "Electric Kettle", "variants": [ { "id": "var_ss", "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_country": "CA", "postal_code": "M5B 2H1" }, "availability": { "available": true, "status": "in_stock" } } ] } ] } } ] } ] }Key design decisions
fulfillment.methods[]lists peer methods; the same shape appears on checkout, so a method is one concept across both surfaces.type; the destination is monomorphic. The kind of place a method delivers to (a store, a locker, the buyer's address) is part of the receive experience — the methodtype— not a property of the destination. So every destination is one shape,{ id, name?, address?, availability }, and a new place-kind is a newtypevalue, not a new schema. This avoids a polymorphic destinationoneOf, which is ambiguous underadditionalProperties: true(a store also validates as a bare shipping address). For shipping,idis the reserved valueaddress; for pickup it's the store/location id.typeis an open string with documented well-known values (shipping,pickup). New methods are added additively via the spec; consumers ignore values they don't recognize. (Checkout'stype, previously a closed enum, opens to the same vocabulary.)iddiscovered here can be carried into cart/checkout asselected_destination_id; checkout can also present its own destinations.