Skip to content

fix!: discriminate destinations at the method level - #689

Merged
igrigorik merged 2 commits into
feat/typed-fulfillment-destinationsfrom
lr/response-only-destination-types
Aug 6, 2026
Merged

fix!: discriminate destinations at the method level#689
igrigorik merged 2 commits into
feat/typed-fulfillment-destinationsfrom
lr/response-only-destination-types

Conversation

@richmolj

@richmolj richmolj commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Counter-proposal for the request side of #688; response-side typing is unchanged. Context: Slack thread.

Problem

#688 discriminates destinations per-object in both directions. Requiring type on request destinations invalidates every deployed Platform integration — requests that validate today fail the moment the requirement lands. Response-side type is additive: producers add a field; consumers ignore unknown fields under the open-world model. The two directions carry incomparable migration costs.

The per-object tag also leaves the agreed authority boundary unenforced. { "type": "business_location", "id": ... } remains a schema-valid Platform-written destination, so a written destination and selected_destination_id can select different locations with no precedence rule — the conflict #688 sets out to remove.

Solution

Move the discriminator up one level: a fulfillment method's type selects the shape of its entire subtree, destinations included. A shipping method has Shipping Destinations; a pickup method has Business Location Destinations; extension-defined method types define their own. Polymorphism resolves at the parent, so request destinations need no per-object tag, and the request wire format Platforms send today remains valid unchanged.

// fulfillment_method.json — illustrative
"dependentRequired": { "destinations": ["type"] },
"allOf": [
  { "if":   { "properties": { "type": { "const": "shipping" } }, "required": ["type"] },
    "then": { "properties": { "destinations": { "items": { "$ref": "shipping_destination.json" } } } } },
  { "if":   { "properties": { "type": { "const": "pickup" } }, "required": ["type"] },
    "then": { "properties": { "destinations": {
      "ucp_request": "omit",
      "items": { "$ref": "location_destination.json" } } } } }
]

Plain draft-2020 if/then + $ref — no direction- or context-specific request schemas.

Design decisions:

  • Pickup destinations are response-only, schema-enforced. ucp_request: "omit" inside the pickup branch removes destinations from the request projection; under strict resolution a Platform-written location is rejected as unevaluated. selected_destination_id is the sole selection channel and accepts any stable, Business-scoped Location ID the Business recognizes for the method, including IDs not yet enumerated in destinations[] — the feat!: Introduce Location Search + Lookup capabilities #589 handoff. One channel, one authority.
  • dependentRequired: {"destinations": ["type"]}. Method type is update-optional (target by id), so a request that writes destinations[] must carry the method type for branch dispatch. Updates touching only selected_destination_id or selected_option_id are unaffected.
  • Responses are unchanged from fix!: make destination types explicit #688. Every response destination carries a required, open type and remains self-describing. type stays optional in requests for reference disambiguation (e.g., an id-only destination naming one of several address sources).
  • Extension-defined method types match no core branch; the negotiated extension's schema defines destination shape and writability.

Before / After — strict request validation

Case #688 this PR
Untyped inline shipping address (today's clients) rejected valid
Unknown field on a shipping address rejected rejected
Platform writes a business location into destinations[] valid (blessed shape) rejected
destinations[] without method.type n/a rejected (dependentRequired)
Update touching only selection fields valid valid

Resolver change

ucp-schema --strict seals item schemas referenced from base properties but does not descend into if/then/else; branch item schemas are never sealed. One match arm in close_additional_properties_inner (resolver.rs):

"then" | "else" => {
    // Conditional branches apply in-place alongside siblings: don't seal
    // the branch itself, but descend so nested properties/items get sealed.
    close_additional_properties_inner(child, true);
}

Verified against a patched build: the full matrix above holds on unmodified resolver output; resolver unit tests pass (153/153). Diagnostic trade-off: a failed then contributes no annotations, so some rejections surface as a root-level unevaluated-destinations error rather than an item-level message — verdicts unchanged, messages less precise.

Trade-offs

  • Pickup write-ban enforcement applies under strict resolution; open validation remains permissive and the MUST NOT prose governs that regime. Strictly stronger than fix!: make destination types explicit #688, where the written location is schema-blessed in both regimes.
  • The schema_fields macro does not collect branch-scoped properties, so destinations no longer renders in the Fulfillment Method field table (covered in the Destinations section). Either the macro learns then.properties, or destinations are documented per method type.
  • fulfillment_destination.json is no longer schema-referenced; retained as the response-side documentation entity, or foldable into docs.

richmolj and others added 2 commits August 5, 2026 15:46
…ly 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
   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.
@igrigorik
igrigorik marked this pull request as ready for review August 5, 2026 21:52
@damaz91 damaz91 added the status:needs-triage Signal that the PR is ready for human triage label Aug 5, 2026
@igrigorik
igrigorik merged commit 455ab32 into feat/typed-fulfillment-destinations Aug 6, 2026
4 of 5 checks passed
@igrigorik
igrigorik deleted the lr/response-only-destination-types branch August 6, 2026 04:34
igrigorik added a commit that referenced this pull request Aug 18, 2026
* fix!: require destination type

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 (#689)

* 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>

* fix eof

* restore generic destination response

   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.

* style(schema): remove redundant destination optionality text

* clarify default destination selection

   The Curbside review exposed two gaps in how the specification describes the
   existing destination contract. The schema already gives every method a safe
   default—destinations are Business-authored and response-only, the Platform
   selects by ID, and returned destinations self-describe through type and id—but
   the prose described non-Shipping/Pickup methods as undefined. It also gave IDs
   learned through Catalog a stronger continuity guarantee than IDs learned from
   Location or an earlier Checkout.

   Clarify that default and make accepted selection source-agnostic. When the
   Business accepts a non-null selected_destination_id, it returns the same value
   with exactly one matching typed destination, revalidates availability and
   terms, and never silently substitutes another destination. If an Update cannot
   be accepted, the Business leaves the current Checkout unchanged and returns a
   recoverable Message identifying the attempted selection. Remove the stale
   mental-model line that placed Curbside beneath Pickup; Curbside remains a peer
   method and already inherits the default contract without a new schema branch.

   No schema change is required because fulfillment_method and
   fulfillment_destination already express this behavior. Separately, we
   *have* replicated well-known values across too many places, but I'm
   reserving that for a separate and followup commit.

* clarify destination authorship

   Describe method and destination types with human-facing examples, state when
   the Platform writes destination inputs, and use the protocol's "omitted"
   terminology.

   Explain shipping destinations as either inline addresses or saved-address
   references resolvable by the Business or a trusted Credential Provider, and
   clarify the Checkout request and response flow.

---------

Co-authored-by: Lee Richmond <richmolj@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status:needs-triage Signal that the PR is ready for human triage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants