Skip to content

fix!: make destination types explicit - #688

Open
igrigorik wants to merge 6 commits into
mainfrom
feat/typed-fulfillment-destinations
Open

fix!: make destination types explicit#688
igrigorik wants to merge 6 commits into
mainfrom
feat/typed-fulfillment-destinations

Conversation

@igrigorik

@igrigorik igrigorik commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

This PR makes Fulfillment destination responses self-describing while preserving existing Platform shipping-address requests.

Destination type is required and open in Business responses. In Platform requests, the enclosing method contract defines destination authorship and any default shape:

  • shipping accepts Platform-written Shipping Destinations; omitted destination type defaults to shipping_address;
  • pickup destinations are Business-authored and response-only; the Platform selects one through selected_destination_id;
  • other method types define their request destination shape and Platform writability in their defining contract.

The PR also introduces a bounded Location Summary that Checkout can render independently and #589 can extend with richer Location fields.

Context

Fulfillment originally used an untagged oneOf between Shipping Destination and Retail Location. The enclosing method suggested the intended shape, but every destination was validated against both branches.

The branches overlap: Shipping Destination is open and requires only id in responses, so every Retail Location also matches it and strict oneOf rejects intended pickup destinations. The old request shape also mixed authority: selected_destination_id could select one Location while Platform-written destinations[] asserted another name/address, with no precedence rule.

#507 opened the method vocabulary but left this debt unchanged. #589 exposes it again and adds a capability-boundary issue: Checkout must not inherit the full Location Search/Lookup entity and its independently evolving geo, hours, amenities, and service-area fields.

The directional contract is:

Direction/method Destination behavior
Platform request, shipping Platform writes Shipping Destination facts; omitted type defaults to shipping_address.
Platform request, pickup Platform submits selected_destination_id; destinations are response-only.
Business response Every destination includes required, open type.
Other method type Its defining contract specifies request shape and Platform writability.

Wire changes

Shipping request — unchanged

{
  "type": "shipping",
  "line_item_ids": ["line_1"],
  "destinations": [
    {
      "street_address": "123 Main St",
      "address_country": "US"
    }
  ]
}

An ID-only saved or provider-held address remains a Shipping Destination. Provider provenance or extra fields require a negotiated extension contract.

Shipping response — typed

{
  "id": "method_shipping",
  "type": "shipping",
  "line_item_ids": ["line_1"],
  "destinations": [
    {
      "type": "shipping_address",
      "id": "address_1",
      "street_address": "123 Main St",
      "address_country": "US"
    }
  ]
}

Pickup request — scalar selection

{
  "type": "pickup",
  "line_item_ids": ["line_1"],
  "selected_destination_id": "loc_downtown"
}

Pickup response — typed Location summary

{
  "id": "method_pickup",
  "type": "pickup",
  "line_item_ids": ["line_1"],
  "selected_destination_id": "loc_downtown",
  "destinations": [
    {
      "type": "business_location",
      "id": "loc_downtown",
      "name": "Downtown Store",
      "address": {
        "street_address": "500 Market St",
        "address_country": "US"
      }
    }
  ]
}

What changes with this PR

  • Moves request destination authorship to the Fulfillment Method contract.
  • Keeps existing untyped Platform shipping-address requests valid.
  • Requires open destination type in Business responses.
  • Makes pickup destinations response-only; selection uses selected_destination_id.
  • Requires method type when a request writes destinations[]; selection-only updates may target the method by id.
  • Adds bounded Location Summary and Business Location Destination contracts.
  • Generalizes Retail Location to Business Location Destination; retail stores remain supported alongside lockers and partner pickup points.

Catalog keeps its scalar Location ID. The same ID may be submitted as selected_destination_id for that method, including before it is enumerated in Checkout. The Business returns the typed destination when accepted and revalidates current terms; recognition is not a reservation or eligibility guarantee.

This is the Fulfillment prerequisite for #589. After it lands, #589 can compose its rich Location entity from location_summary.json, remove its direct Fulfillment rewrite, and keep discovery fields owned by Location Search/Lookup.

Resolver dependency

This schema depends on a companion ucp-schema --strict fix (see #689) that recursively closes item schemas inside conditional branches. Normal JSON Schema remains open; the fix makes strict mode apply its existing closure policy consistently.

Breaking changes

  • Business response producers must add destination type.
  • Retail Location responses migrate to type: "business_location".
  • Platforms that wrote pickup destination objects must select through selected_destination_id.
  • retail_location.json is retired; retail stores remain supported through Business Location Destination.

Existing untyped Platform Shipping Destination requests remain valid.


Checklist

  • Capability: breaking Fulfillment schema update
  • Documentation: directional request/response contract and examples
  • I have followed the Contributing Guide
  • Documentation, schema lint, examples, and validator tests pass locally.

Fulfillment originally paired a closed shipping/pickup method enum with an
untagged oneOf between Shipping Destination and Retail Location. Humans could
infer the intended shape from the enclosing method, but the schema did not
condition destinations[] on method.type. Every destination was validated
against both branches.

The branches are not structurally disjoint. Shipping Destination is open and
requires only id in responses, so every Retail Location also matches it. Strict
oneOf validation rejects intended pickup destinations. Request resolution has
the same defect: it omits the stable Location id and requires Business-owned
name/address fields, which the open Shipping branch also accepts.

PR #507 opened the method-type vocabulary but left this inherited destination
debt unchanged. PR #589 exposes it again and adds a second boundary problem:
using the full Location Search/Lookup entity in Checkout would couple
Fulfillment to a separately negotiated capability's schema and lifecycle.

Checkout and Location Lookup need different projections:

- Checkout request: stable type-plus-id Location reference
- Checkout response: bounded id/name/address rendering summary
- Location Search/Lookup: richer discovery entity with geo, hours, amenities,
  service areas, and future Location fields

Platforms must be able to negotiate and render Checkout without supporting or
invoking Location Lookup.

This change:

- Introduces a bounded Location Summary with stable Business-scoped id,
  Buyer-facing name, and optional address. PR #589 can compose its richer
  Location entity on top without leaking discovery fields into Checkout.
- Replaces structural destination inference with a required, open type
  discriminator. Well-known values are shipping_address and business_location;
  negotiated extensions may define additional values.
- Preserves flat Platform-owned shipping-address fields. Shipping requests keep
  destination id optional; Business responses continue assigning the id.
- Changes Business Location requests to type-plus-id references. The Business
  owns the Location name/address and returns those facts in the response
  summary.
- Keeps Catalog's scalar Location id and selected_destination_id semantics.
  Location recognition is method-scoped and does not reserve inventory or
  guarantee eligibility; the Business revalidates current terms through normal
  Fulfillment responses and messages.
- Generalizes the active pickup destination from Retail Location to Business
  Location Destination. Retail stores remain supported as Business Locations,
  alongside other Business-scoped places such as lockers and partner pickup
  points.
- Updates all destination examples and documents the new authority, identity,
  and extension boundaries.

BREAKING CHANGE: every active Fulfillment Destination now requires type.
Shipping-address producers must add type: shipping_address. Retail Location
destinations migrate to type: business_location; requests send the stable
Location id instead of name/address.
@igrigorik igrigorik added this to the 2026-08-14 milestone Aug 5, 2026
@igrigorik
igrigorik requested review from gsmith85 and richmolj August 5, 2026 15:14
@igrigorik igrigorik self-assigned this Aug 5, 2026
@igrigorik igrigorik added the TC review Ready for TC review label Aug 5, 2026
richmolj and others added 3 commits August 5, 2026 21:34
* fix!: discriminate destinations at the method level; type response-only in requests

A fulfillment method's type selects the shape of its entire subtree,
destinations included: a shipping method has shipping-address
destinations, a pickup method has business-location destinations, and
extension-defined method types define their own. Polymorphism is
resolved at the parent, so request destinations need no per-object
discriminator.

- fulfillment_method branches per method type; the generic
  fulfillment_destination union is no longer referenced by schemas
  (kept for response documentation).
- Destination type is required in responses, optional in requests.
- destinations under pickup is response-only (ucp_request omit inside
  the pickup branch): under strict resolution the Platform cannot write
  business locations. selected_destination_id is the sole selection
  channel and accepts any Business-scoped Location ID the Business
  recognizes for the method, including IDs not yet enumerated (#589
  handoff).
- dependentRequired: a request that writes destinations[] must carry
  the method's type.
- Removed explicit additionalProperties:true from fulfillment_method
  (behavior-neutral in open validation; lets strict sealing work).
- Existing request wire shapes are unchanged; responses gain the
  required type field.

Assisted-By: devx/296664b9-53b6-409a-989a-ace9d3348247

* clarify directional destination typing

   Fulfillment responses always self-describe with a required destination type,
   while Platform requests follow the enclosing method's authorship contract.

   Clarify that untyped destinations under the well-known shipping method default
   to Shipping Destination, pickup destinations are Business-authored and selected
   through selected_destination_id, and other method types define their own request
   shape and Platform writability.

   Remove the unsupported suggestion that an alternate destination type can be
   selected under core shipping. An ID-only saved or provider-held address remains
   a Shipping Destination; provider provenance or additional fields require a
   negotiated extension contract.

---------

Co-authored-by: Ilya Grigorik <ilya@grigorik.com>
   Moving destinations entirely into method-specific conditionals orphaned the
   Fulfillment Destination schema and removed destinations from generic method
   models and generated documentation. It also left extension-defined method
   responses without the shared type/id destination contract.

   Restore response-only destinations on the base Fulfillment Method using the
   generic Fulfillment Destination schema. Known method branches refine that base:
   shipping re-enables Platform-writable request destinations, while pickup
   destinations remain response-only.

   This keeps extension-defined method responses typed, restores destinations to
   generated models and field tables, and makes the generic schema normative
   instead of documentation-only.
"type": {
"type": "string",
"const": "shipping_address",
"description": "Destination type discriminator. Required in responses; optional in requests.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Let "ucp_request": "optional" speak for itself and drop the redundant comment.

"ucp_request": {
"create": "required",
"update": "optional"
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I have some concerns regarding using nested if/then conditional blocks inside allOf in fulfillment_method.json (and fulfillment_destination.json) from both schema maintainability and DevRel/SDK tooling perspective:

  1. Schema Readability & Maintainability: Nested conditional logic inside allOf makes the JSON Schema significantly harder for humans to read, trace, and reason about when reviewing or extending protocol contracts.
  2. SDK Code Generation & DX: Most standard SDK generators (datamodel-code-generator for Python/Pydantic, quicktype for TypeScript/Go/C#, openapi-generator, etc.) do not natively parse or resolve dynamic JSON Schema if/then conditionals. As a result, code generation tools either drop these branches, emit untyped Any / unknown arrays, or fail to produce clean discriminated unions.

{
"if": {
"properties": {
"type": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I believe the intent here is to (i) avoid limiting the value of type (ii) while still adding the correctly typed destinations array either shipping_destination[] or location_destination[]) in recognized cases?

In the spirit of avoiding conditionals in our JSON Schema and keeping it simple(r) for code generation, I think that we can achieve the same result using an object-oriented approach.

fulfillment_method.json (Open Base Object)

Defines common fields shared across all fulfillment methods without imposing destination rules:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://ucp.dev/schemas/shopping/types/fulfillment_method.json",
  "title": "Fulfillment Method",
  "type": "object",
  "ucp_shared_request": true,
  "required": ["id", "type", "line_item_ids"],
  "properties": {
    "id": { "type": "string", "ucp_request": {"create": "omit", "update": "optional"} },
    "type": {
      "type": "string",
      "description": "Fulfillment method type (e.g. shipping, pickup). Open vocabulary."
    },
    "line_item_ids": {
      "type": "array",
      "items": { "type": "string" },
      "ucp_request": {"create": "optional", "update": "required"}
    },
    "selected_destination_id": { "type": ["string", "null"] }
  }
}

shipping_fulfillment_method.json (Specialized Subtype)

Extends fulfillment_method.json via allOf, constrains "type": "shipping", and adds Platform-writable shipping destinations:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://ucp.dev/schemas/shopping/types/shipping_fulfillment_method.json",
  "title": "Shipping Fulfillment Method",
  "type": "object",
  "ucp_shared_request": true,
  "allOf": [
    { "$ref": "fulfillment_method.json" },
    {
      "type": "object",
      "required": ["type"],
      "properties": {
        "type": { "type": "string", "const": "shipping" },
        "destinations": {
          "type": "array",
          "description": "Platform-authored shipping addresses.",
          "ucp_request": "optional",
          "items": { "$ref": "shipping_destination.json" }
        }
      }
    }
  ]
}

pickup_fulfillment_method.json (Specialized Subtype)

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://ucp.dev/schemas/shopping/types/pickup_fulfillment_method.json",
  "title": "Pickup Fulfillment Method",
  "type": "object",
  "ucp_shared_request": true,
  "allOf": [
    { "$ref": "fulfillment_method.json" },
    {
      "type": "object",
      "required": ["type"],
      "properties": {
        "type": { "type": "string", "const": "pickup" },
        "destinations": {
          "type": "array",
          "description": "Business-authored business locations. Response-only.",
          "ucp_request": "omit",
          "items": { "$ref": "location_destination.json" }
        }
      }
    }
  ]
}

A bit of an aside about polymorphism and this pattern: most of our SDK generators are going to be fine serializing from subtype -> JSON but will need custom code (cued off of type) to handle deserialization to JSON -> subtype. We are already doing this for fulfillment_method's current approach, so we'll need to make minor modifications to support this change.

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.

3 participants