Skip to content

feat!: [DRAFT] fulfillment methods on catalog#507

Draft
igrigorik wants to merge 2 commits into
mainfrom
feat/catalog-fulfillment-methods
Draft

feat!: [DRAFT] fulfillment methods on catalog#507
igrigorik wants to merge 2 commits into
mainfrom
feat/catalog-fulfillment-methods

Conversation

@igrigorik

Copy link
Copy Markdown
Contributor

WIP / RFC — schemas validate and docs render, but naming and scope are open. See #375 for related context.

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 exposes fulfillment.methods[], the same shape as checkout.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 — context and filters.fulfills_to are 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.
  • When both are present, fulfills_to is authoritative and wins; context is the fallback. fulfills_to takes 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

  • Method-first, symmetric with checkout. fulfillment.methods[] lists peer methods; the same shape appears on checkout, so a method is one concept across both surfaces.
  • Place-kind on 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 method type — not a property of the destination. So every destination is one shape, { id, name?, address?, availability }, and a new place-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 with documented well-known values (shipping, pickup). New methods are added additively via the spec; consumers ignore values they don't recognize. (Checkout's type, previously a closed enum, opens to the same vocabulary.)
  • Advisory; selection at checkout. Catalog advertises statelessly. A destination id discovered here can be carried into cart/checkout as selected_destination_id; checkout can also present its own destinations.

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.
@igrigorik igrigorik added the TC review Ready for TC review label Jun 11, 2026
"type": "object",
"required": ["id"],
"additionalProperties": true,
"properties": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread source/schemas/shopping/types/fulfillment_method.json
Comment on lines 20 to 175
"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": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread source/schemas/shopping/fulfillment.json
Comment thread source/schemas/shopping/fulfillment.json
Comment thread docs/specification/fulfillment.md Outdated
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"] }
@igrigorik igrigorik changed the title feat: [DRAFT] fulfillment methods on catalog feat!: [DRAFT] fulfillment methods on catalog Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

TC review Ready for TC review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants