Repository navigation
Conversation
… rendering and deep link creations due to potential naming collisions.
|
The operation names ( Two suggestions:
|
|
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:
Because of this, there is intentionally no formal relationship or shared normative schema binding between 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. |
|
Thanks for bringing up this PR @jingyli . Below are some comments about it: 1. Singular Delivery Destination
Because of this mismatch, 2.
|
|
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: 1. No structured pickup handoff. 2. The collector is assumed to be the buyer, in person. 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. |
|
Coordination note on Nothing needs to change here — once this lands I'm happy to send Food the same change as a follow-up. #914 also touches |
|
On the 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 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.
|
+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:
Happy to share our mapping of these onto the current schemas if useful. |
|
@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. |
igrigorik
left a comment
There was a problem hiding this comment.
This is great work. Well-thought-out and leverages UCP correctly in many ways. 👏🏻
Structural feedback
-
main.pyis a second PR inside this one. ±1,400 lines of docs engine, with two regressions @amithanda identified still at HEAD (_restore_fragment_refsnever called;_variant_base/_inherit_base_fieldsgone, so the Media tables lose inherited rows). Can we split it, land it first with a rendered-docs diff, rebase food on it? -
food/cart.jsonis an empty capability with unreachable glue.dev.ucp.food.carthas 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 bothfood.checkoutandfood.cartfails with multiple root capabilities. It's shopping's pre-#940 shape, abandoned onmainfor this reason. Stepping back, do you need cart at all? You can dropcart.json,cart_idand the L433 example; makefood_establishmentrequired on create and update. -
Tip is defined in the vertical; it's a cross-vertical concept. Retail, hotels, salons and all tip; the next vertical either
$refs intofood/or copies. Can we extracttip/tip_optiontocommon/as an opt-in extension composing onto checkout (the discount precedent) so platforms negotiate whether to build tip UI; addtipto the well-known total types. -
Shared types copied where they should compose.
buyer.jsonis byte-identical to shopping's — hoist tocommon/types/buyer.jsonandallOfwhen food needsdietary_restrictions.food_establishmentislocation_summary+media— addmediatolocation_summary(pickup destinations and lodging properties want it too), drop the wrapper, keep the field name.order_confirmationrenamespermalink_url→tracking_urlfor no structural gain — keep the name; prose says what the page shows. -
physical_addressis a fourth "place" idiom; the gap isgeoon the destination.common/already haslocation's{address, geo},location_serves'{point} | {address}, and shopping'sshipping_destination(flat postal, nogeo). Food's destination now nests where shopping's is flat — two serialisers for the one role the verticals share. Add optionalgeotoshipping_destination(additive), makedelivery_destinationthat pattern plus preferences, deletephysical_address.json. Keep the geo-for-dispatch / address-for-display / flag-if-divergent rule as prose on both — it's good. -
policies[]andactionsdropped from the checkout. Both optional output, both needed soon (cancel windows and 86'd-item refunds are policies; 3DS needsactions). Carry them over into food. -
Schema <> prose contradictions (verified against resolved schemas):
tip.selected_option_idadmitted on create thoughoptions[]first exists in a response (selected_term_idhas the right annotation); pickupdestination"response-only" but a Platform-suppliedbusiness_locationvalidates, even strict; RESTupdate_checkoutL169 still says PATCH ("if omitted, unchanged") and MCP lacks the no-Update-during-complete_in_progressrule; Pattern 3'sCourier 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 adate-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'searliest_fulfillment_time. The Location hours work settled this foropen_at: one instant, one owner, no sentinel. Let's reuse the lessons learned from that design.location_closedconflates three states — closed now, open but no capacity at that time, and does-not-take-scheduled-orders. Addfulfillment_time_unavailable(@PedroNatali) and an ASAP-only signal onavailable_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
noteto the kitchen verbatim and MUST NOT act on it commercially (no charge, no structured change). - It MAY return
note_contains_modificationwhen 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 returnnote_not_supportedfor 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.
|
@igrigorik, on Design 8 and 10 of your review: this is a better home for it than what #964 proposed. Two lines I'd keep in the normative text when it moves:
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. Francesco Marinoni Moretto |
|
📋 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. |
|
📋 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.
…d schema alignment.
|
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:
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 |
|
Thanks @ifeanyinw for the thorough feedback, responses to the raised points:
Completely agree on the importance of fee transparency, which is why we adopt a shared
There are 2 angles to consider for this question:
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:
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
|
|
Thanks @smasma42 for the detailed feedback! Some overlapping themes with other commenters: 1. "A restaurant that takes ASAP orders only" (
|
|
📋 Discussed in UCP Shopping Tech Council (STC) Meeting - 2026-10-23 This pull request was referenced under the following agenda topic(s):
See the full meeting minutes for discussion notes and action items. |
|
Thanks @igrigorik for the extensive feedback! Responses to the points you have made: Structural Feedback
Design Issues & Recommendations
|
frmoretto
left a comment
There was a problem hiding this comment.
Thanks for the review request, @jingyli. I've focused on EU consumer-law implications, not a general review of transport or payment implementation.
- 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 intototals[]. 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.
-
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
disclosuretier may not be the right home; an existing terms or legal-information mechanism could be. -
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. | |
There was a problem hiding this comment.
| | 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[]`. | |
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: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.dish,modification): A line item is a dish plus priced, menu-backed modifications (protein, spice level, extra toppings) and a free-form kitchennote. Two units of the same dish with different modifications or notes are distinct line items, and notes MUST NOT carry paid add-ons.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 pricedtotals[]entry.#42) and a live tracking link rather than a static receipt.Scope
In Scope
dev.ucp.food.checkout): Full session lifecycle (incomplete,requires_escalation,ready_for_complete,complete_in_progress,completed,canceled) with deterministic handoffs viacontinue_url(e.g., alcohol ID verification, catering review).physical_addressprimitive incommon/types/(postal address and/or WGS 84geo).food_establishment,line_item,dish,modification,buyer,tip,tip_option, andorder_confirmation.dev.ucp.food.fulfillment): Order-widedelivery/pickupmethods with type-discriminated destinations (delivery_address,business_location), ASAP vs. scheduled timing viarequested_fulfillment_time, priced time-windowoptions[], drop-offpreference_options[], couriernotes, andavailable_methods[].cart_idsupport on Create Checkout viafood/cart.json.location_closed,minimum_order_unmet,note_contains_modification, plusdisclosure-tier guidance forconsumer_advisory,allergens, andnutritional_info.payment_ap2_mandateontodev.ucp.food.checkout.main.pyschema macro renderer for filesystem-discovered verticals, relative$refresolution, and path-qualified reference anchors; nav,llms.txtdescriptions, schema reference sections, and test scaffolds for food contracts.Out of Scope
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, andtotals[].2. Dish Customization: Structured Modifications vs. Kitchen Notes
dish.modifications[]title/price.dish.notemodification.quantityapplies per unit of the dish (default1); repeated units MUST be expressed viaquantity, not repetition.subtotal = (dish.price + Σ(mod.price × mod.quantity)) × line_items[].quantity.note_contains_modificationwarning:{ "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
tipobject separates what the Business suggests from what the buyer chooses;totals[]holds the resolved money:tip.options[], Business-authored):id,unit(percentage|absolute), andamount(percent or ISO 4217 minor units). No labels — Platforms render "18%", "$5".selected_option_idorcustom_amount(or neither, letting the Business apply a default); both is rejected by schemaoneOf.type: "tip"entry intotals[]. Sincetipis a food-defined total type,display_textis 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:
line_item_ids[])methods[]→groups[]→options[]methods[]→options[]destinations[]+selected_destination_iddestination, discriminated bytyperequested_fulfillment_time)preference_options[]+selected_preference_option_ids[]+notesdelivery_address(Platform-authoredpostal_addressand/orgeo, at least one required) orbusiness_location(Business-authored, response-only).requested_fulfillment_timemeans ASAP; an RFC 3339 timestamp means scheduled. If a requested time cannot be honored, the Business returns arecoverableerror and SHOULD offer nearest alternativeoptions[].type: "fulfillment"in checkouttotals[]; 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
location_closed(closed / paused intake) andminimum_order_unmet(basket below delivery minimum), with precisepathtargeting so Platforms can offer targeted recovery.consumer_advisory,allergens,nutritional_info, andtotals_changedusepresentation: "disclosure"and MUST be shown prominently near the referenced item and never auto-dismissed. Operational context (e.g.,high_demand) usesnotice.completed,ordercarriesid(required),tracking_url(required, live preparation/courier status), and an optional shortlabelused 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
shoppingand resolved$refs by bare filename. Because food and shopping share file names (e.g.,food/fulfillment.jsonvs.shopping/fulfillment.json,food/types/line_item.jsonvs.shopping/types/line_item.json), this produced incorrect cross-links and table contents.main.pywas refactored to:source/schemas/sub-directory exceptcommon/andtransports/), with no vertical treated specially.$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.#food-types-fulfillment) fromauto_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.mdnow passes an explicitbase_dirfor shopping and adds food sections alongside common, shopping, and lodging.Category (Required)
ucp-schematool (resolver, linter, validator). (Requires Maintainer approval)Related Issues
#518
Checklist
!for breaking changes).Screenshots / Logs (if applicable)