Skip to content

feat: Introduce Food Checkout Capability and Fulfillment Extension - #897

Open
jingyli wants to merge 14 commits into
mainfrom
food/checkout
Open

jingyli wants to merge 14 commits into
mainfrom
food/checkout

Conversation

@jingyli

@jingyli jingyli commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Note

Early Feedback Welcome: The Food DTC is still actively closing on a few design points regarding the checkout model, so this PR may be actively updated in the coming days. We still wanted to open it now for early engagement.

Description

On behalf of the entire Food Domain Tech Council, this PR introduces Checkout Capability (dev.ucp.food.checkout) and Fulfillment Extension (dev.ucp.food.fulfillment), establishing the foundational data contracts, state lifecycle, and transport bindings for prepared food and beverage orders in UCP.

By standardizing single-establishment order scoping, structured dish customization, first-class gratuity, and order-wide delivery/pickup fulfillment (ASAP or scheduled) across both REST and MCP transports, this capability enables platforms to facilitate food ordering sessions across diverse food businesses (restaurants, cafes, bakeries, food trucks, ghost kitchens, etc.) while leaving menu authority, kitchen state, pricing calculations, and Merchant of Record (MoR) responsibilities with the business.

Motivation

While retail checkout (dev.ucp.shopping.checkout) models itemized physical/digital goods with simple quantities and per-item shipments, food commerce introduces distinct transactional dynamics:

  1. Single-Kitchen Freshness Window (food_establishment): Prepared food is cooked to order and must leave the kitchen within minutes, so a session is bound to exactly one preparation location. Every dish MUST come from that establishment's live menu.
  2. Configured Dishes, Not SKUs (dish, modification): A line item is a dish plus priced, menu-backed modifications (protein, spice level, extra toppings) and a free-form kitchen note. Two units of the same dish with different modifications or notes are distinct line items, and notes MUST NOT carry paid add-ons.
  3. Volatile Kitchen State: Menus, prices, and prep times seen during discovery are provisional. Dishes get 86'd mid-service, kitchens pause intake under load, and menus switch between dayparts. Every create/update must re-validate against live kitchen state and return authoritative totals and targeted, recoverable errors.
  4. Gratuity as a First-Class Charge (tip): Courier and counter-staff tips are part of checkout, not an afterthought. Businesses suggest options; platforms record the buyer's choice; the business resolves it into a priced totals[] entry.
  5. Delivery vs. Pickup, Now vs. Later: Fulfillment is order-wide (no split destination deliveries), may be ASAP or scheduled for a specific time, and requires structured drop-off preferences (e.g., contactless) plus courier instructions.
  6. Food-Safety Disclosures & Live Handoff: Statutory consumer advisories, allergen, and nutrition notices must be rendered prominently, and on completion buyers need a short callout label (e.g., #42) and a live tracking link rather than a static receipt.

Scope

In Scope

  • Core Checkout Capability (dev.ucp.food.checkout): Full session lifecycle (incomplete, requires_escalation, ready_for_complete, complete_in_progress, completed, canceled) with deterministic handoffs via continue_url (e.g., alcohol ID verification, catering review).
  • Data Models & Types:
    • Shared physical_address primitive in common/types/ (postal address and/or WGS 84 geo).
    • Food entities: food_establishment, line_item, dish, modification, buyer, tip, tip_option, and order_confirmation.
  • Fulfillment Extension (dev.ucp.food.fulfillment): Order-wide delivery / pickup methods with type-discriminated destinations (delivery_address, business_location), ASAP vs. scheduled timing via requested_fulfillment_time, priced time-window options[], drop-off preference_options[], courier notes, and available_methods[].
  • Cart-to-Checkout Conversion: cart_id support on Create Checkout via food/cart.json.
  • Food-Specific Error & Warning Codes: location_closed, minimum_order_unmet, note_contains_modification, plus disclosure-tier guidance for consumer_advisory, allergens, and nutritional_info.
  • Transport Bindings: Complete REST (OpenAPI 3.1) and MCP (OpenRPC / tool definitions) specifications and examples.
  • Payment Extension Interoperability: Wiring of payment_ap2_mandate onto dev.ucp.food.checkout.
  • Documentation & Tooling Enhancements: Refactored main.py schema macro renderer for filesystem-discovered verticals, relative $ref resolution, and path-qualified reference anchors; nav, llms.txt descriptions, schema reference sections, and test scaffolds for food contracts.

Out of Scope

  • Upstream menu search/discovery service (dish and modification IDs are assumed to come from upper-funnel discovery) & gradual cart building.
  • Post-purchase order management (order status webhooks, modifications, cancellations, refunds, substitutions).
  • Multi-establishment baskets (e.g., food halls / multi-vendor orders) and group ordering.
  • Catering-specific attributes (headcount, setup, deposits) and table reservations / dine-in ordering.

Design Details

1. Single-Establishment Scope & Shared Menu Identifiers

A session is bound to one food_establishment. On requests the Platform sends only opaque, menu-backed identifiers (food_establishment.id, dish.id, modification.id) plus buyer input (quantity, note); all other attributes are Business-authored.

{
  "food_establishment": { "id": "est_luigis_01" },
  "line_items": [
    {
      "quantity": 2,
      "dish": {
        "id": "dish_margherita_12",
        "modifications": [
          { "id": "mod_extra_mozzarella" },
          { "id": "mod_fresh_basil" }
        ],
        "note": "Well done crust, please"
      }
    },
    {
      "quantity": 1,
      "dish": { "id": "dish_caesar", "note": "Dressing on the side" }
    }
  ]
}

On every create/update, the Business authoritatively re-validates against live operating hours, item and ingredient availability, modifier-group rules, delivery coverage, and order minimums, and expands the response with titles, media, prices, line_items[].totals, and totals[].

2. Dish Customization: Structured Modifications vs. Kitchen Notes

Mechanism Field Priced? Contract
Structured modifications dish.modifications[] Yes Menu-backed IDs; Business validates modifier-group rules and echoes title / price.
Special instructions dish.note No Free-form kitchen text. MUST NOT trigger paid add-ons.
  • modification.quantity applies per unit of the dish (default 1); repeated units MUST be expressed via quantity, not repetition.
  • Line arithmetic: subtotal = (dish.price + Σ(mod.price × mod.quantity)) × line_items[].quantity.
  • When a note requests a paid add-on, the Business SHOULD return a note_contains_modification warning:
{
  "type": "warning",
  "code": "note_contains_modification",
  "path": "$.line_items[0].dish.note",
  "content": "\"Add avocado\" is a paid add-on (+$2.50). Select the Avocado modification to add it; notes are passed to the kitchen as-is and cannot add charges."
}

3. Tips & Gratuity (tip)

The top-level tip object separates what the Business suggests from what the buyer chooses; totals[] holds the resolved money:

  • Suggestions (tip.options[], Business-authored): id, unit (percentage | absolute), and amount (percent or ISO 4217 minor units). No labels — Platforms render "18%", "$5".
  • Choice (Platform-authored): exactly one of selected_option_id or custom_amount (or neither, letting the Business apply a default); both is rejected by schema oneOf.
  • Resolution: Any non-zero tip MUST appear as a type: "tip" entry in totals[]. Since tip is a food-defined total type, display_text is REQUIRED.
{
  "tip": {
    "options": [
      { "id": "tip_15", "unit": "percentage", "amount": 15 },
      { "id": "tip_18", "unit": "percentage", "amount": 18 },
      { "id": "tip_5usd", "unit": "absolute", "amount": 500 }
    ],
    "selected_option_id": "tip_18"
  },
  "totals": [
    { "type": "subtotal", "display_text": "Subtotal", "amount": 5000 },
    { "type": "fulfillment", "display_text": "Standard Delivery", "amount": 299 },
    {
      "type": "fee",
      "display_text": "Service & Regulatory Fees",
      "amount": 550,
      "lines": [
        { "display_text": "Service Fee", "amount": 350 },
        { "display_text": "City Courier Regulatory Fee", "amount": 200 }
      ]
    },
    { "type": "tax", "display_text": "Tax (8.75%)", "amount": 438 },
    { "type": "tip", "display_text": "Courier Tip (18%)", "amount": 900 },
    { "type": "total", "display_text": "Total", "amount": 7187 }
  ]
}

4. Food Fulfillment Extension (dev.ucp.food.fulfillment)

Shares vocabulary with Shopping Fulfillment but is deliberately flatter because a food order is cooked, bagged, and handed off together:

Dimension Shopping Fulfillment Food Fulfillment
Scope Per line item (line_item_ids[]) Whole order
Hierarchy methods[] → groups[] → options[] methods[] → options[]
Destination destinations[] + selected_destination_id One destination, discriminated by type
Timing Chosen only from Business options Platform may request a time (requested_fulfillment_time)
Handoff — preference_options[] + selected_preference_option_ids[] + notes
  • Destinations: delivery_address (Platform-authored postal_address and/or geo, at least one required) or business_location (Business-authored, response-only).
  • Timing: Omitting requested_fulfillment_time means ASAP; an RFC 3339 timestamp means scheduled. If a requested time cannot be honored, the Business returns a recoverable error and SHOULD offer nearest alternative options[].
  • Pricing: The selected option's price is reflected as type: "fulfillment" in checkout totals[]; tax, fees, and percentage tips are recomputed on change.
{
  "id": "method_delivery",
  "type": "delivery",
  "destination": {
    "id": "dest_home",
    "type": "delivery_address",
    "postal_address": {
      "street_address": "450 Serra Mall",
      "extended_address": "Apt 3B",
      "address_locality": "Stanford",
      "address_region": "CA",
      "postal_code": "94305",
      "address_country": "US"
    },
    "preference_options": [
      { "id": "pref_leave_at_door", "label": "Leave at my door" },
      { "id": "pref_hand_to_me", "label": "Hand it to me" }
    ],
    "selected_preference_option_ids": ["pref_leave_at_door"]
  },
  "notes": "Gate code #4210, second floor",
  "selected_option_id": "opt_standard",
  "options": [
    {
      "id": "opt_priority",
      "title": "Priority Delivery",
      "earliest_fulfillment_time": "2026-04-10T18:25:00-07:00",
      "latest_fulfillment_time": "2026-04-10T18:35:00-07:00",
      "totals": [{ "type": "total", "amount": 599 }]
    },
    {
      "id": "opt_standard",
      "title": "Standard Delivery",
      "earliest_fulfillment_time": "2026-04-10T18:35:00-07:00",
      "latest_fulfillment_time": "2026-04-10T18:50:00-07:00",
      "totals": [{ "type": "total", "amount": 299 }]
    }
  ]
}

5. Error Handling, Disclosures & Order Confirmation

  • Recoverable kitchen-state errors: In addition to shared standard errors, food Businesses SHOULD use location_closed (closed / paused intake) and minimum_order_unmet (basket below delivery minimum), with precise path targeting so Platforms can offer targeted recovery.
  • Disclosures: consumer_advisory, allergens, nutritional_info, and totals_changed use presentation: "disclosure" and MUST be shown prominently near the referenced item and never auto-dismissed. Operational context (e.g., high_demand) uses notice.
  • Order confirmation: On completed, order carries id (required), tracking_url (required, live preparation/courier status), and an optional short label used at the counter, shelf, or locker.
{
  "order": {
    "id": "ord_food_9876543210",
    "label": "#42",
    "tracking_url": "https://food.example.com/orders/ord_food_9876543210"
  }
}

6. Documentation Engine Enhancement (main.py)

Adding a third vertical exposed several assumptions in the schema macro renderer, which previously defaulted to shopping and resolved $refs by bare filename. Because food and shopping share file names (e.g., food/fulfillment.json vs. shopping/fulfillment.json, food/types/line_item.json vs. shopping/types/line_item.json), this produced incorrect cross-links and table contents.

main.py was refactored to:

  • Discover verticals from the filesystem (every source/schemas/ sub-directory except common/ and transports/), with no vertical treated specially.
  • Resolve $refs relative to the containing file first (standard JSON Schema semantics), falling back to name-based lookup across protocol, common, the active vertical, and — only if unambiguous — other verticals.
  • Emit path-qualified reference anchors (e.g., #food-types-fulfillment) from auto_generate_schema_reference, so schemas sharing a title across verticals remain individually addressable, and point every type link to the page that actually renders that schema.

reference.md now passes an explicit base_dir for shopping and adds food sections alongside common, shopping, and lodging.

Category (Required)

  • Core Protocol: Changes to the base communication layer, global context, or breaking refactors. (Requires Technical Council approval)
  • Governance/Contributing: Updates to GOVERNANCE.md, CONTRIBUTING.md, or CODEOWNERS. (Requires Governance Council approval)
  • Capability: New schemas (Discovery, Cart, etc.) or extensions. (Requires Maintainer approval)
  • Documentation: Updates to README, or documentations regarding schema or capabilities. (Requires Maintainer approval)
  • Infrastructure: CI/CD, Linters, or build scripts. (Requires DevOps Maintainer approval)
  • Maintenance: Version bumps, lockfile updates, or minor bug fixes. (Requires DevOps Maintainer approval)
  • SDK: Language-specific SDK updates and releases. (Requires DevOps Maintainer approval)
  • Samples / Conformance: Maintaining samples and the conformance suite. (Requires Maintainer approval)
  • UCP Schema: Changes to the ucp-schema tool (resolver, linter, validator). (Requires Maintainer approval)
  • Community Health (.github): Updates to templates, workflows, or org-level configs. (Requires DevOps Maintainer approval)

Related Issues

#518

Checklist

  • I have followed the Contributing Guide (including Conventional Commits title requirements and ! for breaking changes).
  • I have updated the documentation (if applicable).
  • My changes pass all local linting and formatting checks.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • (For Core/Capability) I have included/updated the relevant JSON schemas.
  • I have regenerated Python Pydantic models by running generate_models.sh under python_sdk.

Screenshots / Logs (if applicable)

food-checkout

@damaz91 damaz91 added the status:needs-triage Signal that the PR is ready for human triage label Oct 3, 2026
@karangoel16

Copy link
Copy Markdown
Contributor

The operation names (create_checkout, complete_checkout, etc.) are now defined in both dev.ucp.shopping.checkout and dev.ucp.food.checkout, and shared concepts are already diverging: shopping's order_confirmation requires permalink_url, food's has tracking_url instead; line_item means SKU+quantity in one and dish+modifications in the other. Nothing documents the relationship between the two shapes.

Two suggestions:

  1. Keep one normative description of the shared operation semantics and reference it from both capabilities instead of restating it.
  2. Add conformance coverage asserting both honor the shared behavior (idempotency, consent gating, platform-performs). As verticals multiply, that's what keeps the shape stable.

@damaz91 damaz91 added status:under-review gov:needs-tc-review Requires review and approval from the Technical Council area:food Issues and pull requests related to the Food ordering vertical and removed status:needs-triage Signal that the PR is ready for human triage labels Oct 5, 2026
@jingyli

jingyli commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @karangoel16 for the feedback! This divergence is actually working as intended based on UCP's core design principles around vertical independence & schema boundaries, specifically:

  • Services define vertical-specific domains: types & capabilities within these verticals are decoupled and independent (ref from our core concept architecture diagram)
  • Only constructs in the common service (dev.ucp.common.*) are shared across verticals
  • While operations and types might share intuitive names across some verticals (e.g., checkout, fulfillment, order_confirmation, line_items, etc.), they are designed to fit their specific vertical domain models rather than conform to an artificial shared inheritance.

Because of this, there is intentionally no formal relationship or shared normative schema binding between dev.ucp.shopping.checkout and dev.ucp.food.checkout —coupling them would restrict verticals from evolving independently.

But with respect to your callout on conformance, it's an interesting one as we took a stab on an earlier proposal in Phase 3 of RFC #520. We wanted to have validations at individual vertical suite level instead of coupling schemas across verticals to achieve this.

Comment thread source/schemas/food/types/buyer.json Resolved
@iantrainor iantrainor added the area:payments Issues and pull requests related to the Payments vertical label Oct 5, 2026
@niranjanmanjunath
niranjanmanjunath self-requested a review October 5, 2026 17:35
@niranjanmanjunath

Copy link
Copy Markdown
Contributor

Thanks for bringing up this PR @jingyli . Below are some comments about it:

1. Singular Delivery Destination

source/schemas/food/types/fulfillment_method.json: allOf and dependentRequired still reference destinations (plural) instead of destination

fulfillment_method.json defines singular "destination": { "$ref": "fulfillment_destination.json" } (line 21), but the allOf if/then branches and dependentRequired (lines 45–99) targets "destinations" as an array.

Because of this mismatch, method.destination isn't narrowed to delivery_destination.json / location_destination.json (or marked ucp_request: "omit" for pickup) based on method.type, and dependentRequired doesn't enforce type when destination is present.

2. source/schemas/food/types/modification.json: Missing quantity property

docs/specification/food/checkout/index.md normatively defines modification.quantity (under both Dish Customization and Entities → Modification), including:

  • Defaulting to 1 when omitted,
  • Requiring quantity > 1 rather than repeating modification ids, and
  • Using modifications[].quantity in the line-item subtotal formula.

However, source/schemas/food/types/modification.json only defines id, title, description, price, and media, so quantity is missing from the schema and from the rendered Modification tables in food/checkout/index.md and reference.md. We should add quantity ("type": "integer", "minimum": 1, "default": 1) to modification.json.

3. REST Endpoint Path Mismatch (/checkouts vs. /checkout-sessions)

source/services/food/rest.openapi.json defines all paths as /checkouts, /checkouts/{id}, /checkouts/{id}/complete, and /checkouts/{id}/cancel.
docs/specification/food/checkout/rest.md (both the Operations table and every HTTP request example) uses /checkout-sessions, /checkout-sessions/{id}, /checkout-sessions/{id}/complete, and /checkout-sessions/{id}/cancel (matching shopping/rest.openapi.json).

Should we align source/services/food/rest.openapi.json and docs/specification/food/checkout/rest.md on the intended path naming (/checkout-sessions to stay consistent with shopping, or update rest.md to /checkouts if intentional).

@chad-agate

Copy link
Copy Markdown

Thanks for opening this early. One gap from where we sit: pickup assumes the buyer walks up to the counter.

At Autolane (Austin), a customer's AI agent places a restaurant pickup order, and we send an autonomous vehicle on the customer's behalf to collect it curbside and deliver it. No one walks in; staff load the order into the vehicle. This draft already covers most of that flow: requested_fulfillment_time for the slot, method notes, and order.label plus tracking_url at completion. Two gaps:

1. No structured pickup handoff. preference_options[] and selected_preference_option_ids[] exist only on delivery_address. The pickup destination is response-only, so a business can't offer "Counter", "Curbside" or "Pickup shelf", and the platform can't pick one. For a vehicle, curbside is the only handoff that works, and today it can only go in free-text notes. Shopping's method type is open ("Businesses MAY use additional values") and shows "Curbside Pickup" as an option title; food's is a closed delivery | pickup enum. Would the council take handoff preferences on pickup, or should curbside come in as a negotiated extension?

2. The collector is assumed to be the buyer, in person. buyer is the only party on the order. An optional collector on the pickup method would tell staff who or what to hand the order to: a person (name, phone) or a vehicle (description, stall or parking spot, dispatch phone). That covers a friend, a courier the buyer booked, or an autonomous vehicle. notes can carry it, but a business can't validate free text or count on showing it at handoff.

Arrival check-in matters more without a driver: the platform, not a person, has to say "the vehicle is in stall 3." That probably belongs with Get Order, so I've left it out here.

We'd like to contribute this. Once the council picks a direction, Autolane can open a PR for either change, or both.

@yingzhehu-TT

Copy link
Copy Markdown
Contributor

Coordination note on food/rest.openapi.json: it follows the current shopping layout, so Authorization, Content-Type and Accept are header parameters and there is no components.securitySchemes. #716 (for #668) moves shopping, common and lodging to securitySchemes plus a document-level security, keeping {} so authentication stays optional, and #914 keeps the HTTP Headers tables listing those headers.

Nothing needs to change here — once this lands I'm happy to send Food the same change as a follow-up. #914 also touches header_fields, which this PR refactors, so whichever lands second will need a small rebase; if it's mine, I'll take care of it.

@frmoretto

Copy link
Copy Markdown

On the allergens disclosure: it works for the person reviewing the checkout, and in the EU it fits the rule for non-prepacked food sold at a distance, where allergen information has to be available before the purchase is concluded (Regulation (EU) No 1169/2011, Art. 14(2) with Art. 44(1)(a)).

For an agent, though, it is hard to check reliably. The content is free text, and it arrives on a line item after the dish has been chosen. The same gap was raised on the charter in #518 (comment) , asking for a structured dietary-safety field on the item and cart.

A small option within this PR: an optional list of allergen codes on the warning, next to the free text, with the code list named, since the lists differ by jurisdiction (14 groups in the EU's Annex II, 9 major allergens in the US).

If buyer.json later gets dietary_restrictions, as mentioned in the review thread, a platform could flag a conflict before complete_checkout.

Filtering at selection time belongs with menu discovery, which this PR leaves out of scope; the menuconform fixtures linked in #786 cover that case.

… resolution

- Update source/schemas/food/types/fulfillment_method.json allOf if/then branches, dependentRequired, and description to target singular destination instead of plural destinations.
- Add optional quantity property (integer, minimum 1, default 1) to source/schemas/food/types/modification.json to match the normative Food Checkout specification.
- Align REST endpoint paths in source/services/food/rest.openapi.json to /checkout-sessions* to match docs/specification/food/checkout/rest.md and shopping/rest.openapi.json.
- Restore UCP $def and allOf Type-column resolution in main.py (dropped during merge from main) and guard env.conf access so scripts/test_schema_tables.py passes.
@sawzhang

Copy link
Copy Markdown

+1 to @frmoretto's allergen codes on the warning. We build an ordering agent for F&B brands in Southeast Asia, and three rules have mattered more to us than the code list itself:

  1. A status, not just a family. contains, may_contain (shared equipment) and free_of are different facts, and only the business can assert free_of. Something like allergens: [{code, status, source}] on the dish or the warning, where source says who declared it (the business, a supplier sheet).

  2. Absence is "not recorded", never "safe". If a family isn't listed, the spec should say in normative text that a platform MUST NOT present the dish as free of it. Without that line, a dish with no allergen message reads as clear, which is the most dangerous default for the one buyer who asked.

  3. A buyer restriction can only tighten. If buyer gains dietary_restrictions, the business MAY use it to refuse or warn, and MUST NOT use it to clear anything. A line that conflicts with a stated restriction could carry code: allergen_conflict with severity: requires_buyer_review, so the session can't reach ready_for_complete until the buyer has seen why.

Happy to share our mapping of these onto the current schemas if useful.

@sawzhang

Copy link
Copy Markdown

@frmoretto — the PR against this branch is up: #964. It's the mapping above, three optional fields and one rule. The conflict text now says what is actually known: "No allergen information about celery for this dish, which the buyer listed" when the dish has no entry, and "Contains peanuts…" only when it does.

@jingyli jingyli added this to the 2026-12-08 milestone Oct 20, 2026

@igrigorik igrigorik left a comment

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.

This is great work. Well-thought-out and leverages UCP correctly in many ways. 👏🏻


Structural feedback

  1. main.py is a second PR inside this one. ±1,400 lines of docs engine, with two regressions @amithanda identified still at HEAD (_restore_fragment_refs never called; _variant_base/_inherit_base_fields gone, so the Media tables lose inherited rows). Can we split it, land it first with a rendered-docs diff, rebase food on it?

  2. food/cart.json is an empty capability with unreachable glue. dev.ucp.food.cart has no type, properties, operations or doc; its only content is $defs.checkout, which negotiation can't reach — the composer keys on $defs[<root capability name>], and a profile advertising both food.checkout and food.cart fails with multiple root capabilities. It's shopping's pre-#940 shape, abandoned on main for this reason. Stepping back, do you need cart at all? You can drop cart.json, cart_id and the L433 example; make food_establishment required on create and update.

  3. Tip is defined in the vertical; it's a cross-vertical concept. Retail, hotels, salons and all tip; the next vertical either $refs into food/ or copies. Can we extract tip/tip_option to common/ as an opt-in extension composing onto checkout (the discount precedent) so platforms negotiate whether to build tip UI; add tip to the well-known total types.

  4. Shared types copied where they should compose. buyer.json is byte-identical to shopping's — hoist to common/types/buyer.json and allOf when food needs dietary_restrictions. food_establishment is location_summary + media — add media to location_summary (pickup destinations and lodging properties want it too), drop the wrapper, keep the field name. order_confirmation renames permalink_url → tracking_url for no structural gain — keep the name; prose says what the page shows.

  5. physical_address is a fourth "place" idiom; the gap is geo on the destination. common/ already has location's {address, geo}, location_serves' {point} | {address}, and shopping's shipping_destination (flat postal, no geo). Food's destination now nests where shopping's is flat — two serialisers for the one role the verticals share. Add optional geo to shipping_destination (additive), make delivery_destination that pattern plus preferences, delete physical_address.json. Keep the geo-for-dispatch / address-for-display / flag-if-divergent rule as prose on both — it's good.

  6. policies[] and actions dropped from the checkout. Both optional output, both needed soon (cancel windows and 86'd-item refunds are policies; 3DS needs actions). Carry them over into food.

  7. Schema <> prose contradictions (verified against resolved schemas): tip.selected_option_id admitted on create though options[] first exists in a response (selected_term_id has the right annotation); pickup destination "response-only" but a Platform-supplied business_location validates, even strict; REST update_checkout L169 still says PATCH ("if omitted, unchanged") and MCP lacks the no-Update-during-complete_in_progress rule; Pattern 3's Courier Tip (18%) = 450 is 18% of nothing (L324).


Design issues & recommendations

1. dish{} carries buyer configuration — keep the wrapper, move the boundary

The line_item.item wrapper in shopping exists to separate the instance (the line: Business-minted id, quantity, totals) from the offer (item: id, title, price — every field but id is Business-authored and omit on request). Because item is a pure offer, the same type is reused unchanged by order_line_item, and two IDs with different meanings never collide. Food adopted the envelope and got the two-IDs part right — then put modifications[] and note, which are buyer configuration, inside dish. The PR's own prose has it the other way: it groups note with quantity as Platform input (L112) and says "a configured dish is a line item" (L142) — in which case line_item already is the configured dish and the wrapper partitions nothing.

Why it matters beyond tidiness: Phase 2's Menu capability returns dishes with available modifier groups (required / min / max / exclusive); checkout's dish.modifications[] is the selected flat list. Same type name, same field name, different semantics — either Menu can't reuse dish.json, or modifications is overloaded. And a Platform persisting "the usual" needs offer and configuration separable so the offer half can be refreshed against the menu.

Proposed shape — dish becomes the pure menu entity; configuration sits beside quantity:

{
  "id": "li_1",
  "dish": { "id": "dish_margherita_12", "title": "Margherita Pizza (12\")", "price": 1600 },
  "modifications": [
    { "id": "mod_extra_mozzarella", "title": "Extra Mozzarella", "price": 200, "quantity": 1 }
  ],
  "note": "Well done crust, please",
  "quantity": 2,
  "totals": [ { "type": "subtotal", "amount": 3600 }, { "type": "total", "amount": 3600 } ]
}

Each modification entry stays flat — it's already instance-around-offer one level down (buyer's quantity wrapping the modifier's id/title/price). Request side is dish: {id}, same as shopping's item: {id}. The move is two fields, one level, three path strings — and it also gives the error paths three distinct sites at one depth: 86'd dish → $.line_items[1].dish, 86'd modifier → $.line_items[1].modifications[0], bad note → $.line_items[1].note.

2. Open the two type vocabularies; move handoff preferences to the method

fulfillment_method.type (L17) and available_methods[].type are enum: ["delivery", "pickup"]. Shopping's counterparts are open — "Well-known values: shipping, pickup. Businesses MAY use additional values." Dine-in / table ordering, curbside, drive-through, locker, catering drop and autonomous-vehicle collection are all live food channels; @chad-agate hit the wall in week one. Closed enums are for provably closed sets, and widening one later is a breaking change for every validator in the field. Open string + well-known values + the direction-aware tolerance clause (request side: a Business MUST reject an unknown method type with a recoverable error; response side: Platforms MUST tolerate).

Same shape problem one level down: preference_options[] / selected_preference_option_ids[] live on delivery_destination. Handoff mode is a method concern — "leave at my door / hand to me" for delivery, "counter / curbside / shelf / locker" for pickup. Lifting the two fields to fulfillment_method serves both methods with zero new types and answers Autolane's first gap directly:

{
  "id": "method_pickup",
  "type": "pickup",
  "preference_options": [
    { "id": "pref_counter",  "label": "Counter" },
    { "id": "pref_curbside", "label": "Curbside — we'll bring it to your car" }
  ],
  "selected_preference_option_id": "pref_curbside",
  "notes": "Grey van, stall 3"
}

Single-select, per @amithanda — the examples are mutually exclusive and nothing today stops ["leave_at_door", "hand_to_me"]. If multi-select is genuinely intended, the spec has to say how a Business resolves conflicting picks.

3. Request a fulfillment window, not a point

Today the Platform sends one timestamp and the Business returns every priced window that contains it (earliest ≤ t ≤ latest, Business Validation). Mechanically that works, including for overlapping windows — but a timestamp can't distinguish three different buyers, and containment is the weakest of the three readings:

Buyer meant 7:00–8:00 @ $2.99 7:25–7:40 @ $5.99 7:35–7:50 @ $2.99
"around 7:30" fine fine fine
"by 7:30" (guests arrive) 50% late maybe 7:40 late
"not before 7:30" (not home yet) 50% cold on the porch maybe 7:25 perfect — but rejected

A 60-minute window straddling the target counts as a match; the one window that serves "not before" doesn't contain 7:30 and comes back as unavailable; and the Business can't offer a better cheap option ("Standard, 7:00–7:30") because it never learned the constraint was a deadline. The Platform ends up choosing among overlapping windows on price alone.

The response side is already an interval — every option carries earliest_fulfillment_time / latest_fulfillment_time. Make the request side the same shape and the matching rule becomes containment of intervals, which is well-defined:

{ "type": "delivery", "latest_fulfillment_time": "2026-04-10T19:30:00-07:00" }

"By 7:30" = {latest}; "not before" = {earliest}; "around" = both, with the agent's tolerance; ASAP = omit both. Omitted earliest = now, omitted latest = unbounded (the {min, max} convention). An option matches when its window ⊆ the requested window; none ⇒ recoverable error plus nearest alternatives, as today. Zero new types, and it's how every scheduling UI actually works — DoorDash, Uber Eats and Instacart all have the buyer pick a window from Business-generated slots. Two sentences to add alongside: the requested window is a query input that generates options[], and selected_option_id is the commitment (today both can be sent with no precedence rule).

Two riders on the same timing model:

  • fulfillable_on: "now" (L477) should be a date-time. Food has no "now" — pickup is ~15 minutes out, delivery ~35 — and the Business already knows the instant: it quotes it as the first option's earliest_fulfillment_time. The Location hours work settled this for open_at: one instant, one owner, no sentinel. Let's reuse the lessons learned from that design.
  • location_closed conflates three states — closed now, open but no capacity at that time, and does-not-take-scheduled-orders. Add fulfillment_time_unavailable (@PedroNatali) and an ASAP-only signal on available_methods[] (@smasma42). Closed is a Location fact; slot-full is a checkout fact.

4. Tip shape and basis

tip_option is {unit: "percentage" | "absolute", amount} — one amount whose meaning flips on unit, and percent as an integer, so 12.5% isn't expressible. #634 just landed the same primitive for return fees as two distinct fields, {basis_points} | {amount}, with an optional max_amount cap. Use one common/types/ charge shape for both; percentage tips get capped in practice, and the cap belongs on the option, not in prose:

"options": [
  { "id": "tip_18",   "basis_points": 1800, "max_amount": 2000 },
  { "id": "tip_5usd", "amount": 500 }
]

The percentage basis has to be stated or the "18%" a buyer taps won't match the charge — Pattern 3's 450 is 18% of none of 5000 / 4400 / 3960, and the prose's "against its stated basis" has nowhere to state it. Minimal contract: percentage options apply to the subtotal entry; an option MAY declare basis (a totals type) to override. Finally, define what happens to selected_option_id when the Business replaces options[] on a delivery→pickup switch (courier tip → staff tip): the stale selection is dropped, the Business MAY apply a default, and says so with a notice.

5. Post-completion is under-modelled

Food has no post-purchase object — charter Phase 1 listed Get Order; the PR punts it — so order_confirmation is the only surface after completion. It carries id, label, tracking_url. The one fact a food buyer needs at that moment is when: "ready at 6:35" / "arriving 6:35–6:50". Today that lives only behind tracking_url, a human page an agent can't read, and the selected option's window is the pre-acceptance estimate — the kitchen's committed window after acceptance can differ.

"order": {
  "id": "ord_food_9876543210",
  "label": "#42",
  "permalink_url": "https://food.example.com/orders/ord_food_9876543210",
  "earliest_fulfillment_time": "2026-04-10T18:35:00-07:00",
  "latest_fulfillment_time":   "2026-04-10T18:50:00-07:00"
}

Same two field names as the option. And name Get Order as the Phase-1 follow-up — that's where live ETA, status and reorder belong.

6. Modifier-group violations have no typed channel

The spec requires the Business to validate "required choices, min/max selections, mutually exclusive options" — the single most common food checkout failure — and defines no code for it. out_of_stock / item_unavailable cover availability only, so Platforms will receive freeform codes and can't build recovery. At least:

{ "type": "error", "code": "modification_required", "path": "$.line_items[0]",
  "content": "Choose a size for the Margherita.", "severity": "recoverable" }

plus modification_invalid with path to the offending entry ($.line_items[0].modifications[1]). The wire doesn't carry group identity (menu discovery is out of scope), so path + content is the floor; a structured group hint can arrive with the Menu capability.

7. Note semantics need one firm rule

Today note is "unstructured, passed to the kitchen as-is" and, two paragraphs later, something the Business SHOULD parse for paid add-ons and warn about. That's contradictory, and it leaves the buyer's actual question unanswered: I typed "add avocado" — what happens? Proposed:

  • The Business passes note to the kitchen verbatim and MUST NOT act on it commercially (no charge, no structured change).
  • It MAY return note_contains_modification when it detects a request the menu models as a modifier — at any price, zero included; "no onions" is a modifier if the menu models it (@PedroNatali) — and MAY return note_not_supported for a request the menu cannot honour at all (@smasma42's "pepperoni on half").
  • Detection is best-effort, not a contract. The firm rule is on the consumer: a Platform MUST NOT present a note as applied or priced.

That keeps notes unstructured without leaving the buyer guessing, and it's the same shape as every other warning in the model: the Business tells the Platform what it noticed; the Platform decides what to say.

8. totals_changed has no normative home

The doc sends readers to a shopping section that only defines the notice/disclosure rendering tiers; the only normative totals_changed rule in the corpus is lodging's (recomputed totals[] plus a totals_changed warning with path: "$.totals", and "MUST NOT complete at a modified price without first returning updated totals"). For a vertical whose state changes by the minute, that rule is load-bearing — state it here, and extend it on the same terms to a changed allergens disclosure after the buyer has reviewed (@frmoretto): re-sent, and no completion until seen.

9. line_items[].id under full replacement

id is create: omit, update: optional, which is correct — one update carries both retained lines and new ones — but nothing says what presence or absence means, which is @PedroNatali's question. Under full replacement most of it is moot: the body is authoritative, the Business materialises exactly the lines sent, and it never matches by content (dish + modifications + note isn't unique, and note is free text). What isn't moot is the handle. The rule, in one sentence: an echoed id is preserved on the resulting line; an omitted one is minted; an unknown or duplicated one is a recoverable error at that path.

"line_items": [
  { "id": "li_1", "dish": { "id": "dish_margherita_12" }, "quantity": 3 },
  { "dish": { "id": "dish_caesar" }, "quantity": 1 }
]

The first line keeps its handle (quantity changed); the second is new. Preserving echoed ids is what keeps by-value references and Business side-state (holds, a cleared age check, computed applies_to) stable across updates. Shopping has the identical annotation and the identical silence — the same sentence belongs there.

10. Allergens: structured input, Business computes, typed outcome — same shape as eligibility

The allergens disclosure as a messages[] warning is right as drafted. It's the same code shopping already uses for a nut bar (checkout/index.md:928 — **Contains: tree nuts.**, disclosure, path, image_url), the Business's wording is the legally operative statement, and path lets one message cover a kitchen-wide cross-contact note or a single line. Keep it; don't type it; don't make the Platform re-derive it from facts — a Platform rendering its own "contains peanuts" from codes has made its own food-safety claim.

What the thread (#964, @frmoretto, @sawzhang) is really asking for is a reliable check against a buyer's stated restriction, and the corpus already has the pattern for that: context.eligibility. The Platform sends claims, the Business evaluates against rules it never ships, and returns a typed outcome (eligibility_invalid, with path). Allergens are that shape exactly:

// request
"buyer": { "dietary_restrictions": [ { "code": "peanuts" }, { "code": "lupin" } ] }

// response
"messages": [
  { "type": "error", "code": "allergen_conflict", "path": "$.line_items[0]",
    "severity": "requires_buyer_review",
    "content": "Pad Thai contains peanuts, which the buyer listed." },
  { "type": "error", "code": "allergen_conflict", "path": "$.line_items[1]",
    "severity": "requires_buyer_review",
    "content": "No allergen information about peanuts is recorded for the Green Curry." },
  { "type": "error", "code": "restriction_unsupported", "path": "$.buyer.dietary_restrictions[1]",
    "severity": "requires_buyer_review",
    "content": "We can't evaluate orders against lupin." }
]

So, against #964 as filed: keep buyer.dietary_restrictions and allergen_conflict; drop dish.allergens from checkout scope. The Business is always in the loop at checkout, so independent evaluation isn't required for interoperability — typed dish-level facts are the Menu capability's concern (selection-time filtering, which the thread already assigned there) and can arrive later as an additive field if wanted. Drop code_list too: UCP adopts a vocabulary once in the schema, not per payload, and {list, code} identity produces false negatives exactly where they hurt; one open vocabulary of well-known slugs (peanuts, tree_nuts, milk, eggs, fish, crustaceans, molluscs, wheat, gluten, soy, sesame, celery, mustard, sulphites, lupin, buckwheat) is enough — granularity (gluten-cereals vs. wheat; enumerated tree nuts) is resolved by the Business, who knows almonds are tree nuts, not by a taxonomy on the wire.

Comment thread source/schemas/food/types/buyer.json Resolved
@frmoretto

Copy link
Copy Markdown

@igrigorik, on Design 8 and 10 of your review: this is a better home for it than what #964 proposed.
The eligibility shape answers the question we were actually asking: can a platform rely on a check against the buyer's stated restriction. Moving dish-level facts to Menu is fine with me; @sawzhang wrote #964, so reshaping it is his call.

Two lines I'd keep in the normative text when it moves:

  1. Silence must mean something. For each stated restriction, the Business returns allergen_conflict on every line where the allergen is contained, may be contained, or is not recorded, and restriction_unsupported when it cannot evaluate it.
    A line with neither message then means the Business declares it free of that allergen, and nothing else. Without this, a missing message could mean "checked and clear" or "not checked", which is the ambiguity feat(food): per-allergen status on dishes and buyer dietary restrictions #964 was written to remove.
    Your Green Curry example already follows the rule; it just needs saying.

  2. A restriction can only tighten. The Business MAY use dietary_restrictions to refuse or warn, and MUST NOT use it to clear a line or drop a disclosure it would otherwise send.

On the vocabulary: one open set of slugs works. One request: the schema description could say, for each well-known slug, which entry of the EU list (Regulation (EU) No 1169/2011, Annex II) and of the US list it falls under, e.g. wheat and gluten both under Annex II point 1. In the EU the Business has to make the Annex II information available before the purchase is concluded, so it needs to know which of its duties a slug covers.
That mapping lives in the docs, not the payload.

Francesco Marinoni Moretto

@damaz91

damaz91 commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

📋 Discussed in UCP Payments Tech Council (PTC) Meeting - 2026-10-15

This pull request was referenced under the following agenda topic(s):

See the full meeting minutes for discussion notes and action items.

@damaz91

damaz91 commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

📋 Discussed in UCP Shopping Tech Council (STC) Meeting - 2026-10-16

This pull request was referenced under the following agenda topic(s):

See the full meeting minutes for discussion notes and action items.

…s: 1) Enrich fulfillment representation based on learning from retail. 2) Tighten normative clauses in the specification documentation. 3) Enable common features like policies and actions on food checkout.
…ptions and provide normative languages around any confilcts.
@jingyli

jingyli commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @niranjanmanjunath for the feedback on schema inconsistencies and pushing the commit to the branch with the proposed fix!

Thanks @chad-agate for the feedback, some reactions to the 2 points you have raised:

  1. Good call out! This is similar to the feedback/recommendation shared in Design 2 in feat: Introduce Food Checkout Capability and Fulfillment Extension #897 (review) and it actually keeps the shape consistent. 3593bb8 and 53232f9 should now cover preference option selection consistently between delivery & pickup.
  2. This is a really good callout and actually is a general gap for any pickup fulfillment modes today in UCP (not only in food, but also in shopping). We welcome you to engage in any follow-up PRs/enhancement proposals to address the problem given right now it's a more forward looking goal within the Food DTC.

Thanks @yingzhehu-TT for the callout and help on this front!

@frmoretto @sawzhang Really appreciate all the thoughtful discussion both are having on the allergens and for proposing #964 that stacks on top of it. To keep this initial Food Checkout PR focused on the core session lifecycle, we are keeping the allergens disclosure warning in messages[] (where some back-and-forth are actually happening above as part of #897 (comment)) as in-scope and deferring structured dietary/allergen schemas as a follow-up/Phase 2 exploration. Please continue the engagement and interest on the PR!

@jingyli

jingyli commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @ifeanyinw for the thorough feedback, responses to the raised points:

  1. Fee itemization: please keep display_text + itemized lines[] normative — and consider tightening them.

Completely agree on the importance of fee transparency, which is why we adopt a shared totals[] rendering contract as https://ucp.dev/latest/specification/shopping/checkout/#rendering-contract. However, we cannot tighten them further due to 2 reasons:

  • Continue providing the flexibility of letting business choose between sub-line breakdown representation vs. a single fee entry will enable us to support all use cases, whereas if we require the former rendering contract, it will break businesses that truly charge only a single flat fee model with nothing they can itemize over.
  • We already shared some examples in prose - Some common examples include: marketplace service fees, small-order fees (basket below a threshold), bag or packaging fees, and municipal surcharges (e.g., courier minimum-pay regulatory fees). but we won't be encoding it into the schema given the associating field (display_text) is an open string. Consolidating them further into an enum type would go against UCP's schema authoring proposal.
  1. Where does /.well-known/ucp live for POS-hosted restaurant origins?

There are 2 angles to consider for this question:

  1. For shared POS or marketplace ordering origins like the examples you have listed, the POS/ordering provider can publish their single origin-level /.well-known/ucp advertising its dev.ucp.food and dev.ucp.common services. Then discovery would happen as normal, and by leveraging the Locations common capability, platforms would be able to reach the individual restaurants.
  2. There is also an active RFC open around hosted business directory that would enable discovery to any opted-in tenant profiles - [RFC] Hosted Business Directory #937.
  1. Is a non-paying, cart-handoff-only platform a conforming role?

Short-answer is yes. But I would not recommend narrowing in on supporting Checkout capability for this model vs. in future phases, when Cart capability is properly being introduced for Food vertical, a platform can choose to only implement and support that capability to signify that it only enables cart-building.

But coming back to your specific questions:

  • For food, consider making continue_url REQUIRED at least on requires_escalation and incomplete: in the current contract, status: requires_escalation already enforces a MUST on continue_url presence because buyer handoff is needed. However, we cannot make it REQUIRED for incomplete (right now it's a SHOULD) to allow for flexibility to support different kinds of businesses (e.g., incomplete sessions = ones that have not yet passed basic validation may not have a minted URL).
  • A non-paying platform can never reach ready_for_complete: I do want to correct that technically a session can be put into ready_for_complete without any payment info provided (note how payment is optional on both create and update). This is because the expectation is that payment is a required attribute in complete and that's when processing occurs - and in the case of a non-paying platform, this will fail 100% of the time. It is for this exact reason why I have provided the earlier recommendation that anchoring on Checkout capability for this use case you are describing here may not be the best solution.
  1. A live argument for this PR: "UCP-enabled" restaurant domains currently serve the gift shop, not the kitchen.

I cannot comment on the specifics of individual implementations that are live right now. However, from UCP's angle - until this PR, we did not have any capabilities designed for Food vertical that is endorsed by the Food DTC so there are no architectural promise nor semantic support for common food concepts. Once this PR lands, for platforms and businesses that negotiate against dev.ucp.food.* will get the benefits of the structured schema we are proposing here.

  1. Retail-side friction the food vertical will inherit
  • Reference samples (signing_keys[] vs. keys[]) & js-sdk profile schema: Please feel free to open issues on the samples and js-sdk repos so we can get those aligned with the normative top-level keys[] JWK Set and latest profile schema.
  • How should a platform advertise multi-version support?: By design (Overview — Protocol Version), version discovery is Business-first as the platform would fetch & negotiate with a business to identify a mutually supported protocol version D (this can be D with business A and D' with business B. The platform then will send its own profile URL for that corresponding version (D or D') in the UCP-Agent request header (in the case of REST transport). A platform can host multiple profiles and select the matching profile URL per business per request.
  • I don't quite understand the point on Update-by-full-replacement reaches into fulfillment groups[] given there is no fulfillment.groups[] concept in food here so will skip over this bullet unless more clarification is given.
  • Platform conformance suite: Same as the first bullet, please to open an issue or discussion on the relevant repository so we can continue from there!

@jingyli

jingyli commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @smasma42 for the detailed feedback! Some overlapping themes with other commenters:

1. "A restaurant that takes ASAP orders only" (scheduling vs. static pre-order horizon)

An optional hint on the supported scheduling per available fulfillment methods makes sense so it has been addressed as part of 3593bb8. However, we are keeping it as an open string with the proposed values as well-known values to be consistent with UCP's schema authoring guidelines.

However, we are holding off on specifying the furthest time a scheduled order can be placed as omitting it would enable us to keep a clear provisional vs. authoritative boundary. available_methods[] is a fulfillment hint, but the actual pre-order window can differ based on the bounded session composition. Instead, we will rely on businesses to provide the SoT and respond with fulfillment_time_unavailable if the requested time is outside of the supported pre-order window.

2. "A note that asks for something the menu cannot do, at no charge"

Addressed now, PTAL.

3. "What a continue_url exposes, and who may open it"

In general, we avoid prescribing browser-binding rules for continue_url directly inside the spec vs. giving flexibility to the business, especially since it will own the hosted web surface behind continue_url. The business will have full discretion over how that URL protects pre-filled buyer state - some strategies can include:

  1. Use short-lived or single-use handoff token in continue_url.
  2. Mask pre-filled PII until buyer authenticates on their surface.

4. "Totals when the tip is chosen after the hand-off"

We shouldn't need an additional bit to support the use case being described here. Right now, tip enforces strictly 3 different states (per Tips and Gratuity section in index.md):

  • Preset selection
  • Custom amount
  • Unset: no choice yet from the buyer (business MAY apply a default)

In the third case here (Unset), the platform already knows deterministically gratuity has not yet been specified by the buyer. Platform also cannot interpret and transform the elements in totals[] received from business (it remains the single source of truth of authoritative amounts. If Business wants to encourage platforms to display certain info to improve explainability, then they can do so via the display_text field in each entry.

@damaz91

damaz91 commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

📋 Discussed in UCP Shopping Tech Council (STC) Meeting - 2026-10-23

This pull request was referenced under the following agenda topic(s):

  • Direct-to-Consumer Proposals and Food Domain Feedback (view section)

See the full meeting minutes for discussion notes and action items.

@jingyli

jingyli commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @igrigorik for the extensive feedback! Responses to the points you have made:

Structural Feedback

  1. main.py regressions & scope: Raised by a comment thread above as well - all the regressions should now be addressed. I prefer keeping the main.py changes in this same PR so I can properly see whether the changes being introduced (e.g., being able to dynamically render capabilities, fulfillment, and types with the same name across different verticals properly on reference.md) is WAI.
  2. food/cart.json and cart_id:
    • Having cart_id as the sole attribute in cart.json is intentional: we want to allow the flexibility for Businesses to carry over a pre-defined cart (for example, a basket built on a restaurant's native app/web surface, kiosk, or group-order session) that Platform also understands directly into checkout via cart_id. There is no formal dev.ucp.food.cart capability definition yet (it's a future milestone from the Food DTC) so negotiation cannot happen on cart as a standalone capability.
    • We acknowledge this deviates slightly from how shopping/retail is currently dealing with cart-to-checkout conversion, but the DTC is vouching for this flexibility.
  3. tip placement (food/checkout.json vs. common/ extension): We chose to keep tip as an optional top-level property on dev.ucp.food.checkout rather than extracting it into an opt-in extension. In prepared food delivery and pickup, gratuity is a core step of nearly every checkout. Requiring every food Business and Platform to negotiate an extra capability extension just for tip introduces overhead for the 99% case, while keeping tip optional (ucp_request: optional) costs nothing when unused. Also when we see more a broader trend of verticals needing tipping, we can hoist tip.json and tip_option.json into common/types/ (following the principle of keeping localized scopes unless it's a common ask from all verticals).
  4. Shared types (buyer.json, food_establishment.json, order_confirmation.json):
    • buyer.json: We want to keep food/types/buyer.json as a vertical-owned type rather than consolidating into common/types/buyer.json. We want to uphold the principle that each vertical domain in UCP can design its own human/actor representations tailored to its domain semantics - just as lodging today defines booker.json and guest.json rather than sharing a common buyer.json. Also more tactically, post chore: Refactor schema references to common types #436, we decided to enforce the principle that once a type is promoted to common/, we cannot define vertical-level overrides, so we would need distinct names to compose common/types/buyer.json with.
    • location_summary.json / food_establishment.json: Aligned with the recommendation to hoist media into location_summary.json and remove food_establishment.json. I will keep a TODO to also clean up the definition from lodging in a separate PR.
    • order_confirmation.tracking_url vs. permalink_url: We are choosing to keep tracking_url because the post-checkout link returned to the buyer is almost always an ephemeral, token-scoped live kitchen/courier tracker (showing live prep state and courier GPS) that expires or redacts PII shortly after handoff. Calling it permalink_url like in retail/hotel booking is even more confusing given it will ultimately "expire".
  5. physical_address.json vs. delivery_destination.json: Aligned and addressed in 3593bb8.
  6. policies[] and actions: Addressed, PTAL!
  7. Schema <> prose contradictions: Addressed, PTAL!

Design Issues & Recommendations

  1. D1: Agreed and addressed! In fact it was an alternative pattern we have considered and there were no strong preference - we simply chose the pattern where it can demonstrate the association of modification on top of dish better.
  2. D2: Aligned and addressed, with the caveat that like mentioned above in feat: Introduce Food Checkout Capability and Fulfillment Extension #897 (comment), multi-select is intended so we have added normative clauses & prose on how to deal with conflicting selections.
  3. D3:
    • fulfillment_time_unavailable: Aligned and addressed!
    • There seems to be 2 edge cases if we provide the range in {earliest, latest}:
      • The "around 7:30 PM" containment trap: Restaurant delivery slots are typically 20–30 minutes wide (e.g., 7:15–7:45 PM). If a buyer says "Deliver around 7:30 PM" and the agent sends a ±10 min window {earliest: "19:20", latest: "19:40"} (or {earliest: "19:30", latest: "19:30"}), option ⊆ request rejects the 7:15–7:45 PM slot because [19:15, 19:45] ⊈ [19:20, 19:40].
      • The "by 7:30 PM" early-delivery trap: Under "omitted earliest = now", sending {latest: "19:30"} at 2:00 PM creates a requested window of [14:00, 19:30]. Under option ⊆ request, an ASAP slot at 2:30–2:45 PM is a valid subset match for a dinner order meant for 7:30 PM.
      • Tactically in practice, Food businesses take a single target timestamp to query available slots around that target, and return concrete options[] (and businesses control how many slots/options they want to return back to the buyer). Then the buyer/agent can inspect the exact windows and leverage selected_option_id as the binding commitment.
  4. D4: From an ergonomics standpoint, when a buyer says "Tip 18%", { "id": "tip_18", "unit": "percentage", "amount": 18 } is a 1:1 literal match with zero unit-conversion math. With { "id": "tip_18", "basis_points": 1800 } next to { "id": "tip_5", "amount": 500 }, 1800 in minor USD units is $18.00, creating a high risk that an agent may confuse 1800 bps with $18.00. Also unlike the design of restocking fees in feat: add return policy extension (dev.ucp.shopping.policy.return) #634, restaurant tip presets in food are never fractional percentages like 12.5% or capped by max_amount.
  5. D5, D6, D7, D8, D9: Aligned and addressed!
  6. D10: Similar feedback as the one shared in feat: Introduce Food Checkout Capability and Fulfillment Extension #897 (comment), Food DTC is deferring the modelling of structured allergens representation to later phase. The only in scope part is how to display allergens related disclosure warning in messages[].

@jingyli
jingyli requested a review from amithanda October 29, 2026 21:26

@frmoretto frmoretto left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the review request, @jingyli. I've focused on EU consumer-law implications, not a general review of transport or payment implementation.

  1. Tip defaults. In the Unset state the Business may apply a default, and on a fulfillment switch it clears or re-defaults selected_option_id, so a default ends up as a selection, and any non-zero tip goes into totals[]. The checked 18% in the example reads the same way. In the EU, an extra payment on top of the main price needs the consumer's express consent before they are bound, and consent inferred from a default they have to reject gives a right to a refund (Directive 2011/83/EU, Art. 22). Confirming the final total is useful, but it is not the same as choosing a tip.

Suggestion inline on the Unset row; the same would apply to the re-default on a fulfillment switch.

  1. Right of withdrawal. Some food orders fall under an exception to the EU right of withdrawal, for example goods that deteriorate rapidly (Art. 16(d)), though not every item in a basket does. Where an exception applies, the consumer has to be told before the contract that the right does not apply (Art. 6(1)(k)). Is there a place in the checkout flow for that information? The food-safety disclosure tier may not be the right home; an existing terms or legal-information mechanism could be.

  2. Allergens: fine with the Phase 2 plan agreed above, together with #964 and the delivery side.

Francesco Marinoni Moretto

| :--- | :--- | :--- | :--- |
| Preset | ✅ | — | Buyer picked a suggested option (on update). |
| Custom | — | ✅ | Buyer entered an amount in minor units (`0` = no tip). |
| Unset | — | — | No choice yet; the Business **MAY** apply a default. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
| Unset | — | — | No choice yet; the Business **MAY** apply a default. |
| Unset | — | — | No choice yet; the Business **MAY** suggest a default for the Platform to highlight. Where applicable law requires express consent to extra payments, an Unset tip adds nothing to `totals[]`. |

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:food Issues and pull requests related to the Food ordering vertical area:payments Issues and pull requests related to the Payments vertical gov:needs-tc-review Requires review and approval from the Technical Council status:under-review

Projects

None yet

Development

Successfully merging this pull request may close these issues.