fix!: make destination types explicit - #688
Conversation
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.
* 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.", |
There was a problem hiding this comment.
Nit: Let "ucp_request": "optional" speak for itself and drop the redundant comment.
| "ucp_request": { | ||
| "create": "required", | ||
| "update": "optional" | ||
| } |
There was a problem hiding this comment.
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:
- Schema Readability & Maintainability: Nested conditional logic inside
allOfmakes the JSON Schema significantly harder for humans to read, trace, and reason about when reviewing or extending protocol contracts. - SDK Code Generation & DX: Most standard SDK generators (
datamodel-code-generatorfor Python/Pydantic,quicktypefor TypeScript/Go/C#,openapi-generator, etc.) do not natively parse or resolve dynamic JSON Schemaif/thenconditionals. As a result, code generation tools either drop these branches, emit untypedAny/unknownarrays, or fail to produce clean discriminated unions.
| { | ||
| "if": { | ||
| "properties": { | ||
| "type": { |
There was a problem hiding this comment.
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.
This PR makes Fulfillment destination responses self-describing while preserving existing Platform shipping-address requests.
Destination
typeis required and open in Business responses. In Platform requests, the enclosing method contract defines destination authorship and any default shape:shippingaccepts Platform-written Shipping Destinations; omitted destinationtypedefaults toshipping_address;pickupdestinations are Business-authored and response-only; the Platform selects one throughselected_destination_id;The PR also introduces a bounded
Location Summarythat Checkout can render independently and #589 can extend with richer Location fields.Context
Fulfillment originally used an untagged
oneOfbetween 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
idin responses, so every Retail Location also matches it and strictoneOfrejects intended pickup destinations. The old request shape also mixed authority:selected_destination_idcould select one Location while Platform-writtendestinations[]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:
shippingshipping_address.pickupselected_destination_id; destinations are response-only.type.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
typein Business responses.selected_destination_id.typewhen a request writesdestinations[]; selection-only updates may target the method byid.Location Summaryand Business Location Destination contracts.Catalog keeps its scalar Location ID. The same ID may be submitted as
selected_destination_idfor 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 --strictfix (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
type.type: "business_location".selected_destination_id.retail_location.jsonis retired; retail stores remain supported through Business Location Destination.Existing untyped Platform Shipping Destination requests remain valid.
Checklist