feat: Introduce Lodging Booking Capability and Cancellation Policy Extension - #780
feat: Introduce Lodging Booking Capability and Cancellation Policy Extension#780jingyli wants to merge 12 commits into
Conversation
| from provisional discovery to an authoritative state. The Business locks | ||
| or evaluates real-time inventory, resolves binding rate rules, enforces | ||
| room capacity bounds, calculates totals (`totals[]`), and attaches | ||
| authoritative cancellation terms (`policies[]`). |
There was a problem hiding this comment.
What is the relationship between room_rates[].totals and the root totals?
Nothing in room_rate.json, booking.json, or index.md defines the scope of a per-room-rate totals block. Both use common/types/totals.json, whose description says "MUST contain exactly one subtotal and one total entry" and whose rendering contract says platforms MUST render all entries. A platform sees two structurally identical, equally authoritative price blocks with no rule for which to show.
The examples do not disambiguate it and do not reconcile. In every response example across both bindings (rest.md:208/229, 454/504, 662/712, 845/895, 1028/1078, and the mcp.md equivalents):
itinerary: 2026-07-15 -> 2026-07-21 (6 nights)
room_rates[0].totals: subtotal 55000, tax 5500, total 60500
root totals: subtotal 385000, tax 38500, fee 1000, total 424500
There is exactly one room rate. If room_rate.totals covered the stay, the root subtotal would be 55000. If it is a nightly rate, the root subtotal should be 6 x 55000 = 330000. It is 385000, which is 7 x 55000. Under either reading the flagship example is wrong, and the two readings differ by a factor of six in what a platform displays next to the room. Each block is internally consistent (10% tax, totals sum correctly), so this only shows up when you cross-check the two against the night count, which nothing automated does.
totals[].lines already exists for exactly this: "Optional itemized breakdown. The parent entry is always rendered; lines are supplementary. Sum of line amounts MUST equal the parent entry amount." Could we state the scope normatively and use lines for the nightly view?
| authoritative cancellation terms (`policies[]`). | |
| authoritative cancellation terms (`policies[]`). | |
| ### Pricing scope | |
| `room_rates[].totals` states the price of that room rate for the **entire** | |
| itinerary, not per night. The root `totals` is the authoritative booking total and | |
| **MUST** equal the sum of all `room_rates[].totals` plus any booking-level fees and | |
| taxes. Businesses **MAY** provide a per-night breakdown using `totals[].lines`, | |
| which is supplementary and does not change the parent amount. Platforms **MUST** | |
| render the root `total` as the price of the booking. |
Which reading do we intend? Whichever it is, the example arithmetic needs a pass, since 385000 does not equal either 55000 or 6 x 55000.
There was a problem hiding this comment.
Looking at this comment and one below (#780 (comment)), I realized the original specification documentation was not descriptive enough on the key pricing concept in lodging (and did not capture a lot of interesting DTC discussions and alignments around how terms should be modelled).
Enhanced the documentation as part of 87976f7 and provided some more concrete examples. PTAL!
|
|
||
| The `payment` object is optional on booking creation and may be omitted for | ||
| use cases that don't require immediate payment processing (e.g., pay after | ||
| arrival or hold-with-card). |
There was a problem hiding this comment.
Does payment being complete: "required" contradict the pay-at-property text here?
The annotation on booking.json:167-171 is {"create": "optional", "update": "optional", "complete": "required"}, and ucp-schema resolve --op complete --request returns required: ["payment"]. So payment is optional right up until the point where it becomes mandatory, and a genuine pay-at-property reservation with no card guarantee cannot be completed.
This is inherited verbatim from checkout.json, where it is correct, because checkout never claims payment is optional at completion. Lodging adds prose that contradicts the inherited annotation, so one of the two has to move. What do you think?
There was a problem hiding this comment.
Just sharing my naive thought on this front: I think payment being required in complete_booking_session remains the correct contract, even for the pay-at-property scenario where no instrument guarantee is needed.
This is because right now our modelling of payment_terms (where the deferred/pay-at-property schedule would be specified) is an extension that decorates the payment field. We need a final state to which the user locks in a selected payment term, regardless whether a payment token will be processed as part of the operation or not and enforcing the required payment construct in complete_booking_session makes the selection explicit (not relying on the latest selected term in an update call) while also keeping interface consistency/parity with lower-funnel checkout capabilities in retail shopping.
| "properties": { | ||
| "id": { | ||
| "type": "string", | ||
| "description": "Stable, opaque unique identifier of the room rate binding.", |
There was a problem hiding this comment.
How does a booking express two identical rooms?
room_rates[] has no quantity, and room_rate.id is a compound offer key (rt_luxury_queen__rp_avg_base_rate in every example) rather than a per-unit instance id. To book two Luxury Queen rooms on the same rate plan a platform emits two array entries with the same id:
"room_rates": [
{ "id": "rt_luxury_queen__rp_avg_base_rate", "guest_assignments": [{ "guest_id": "gst_01", "role": "primary_guest" }] },
{ "id": "rt_luxury_queen__rp_avg_base_rate", "guest_assignments": [{ "guest_id": "gst_03", "role": "primary_guest" }] }
]applies_to: ["$.room_rates[0]"] is then positional, and since Update is a full-replacement PUT, a business that reorders the array in a response silently retargets a room-scoped cancellation policy to the other room. There is also no stable handle for "the second room" across updates.
A quantity field would conflict with per-room guest_assignments (you cannot assign different guests to each of two rooms collapsed into one entry with quantity: 2), so making the id a per-unit instance identifier seems cleaner. The offer key is already fully expressed by room_type.id + rate_plan.id.
| "description": "Stable, opaque unique identifier of the room rate binding.", | |
| "description": "Stable, opaque identifier for this room rate binding. Identifies one bookable unit: when a booking contains several rooms of the same type on the same rate plan, each MUST carry a distinct `id`. The commercial offer is identified by `room_type.id` + `rate_plan.id`, not by this field.", |
The PR's motivation calls out multi-room bookings explicitly, so would a two-room example in rest.md be worth adding to settle it either way?
There was a problem hiding this comment.
Quantity vs Repeated Entry Modeling
We explicitly opted not to model quantity for RoomRate. It is more common in the lodging industry to represent multiple room bookings as separate entries, and as you've already noticed there are fields such as guest_assignments which can differ between entires.
ID Uniqueness
RoomRate.id, RoomType.id, and RatePlan.id are all identifiers that correspond to Business-specific concepts and should be retrieved from an upper-funnel discovery mechanism for that Business. Requiring uniqueness of these fields is therefore infeasible, as two entries for the same type of room and rate plan would have matching ids that are needed for Business lookups.
You may also note from the anyOf in RoomRate that RoomRate.id is not required if RatePlan.id and RoomType.id are both present, so targeting based on this field would be insuficient.
Policy targeting
As for handling applies_to targeting, it's a fair callout that relying on order/indexing has the potential to be error-prone if order is not preserved by the Business, however this is also explicitly listed as a supported mechanism in the Targeting spec and Policies Spec.
It would be my preference to maintain index based targeting and reinforce through documentation the importance of order persistence; however, I will present an alternative:
A Business may present a uid in the response for any schema with "additionalProperties": true and target that if they would prefer that to index based targeting. For example:
"room_rates": [
{ "uid": "room_rate_1", "id": "rt_luxury_queen__rp_avg_base_rate", "guest_assignments": [{ "guest_id": "gst_01", "role": "primary_guest" }] },
{ "uid": "room_rate_2", "id": "rt_luxury_queen__rp_avg_base_rate", "guest_assignments": [{ "guest_id": "gst_03", "role": "primary_guest" }] }
]Which could then be targeted with applies_to: ["$.room_rates[?@.uid=='room_rate_1']"] to target a specific room_rates[] entry or applies_to: ["$.room_rates[?@.id=='rt_luxury_queen__rp_avg_base_rate']"] to target all RoomRates of that id/type.
|
Great to see the Lodging TC defining cancellation directly on the Booking surface. I agree with keeping the first version focused, but there is one gap worth resolving before the wire shape settles. The PR says the extension enables platforms to answer questions such as "What is the penalty cutoff?" deterministically. At present, however, the schema structures only the current For example, the included "free until Dec 20; one-night penalty thereafter" policy still requires a platform to parse prose before it can determine either the applicable deadline or the amount the buyer receives back. I raised this tiered, anchor-relative policy class while
The corpus showed that the recurring deterministic core is an anchor plus ordered tiers, with outcomes expressed as percentages, fixed fees, or unit deductions such as "one night." It also covers no-show outcomes, which are particularly relevant to lodging. I do not think this PR necessarily needs to absorb the full model immediately. Two incremental options seem possible:
I would be happy to contribute a focused patch or adapt the existing test vectors to the Lodging TC's preferred shape. |
amithanda
left a comment
There was a problem hiding this comment.
Added some minor documentation clean up suggestions. PTAL.
|
Coexistence with checkout-based lodging implementations We're a hotel company that has built a UCP integration representing hotel bookings on the generic checkout capability (lodging details carried as custom fields on checkout sessions), developed against a major agent platform's integration requirements and currently in pre-production. With
Even a short non-normative "transitioning from checkout-based lodging" note in the docs would help early implementers plan the rebuild with confidence about what's stable vs. still evolving. |
|
Machine-readable cancellation policy fields The Leaving penalties in free text + URL pushes agents back toward parsing policy prose, which reintroduces exactly the ambiguity this extension exists to remove. |
|
Partial operation support / conformance floor Is a business conformant if it supports create / get / complete / cancel but not |
|
Merchant-defined extensions / custom fields What is the recommended pattern for merchant-specific data on booking sessions — loyalty identifiers, special requests, accessibility needs, marketing preferences? Checkout-based implementations commonly lean on free-form |
|
Loyalty membership and member-only rates How should Suggested: an eligibility indicator on |
|
Session state semantics: Please define normative triggers and expected agent behavior for |
|
Thanks, @juliekye. On your machine-readable cancellation policy point, the deterministic "what happens if I cancel Tuesday?" case is exactly the gap explored in draft #808, following the discussion here. It proposes an optional anchor-relative schedule alongside Would your team be open to sharing one synthetic policy example with the expected terms just before and exactly at a cutoff? I can turn it into a test vector against the draft for your team to check, including any gaps in the representation. No confidential policy or customer data would be needed. |
|
@yairsabag Happy to — here's a synthetic example modeled on a common lodging pattern (no real policy data). Synthetic policy (prose): "Free cancellation until 6:00 PM property-local time two days before check-in. Cancellations at or after that time are charged one night's room rate (excluding taxes and fees)." Concrete reservation for the vector:
Expected selected terms:
That is, our expectation reads "until 6:00 PM" as strictly-before: free interval Two things this example intentionally probes, which may be representation gaps:
Glad to check the resulting vector against additional variants (multi-tier schedules, non-refundable-from-booking) if useful. |
|
Thanks, @juliekye, I've added your synthetic example, with source attribution, to #808 in a30b613. Boundary: your expectation matches the draft. The Phoenix schedule uses Local-clock templates: you're right that the earlier offset/DST cases did not test template resolution. New, separate test-only checks use IANA timezone data to derive the chosen local cutoffs: Phoenix is 45 hours; the New York spring/fall variants I added are 44/46 hours. These are unambiguous local-time examples, not a general template compiler or a policy for repeated/nonexistent local times. The wire schedule remains reservation-specific resolved instants and elapsed offsets. Penalty basis: not currently. The shared The suite passes 52 selection/fallback cases, 26 schema expectations, 3 local-policy derivations, and 8 complete outcome assertions. Could you check that the Phoenix cases preserve your intended semantics, and whether a Business-resolved concrete amount is useful for your integration alongside the remaining need for an explicit basis? |
mecolmg
left a comment
There was a problem hiding this comment.
Minor description suggestions, but otherwise LGTM 👍
| }, | ||
| "total": { | ||
| "type": "integer", | ||
| "description": "Total number of occupants. MUST be equal to adults + sum(children.total).", |
There was a problem hiding this comment.
| "description": "Total number of occupants. MUST be equal to adults + sum(children.total).", | |
| "description": "Maximum number of total occupants (adult or child). If adults and/or children are present, this MUST be greater than or equal to max(adults, children.items[].total).", |
There was a problem hiding this comment.
I updated the first part of the sentence, but kept the invariant the same what we have right now (adults + sum(children.total)) as I feel that's a tighter contract - you either specify the breakdown into adult and children OR you specify a bare-bone total.
Specifying partial combinations doesn't make much sense to me for 2 reasons:
- Platforms may need to make inferences based on the difference for any missing category (e.g., having
adult&totalwill mean that platforms need to inferchildrenbased on the difference) - What does it mean when we have
total > adults + sum(children.total)? This would be allowed by the new wording, but what does it mean in practice, what is this difference?
| "type": "object", | ||
| "description": "Request metadata.", | ||
| "required": [ | ||
| "ucp_agent", |
There was a problem hiding this comment.
The specification (mcp.md:87-90), all documentation examples, and the reference implementation in Retail Shopping (mcp.openrpc.json:52-68) all use kebab-case ('ucp-agent').
Should we maintain consistency with them and use 'ucp-agent' ?
There was a problem hiding this comment.
The same observation for 'idempotency_key' as well
| collected if required, lead guest identification via `booker` or | ||
| `primary_guest`, and all outstanding gating actions resolved) and platform | ||
| can finalize programmatically. Platform can call Complete Booking Session. | ||
| * **`complete_in_progress`**: Business is processing the Complete Booking |
There was a problem hiding this comment.
Could we port the "Accepted completion" operation contract from Checkout into this section to clarify what operations are permitted while in complete_in_progress?
Retail Checkout already addresses this in checkout/index.md#accepted-completion. How about expanding line 366 with a similar operation table?
There was a problem hiding this comment.
Same flag.
complete_in_progress is in the enum and the lifecycle diagram, but the contract that gives it meaning isn't here. Checkout's Accepted completion defines what each operation does while Complete is in flight — Get MAY poll with bounded backoff and MUST stop at expires_at; Update MUST NOT start and the Business returns the unchanged session with a recoverable error; Complete MUST NOT start anew, with a narrow lost-response retry using the same idempotency key and a fresh key for any genuinely new Complete; Cancel only after fallback and continue_url handoff are exhausted, and the session is not canceled until the Business says status: canceled. None of that is in this doc, and hotel completions — PMS and channel-manager round trips — are exactly the slow, asynchronous case it exists for. Without it, a Platform that times out on Complete has no spec-defined safe recovery for a booking that may or may not have been confirmed.
Given the duplication point above, I'd rather this doc reference checkout's section with a one-paragraph lodging note than port fifty lines — but one or the other has to happen before complete_in_progress means anything here.
|
@yairsabag Confirmed — the Phoenix cases match our intent exactly: On the Business-resolved amount: yes, that works well for us. Our systems already calculate the exact charge for a specific reservation at booking time, so filling in a resolved
Thanks again for turning this around so quickly. |
|
Thanks, @juliekye, your confirmation of both the boundary behavior and the Business-resolved amount is very helpful. I've added a short snapshot clarification in #808: the amount reflects the reservation terms used in its calculation, not a reusable formula. Changes to those terms may require an updated Business-provided amount even within the same booking session. One representation clarification: when using |
There was a problem hiding this comment.
Overall, this is great work!
Left a number of questions and suggestions. Some are simple nits and easy to fix divergences, but the big ones I want to flag and align on are...
- Schema is "room" centric, which under-models the domain, and it would be a breaking change to fix this + have ripple consequences through prose and schemas. As a request to the Lodging TC, please revisit this and explore how we can make this generic+polymorphic. This is not a novel pattern for UCP, we just need to carefully apply it here.
- We (re)invented due-now vs due-later in the payment schemas, with broken invariants over sum+verify that we need to restore. PTAL below.
- Remove -sessions to match shape of shopping operations.
Wrestled with the payment for a while, I think we took a small (wrong) detour...
1. The new totals[] types fail the shared verification rule. checkout/index.md § Verification — the normative home of totals.json — lets a Platform verify sum(non-total entries) == total, and on mismatch says it MUST NOT autonomously complete. due_now and due_at_property are sums of their sibling entries in the same flat array, so every example that uses them double-counts:
| Pricing Scope example | Σ non-total |
total |
|
|---|---|---|---|
| Pattern 1 (property-collected) | 152 800 | 76 400 | 2× — Platform must not complete |
| Pattern 2 (upfront + savings) | 260 000 | 130 000 | 2× |
| Pattern 3 (deposit + balance) | 270 000 | 135 000 | 2× |
rest.md / mcp.md examples (no new types) |
= | = | pass |
Because totals[].type is an open vocabulary, this can't be fixed by exempting named types in a generic verifier -- the rule only works if every entry is an addend. Two smaller things ride along: root subtotal becomes the deposit (40 000) while room_rates[0].totals.subtotal is the full stay (120 000), so one shared type now carries two meanings of subtotal; and the deferred MUST keys on a value that terms.md says "carr[ies] no protocol meaning beyond 'not immediate'".
2. Payment Terms already models this. terms.md opens with "A lodging Business can offer one term that charges the full stay at booking and another that charges the first night now and the balance at check-in", and its worked example is the same pt_pay_now / pt_deposit_balance scenario this PR uses. Three of its rules decide the placement question:
totalsis price; schedules are timing. "Where the selected term changes what the purchase costs — a discount for paying today, a finance charge — that difference MUST appear as its own entry incheckout.totals." Only price deltas enter totals; the amount due today is the sum of the selected term'simmediateschedules.- The due-now / due-later split is a Platform-derived view, not wire data: "
typeanddue_atlet a Platform that wants to do more — split the checkout into due-now and due-later, sort schedules, drive a calendar reminder — without ever being required to." - On the "terms is optional, so core totals must be unambiguous alone" point from the earlier thread: terms handles optionality by making silence mean due-in-full — "Where payment is due in full at completion, a Business may omit
terms… and the Checkouttotalis the amount due." A Business with deferred or property-collected timing therefore advertises terms (it'scommon/, with a deliberately trivial presentation floor), the same way a Business with shipping choices advertises fulfillment. A single term is explicitly "disclosing rather than asking."
The PR's payment.terms[] block is already correct. The proposal is just that totals[] shrinks back to price, and the split is read off the schedules.
3. What Patterns 3 and 1 look like under that. Deposit & balance (same numbers as today):
"totals": [
{ "type": "subtotal", "display_text": "Room rate (3 nights, 1 room)", "amount": 120000,
"lines": [ { "display_text": "Jul 15", "amount": 40000 },
{ "display_text": "Jul 16", "amount": 40000 },
{ "display_text": "Jul 17", "amount": 40000 } ] },
{ "type": "fee", "display_text": "Service fee", "amount": 3000 },
{ "type": "tax", "display_text": "State lodging tax (10%)", "amount": 12000 },
{ "type": "total", "display_text": "Total stay cost", "amount": 135000 }
],
"payment": {
"selected_term_id": "pt_deposit_balance",
"terms": [
{ "id": "pt_pay_now", "title": "Pay now",
"description": { "plain": "Save $50 by paying for your stay today." },
"schedules": [ { "id": "sched_full", "type": "immediate",
"description": { "plain": "Due today when you book." }, "amount": 130000 } ] },
{ "id": "pt_deposit_balance", "title": "First night now, balance at check-in",
"description": { "plain": "Hold your room with one night's rate plus its service fee and tax." },
"schedules": [
{ "id": "sched_first_night", "type": "immediate",
"description": { "plain": "Due today when you book: first night, service fee, and tax on the deposit." },
"amount": 47000 },
{ "id": "sched_balance", "type": "deferred",
"description": { "plain": "Due at check-in on July 15, 2026 at 3:00 PM PDT: remaining 2 nights and tax." },
"due_at": "2026-07-15T15:00:00-07:00", "amount": 88000 } ] }
]
}Σ non-total = 135 000 = total; selected term schedules 47 000 + 88 000 = 135 000; a Platform that wants "Due today $470 / Due at check-in $880" computes it from type. Property-collected charges (Pattern 1) are ordinary fee / tax entries — they are part of the price — and their timing is one disclosing term:
"totals": [
{ "type": "subtotal", "display_text": "Room rate (3 nights, 1 room, incl. 10% consumption tax)", "amount": 64000 },
{ "type": "fee", "display_text": "Service fee (10%)", "amount": 6400 },
{ "type": "fee", "display_text": "Resort & facility fee — paid at the hotel (¥2,000/night)", "amount": 6000 },
{ "type": "tax", "display_text": "Tokyo accommodation tax — paid at the hotel (¥200/guest/night)", "amount": 1200 },
{ "type": "total", "display_text": "Total stay cost", "amount": 77600 }
],
"payment": {
"selected_term_id": "pt_standard",
"terms": [
{ "id": "pt_standard", "title": "Room charged now, hotel fees at check-in",
"description": { "plain": "Your room and service fee are charged today. The resort fee and Tokyo accommodation tax are collected by the hotel when you check in." },
"schedules": [
{ "id": "sched_room", "type": "immediate",
"description": { "plain": "Due today when you book: room rate and service fee." }, "amount": 70400 },
{ "id": "sched_property", "type": "at_property",
"description": { "plain": "Collected by the hotel at check-in: resort & facility fee and Tokyo accommodation tax." },
"amount": 7200 } ] }
]
}Both validate against payment_terms.json#/$defs/dev.ucp.lodging.booking and pass the checkout assert. The local-tax disclosure then targets the schedule (path: "$.payment.terms[0].schedules[1]"), which is where terms.md puts timing disclosures, and doesn't shift when totals are recomputed.
4. The one real gap, and where it belongs. Pattern 1's second schedule is collected by the property, not charged to the instrument the Platform supplies. terms.md currently says the funding instrument "MUST be capable of every one of [the term's schedules]", which a pay-at-desk schedule doesn't fit. That's a one-sentence clarification to terms.md (a schedule whose description states it is settled directly with the Business at a future event isn't funded through this checkout).
Concretely: drop due_now, postpaid_subtotal, postpaid_fee, postpaid_tax, due_at_property and the accounting-invariants table; keep total all-in and subtotal as the full-stay room charge (so Σ room_rates[].totals.subtotal = root subtotal); replace "Immediate vs. Deferred Payment Breakdown" with a short "Payment timing" section that points at terms.md and shows Pattern 1 as a single term; re-run the sum check over the examples. Happy to push a branch with exactly that diff if it's easier to review as code than as a comment.
| responses provide provisional rates, available room types, and policy | ||
| summaries based on search parameters. | ||
| * *Booking Session (Authoritative)*: Creating a booking session transitions | ||
| from provisional discovery to an authoritative state. The Business locks |
There was a problem hiding this comment.
"locks" suggests reservation, but I assume reservation does not happen until payment? Or if it is, how does this reservation behave? TTL expiry? Release mechanism? What happens when agent rolls through 15 different requests by mistake.. does that lock up inventory for next X minutes?
| * **`canceled`**: Booking session is invalid or expired. Platform should | ||
| start a new booking session if needed. | ||
|
|
||
| ### Actions |
There was a problem hiding this comment.
Meta: we're duplicating actinos, error handling, ..., across verticals, which is quick and easy path for divergence and subtle bugs. We should explore how we can consolidate these surfaces. Non-blocking.
| room capacity bounds, calculates totals (`totals[]`), and attaches | ||
| authoritative cancellation terms (`policies[]`). | ||
|
|
||
| ### Pricing Scope |
There was a problem hiding this comment.
We're citing US and EU, but that excludes many other regions, I'd suggest we generalize to "applicable consumer price-display law".
|
|
||
| | Tool | Operation | Description | | ||
| | :------------------------- | :------------------------------------------------------------------ | :------------------------- | | ||
| | `create_booking_session` | [Create Booking Session](index.md#create-booking-session) | Create a booking session. | |
There was a problem hiding this comment.
Nit, why do we need _session suffix everywhere? We explicitly avoided it in shopping: get_booking, create_booking, etc.
There was a problem hiding this comment.
This is an explicit differentiation as the order equivalent in lodging industry is also a "booking", so for operations like cancel_booking this may be confused with the post-payment operation to actually cancel the entire booking/reservation.
Adding the _session suffix makes it clear that all operations pertain only to the active booking session and has no side effects - this is also consistent with the REST checkout modelling in shopping - https://ucp.dev/latest/specification/shopping/checkout/rest/#operations.
| "meta": { | ||
| "type": "object", | ||
| "description": "Request metadata.", | ||
| "required": [ | ||
| "ucp_agent", | ||
| "idempotency_key" | ||
| ], | ||
| "additionalProperties": true, | ||
| "properties": { | ||
| "ucp_agent": { | ||
| "type": "object", | ||
| "description": "Platform agent identification. Maps to HTTP UCP-Agent header.", | ||
| "required": [ | ||
| "profile" | ||
| ], | ||
| "properties": { | ||
| "profile": { | ||
| "type": "string", | ||
| "format": "uri", | ||
| "description": "URL to the platform's UCP profile document." | ||
| } | ||
| } | ||
| }, | ||
| "idempotency_key": { | ||
| "type": "string", | ||
| "format": "uuid", | ||
| "description": "Unique key for retry safety. Maps to HTTP Idempotency-Key header." | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
The meta schema here diverges from every other UCP MCP service on the wire. shopping/mcp.openrpc.json has used ucp-agent / idempotency-key (header-style, "Maps to HTTP UCP-Agent header") since #154 and never changed; overview/index.md, cart/mcp.md, order/mcp.md all match. This file requires ucp_agent / idempotency_key — so a Platform's shared MCP envelope code fails validation against a lodging server and vice versa, which is the one thing a common/ envelope exists to prevent.
Two things make it worth fixing before merge rather than after:
- Every
metaexample in this PR's ownmcp.md(lines 87, 157, 395, 640) uses the hyphenated keys, so the doc's examples are invalid against this schema. CI can't see it — theucp:examplemacro extracts$.params.arguments.bookingand never validatesmeta. ucp-schema lintalready flags it:lodging/mcp.openrpc.jsonW007 "ucp_agentis not a UCP annotation and has no effect; theucp_prefix is reserved." Shopping'smcp.openrpc.jsonproduces no such warning.
Separately, required here lists idempotency_key for every op, including get_booking_session, whereas shopping requires it at the meta level only for ucp-agent and narrows complete/cancel with an allOf (see the two threads on those methods). The suggestion is shopping's block verbatim:
| "meta": { | |
| "type": "object", | |
| "description": "Request metadata.", | |
| "required": [ | |
| "ucp_agent", | |
| "idempotency_key" | |
| ], | |
| "additionalProperties": true, | |
| "properties": { | |
| "ucp_agent": { | |
| "type": "object", | |
| "description": "Platform agent identification. Maps to HTTP UCP-Agent header.", | |
| "required": [ | |
| "profile" | |
| ], | |
| "properties": { | |
| "profile": { | |
| "type": "string", | |
| "format": "uri", | |
| "description": "URL to the platform's UCP profile document." | |
| } | |
| } | |
| }, | |
| "idempotency_key": { | |
| "type": "string", | |
| "format": "uuid", | |
| "description": "Unique key for retry safety. Maps to HTTP Idempotency-Key header." | |
| } | |
| } | |
| } | |
| "meta": { | |
| "type": "object", | |
| "description": "Request metadata.", | |
| "required": [ | |
| "ucp-agent" | |
| ], | |
| "additionalProperties": true, | |
| "properties": { | |
| "ucp-agent": { | |
| "type": "object", | |
| "description": "Platform agent identification. Maps to HTTP UCP-Agent header.", | |
| "required": [ | |
| "profile" | |
| ], | |
| "properties": { | |
| "profile": { | |
| "type": "string", | |
| "format": "uri", | |
| "description": "URL to the platform's UCP profile document." | |
| } | |
| } | |
| }, | |
| "idempotency-key": { | |
| "type": "string", | |
| "format": "uuid", | |
| "description": "Unique key for retry safety. Maps to HTTP Idempotency-Key header." | |
| } | |
| } | |
| } |
This is a concrete instance of the duplication point I raised on index.md — the envelope (meta, header set, idempotency prose) wants to live once in services/common/ and be $ref'd by each vertical, or the next vertical forks it again.
| "role": { | ||
| "type": "string", | ||
| "description": "Role of the guest in this room. Well-known values: `primary_guest` (the guest the reservation is attached to), `additional_guest`. Businesses MAY define additional values; platforms MUST tolerate unknown values." | ||
| } |
There was a problem hiding this comment.
The open-vocabulary sentence here is copied from response-side fields and points the wrong way for a request field. guest_assignments[] is Platform-written (create/update), so the Platform never meets a role it doesn't know — "platforms MUST tolerate unknown values" has no one to bind. And "Businesses MAY define additional values" has no wire: there's no discovery field, capability config, or messages[] pattern through which a Business could tell a Platform which roles exist (where the corpus wants Business-defined choices a Platform picks from, it publishes them with ids — fulfillment options[], payment terms[]). What's missing is the receiver's clause: what a Business does with role: "vip_host".
Since primary_guest is the only role with protocol semantics — the guest the folio and the lead-identity floor attach to — the safe degradation is the payment_schedule.type shape ("immediate is the only value with defined meaning; any other value means…"):
| "role": { | |
| "type": "string", | |
| "description": "Role of the guest in this room. Well-known values: `primary_guest` (the guest the reservation is attached to), `additional_guest`. Businesses MAY define additional values; platforms MUST tolerate unknown values." | |
| } | |
| "role": { | |
| "type": "string", | |
| "description": "Role of the guest in this room. `primary_guest` is the only value with defined meaning: the guest the reservation and folio are attached to. `additional_guest` is the well-known value for every other occupant. Platforms MAY send additional values; a Business MUST treat any value it does not recognize as `additional_guest`." | |
| } |
| }, | ||
| "travel_purpose": { | ||
| "type": "string", | ||
| "description": "Purpose of the trip or reservation. Well-known values: `business`, `leisure`. Businesses MAY implement and support additional values.", |
There was a problem hiding this comment.
Same direction issue as guest_assignments[].role: this is a Platform-written hint, so the clause that matters is what the Business does with a value it doesn't support (e.g. if business gates a corporate rate, what does bleisure do?). "Businesses MAY implement and support additional values" is fine as far as it goes; it just never says the receiver's behavior.
| "description": "Purpose of the trip or reservation. Well-known values: `business`, `leisure`. Businesses MAY implement and support additional values.", | |
| "description": "Purpose of the trip or reservation, supplied by the Platform as a hint. Well-known values: `business`, `leisure`. Platforms MAY send additional values; a Business MUST ignore values it does not recognize and treat the purpose as unspecified.", |
| "image_urls": { | ||
| "type": "array", | ||
| "items": { | ||
| "type": "string", | ||
| "format": "uri" | ||
| }, | ||
| "description": "List of image URIs for the room type.", | ||
| "ucp_request": "omit" | ||
| } |
There was a problem hiding this comment.
Bare URIs drop the printable string. The corpus already has a media type — common/types/media.json (type, url, alt_text, width, height), used by product.media[] and variant.media[] with "first item is the featured media" — and for a non-text asset alt_text is what a text-only agent or a screen reader presents. image_urls: string[] gives them nothing.
It also under-fits the vertical. #690 (open) extends Media into image / video / model_3d variants with sources[], a preview still, and name distinct from alt_text. Hotels are the media-heavy case that shape was built for — galleries, video walkthroughs, and 360°/3D room tours are standard on every booking surface — and none of it is expressible as a list of image URIs. Adopting media[] here means lodging inherits that for free when it lands; shipping image_urls means a breaking rename later.
| "image_urls": { | |
| "type": "array", | |
| "items": { | |
| "type": "string", | |
| "format": "uri" | |
| }, | |
| "description": "List of image URIs for the room type.", | |
| "ucp_request": "omit" | |
| } | |
| "media": { | |
| "type": "array", | |
| "items": { | |
| "$ref": "../../common/types/media.json" | |
| }, | |
| "description": "Room type media (images, videos, 3D tours). First item is the featured media for presentation.", | |
| "ucp_request": "omit" | |
| } |
(Same change on accommodation.json. No example payload uses image_urls today, so this is a schema-only rename.)
| "image_urls": { | ||
| "type": "array", | ||
| "items": { | ||
| "type": "string", | ||
| "format": "uri" | ||
| }, | ||
| "description": "List of accommodation image URIs.", | ||
| "ucp_request": "omit" | ||
| } |
There was a problem hiding this comment.
Same as room_type.image_urls — use the shared media type so the asset carries its alt_text (and, via #690, video and 3D tours). Property-level media is where a virtual tour most often lives.
| "image_urls": { | |
| "type": "array", | |
| "items": { | |
| "type": "string", | |
| "format": "uri" | |
| }, | |
| "description": "List of accommodation image URIs.", | |
| "ucp_request": "omit" | |
| } | |
| "media": { | |
| "type": "array", | |
| "items": { | |
| "$ref": "../../common/types/media.json" | |
| }, | |
| "description": "Accommodation media (images, videos, 3D tours). First item is the featured media for presentation.", | |
| "ucp_request": "omit" | |
| } |
| collected if required, lead guest identification via `booker` or | ||
| `primary_guest`, and all outstanding gating actions resolved) and platform | ||
| can finalize programmatically. Platform can call Complete Booking Session. | ||
| * **`complete_in_progress`**: Business is processing the Complete Booking |
There was a problem hiding this comment.
Same flag.
complete_in_progress is in the enum and the lifecycle diagram, but the contract that gives it meaning isn't here. Checkout's Accepted completion defines what each operation does while Complete is in flight — Get MAY poll with bounded backoff and MUST stop at expires_at; Update MUST NOT start and the Business returns the unchanged session with a recoverable error; Complete MUST NOT start anew, with a narrow lost-response retry using the same idempotency key and a fresh key for any genuinely new Complete; Cancel only after fallback and continue_url handoff are exhausted, and the session is not canceled until the Business says status: canceled. None of that is in this doc, and hotel completions — PMS and channel-manager round trips — are exactly the slow, asynchronous case it exists for. Without it, a Platform that times out on Complete has no spec-defined safe recovery for a booking that may or may not have been confirmed.
Given the duplication point above, I'd rather this doc reference checkout's section with a one-paragraph lodging note than port fifty lines — but one or the other has to happen before complete_in_progress means anything here.
| "ucp_request": "omit" | ||
| }, | ||
| "actions": { | ||
| "$ref": "../common/types/actions.json", |
There was a problem hiding this comment.
Hi @jingyli - The actions schema allows extension-defined Action types using reverse-domain keys. Should UCP define a set of standardized/well-known Action types for common flows (e.g., 3DS authentication) so that businesses and platforms use consistent semantics, while still allowing custom extension-defined Actions?
| "address": { | ||
| "$ref": "../../common/types/postal_address.json", | ||
| "description": "Physical or registered address of the booker or company for legal contracting, regulatory compliance, and invoicing." | ||
| } |
There was a problem hiding this comment.
Hi @jingyli - For a travel agency making a booking through the UCP platform, how should the platform pass agency-specific information, such as agency name, IATA number, agency/agent ID, and contact details, to the business? The current booker node does not provide dedicated fields for capturing the travel agency or agent facilitating the booking.
| "children": { | ||
| "type": "array", | ||
| "items": { | ||
| "type": "object", | ||
| "properties": { | ||
| "from_age": { | ||
| "type": "integer", | ||
| "minimum": 0, | ||
| "description": "Lower bound of the age bracket." | ||
| }, | ||
| "to_age": { | ||
| "type": "integer", | ||
| "minimum": 0, | ||
| "description": "Upper bound of the age bracket." | ||
| }, | ||
| "total": { | ||
| "type": "integer", | ||
| "minimum": 0, | ||
| "description": "Capacity limit." | ||
| } | ||
| } | ||
| }, | ||
| "description": "Child capacity limits broken down by age brackets." | ||
| }, | ||
| "total": { | ||
| "type": "integer", | ||
| "description": "Total number of occupants. MUST be equal to adults + sum(children.total).", | ||
| "minimum": 0 |
There was a problem hiding this comment.
@jingyli this was my intention by the suggestion of an inequality in this comment.
The callout on maintaining naming consistency with occupancy is also a good callout. The proposed structure here I think makes sense and simplifies enforcement by Platforms and Businesses.
| }, | ||
| "role": { | ||
| "type": "string", | ||
| "description": "Role of the guest in this room. Well-known values: `primary_guest` (the guest the reservation is attached to), `additional_guest`. Businesses MAY define additional values; platforms MUST tolerate unknown values." |
There was a problem hiding this comment.
Conversely, role: "additional" does not have the same level of intuitive implication. Perhaps we should suggest secondary instead of additional?
Description
On behalf of the entire Lodging Domain Tech Council, this PR introduces the Lodging Booking Capability (
dev.ucp.lodging.booking) and the Cancellation Policy Extension (dev.ucp.lodging.policy.cancellation), establishing the foundational data contracts, state lifecycle, and transport bindings for lodging reservations in UCP.By standardizing compound room-rate bindings, a decoupled guest pool assignment model, and machine-readable cancellation classifications across both REST and MCP transports, this capability enables platforms to facilitate hotel booking sessions seamlessly while leaving inventory authority, 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, lodging commerce introduces distinct transactional dynamics:room_rate): A bookable lodging unit is not a simple SKU; it is a compound binding of a physical room real-estate unit (room_details), a commercial rate plan contract (rate_plan), a date interval (itinerary), and an occupancy configuration (occupancy).booker) frequently differs from the individuals physically occupying the rooms (guests), especially in corporate travel, proxy bookings, family reservations, and multi-room bookings.Proposal Scope
This PR focuses on the core pre-purchase through booking completion lifecycle for lodging reservations.
In Scope
dev.ucp.lodging.booking): Full session lifecycle (incomplete,requires_escalation,ready_for_complete,complete_in_progress,completed,canceled) with deterministic handoffs viacontinue_url.date_intervalprimitive incommon/types/.accommodation,room_rate,room_details,rate_plan,occupancy,capacity,booker,guest,guest_assignment, andbooking_confirmation.dev.ucp.lodging.policy.cancellation): Pre-purchase cancellation terms decorating the corepolicies[]primitive with tri-state refundability (refundable,partially_refundable,non_refundable).payment_terms,payment_split_payments,payment_authentication,payment_ap2_mandate) ontodev.ucp.lodging.booking.main.pyschema macro renderer to cleanly deduplicate overridden fields inallOfcompositions, plus test scaffolds for lodging contracts.Out of Scope
Design Details
1. Compound Room Rate & Upstream Discovery
A reservation contains one or more
room_rates[]items. Each item binds:room_details.id: The physical room category (e.g.rt_luxury_queen).rate_plan.id: The commercial rate terms and inclusions (e.g.rp_avg_base_rate).occupancy: Adult and child count for the room.guest_assignments: Specific guest mappings for the room.{ "accommodation": { "id": "hotel_123" }, "room_rates": [ { "id": "rt_luxury_queen__rp_avg_base_rate", "room_details": { "id": "rt_luxury_queen" }, "rate_plan": { "id": "rp_avg_base_rate" }, "occupancy": { "adults": 2, "total": 2 } } ], "itinerary": { "start_date": "2026-07-15", "end_date": "2026-07-21" } }When creating a session, the Platform provides the minimal discovery keys. The Business authoritatively validates availability and expands the response with titles, descriptions, capacity limits, calculated price breakdowns (
totals[]), and policy terms (policies[]).2. Platform-Generated Guest IDs & Two-Tier Guest Pool
To eliminate ID remapping across distributed systems and avoid transmitting unnecessary PII before booking, guest data is structured into a two-level relational model:
guests[]): A flat array of individual guest profiles. Eachguest.id(e.g.,"gst_01") is generated and supplied by the Platform in the platform namespace.room_rates[].guest_assignments[]): Granular mappings referencingguest.idand designating roles (primary_guest,additional_guest).bookervs.guests: Decouples the legal purchaser from the room occupants.{ "guests": [ { "id": "gst_01", "first_name": "Jane", "last_name": "Doe", "email": "jane.doe@example.com", "phone_number": "+14155551234" }, { "id": "gst_02", "first_name": "Mary", "last_name": "Doe" } ], "room_rates": [ { "id": "rt_luxury_queen__rp_avg_base_rate", "guest_assignments": [ { "guest_id": "gst_01", "role": "primary_guest" }, { "guest_id": "gst_02", "role": "additional_guest" } ] } ] }3. Cancellation Policy Extension (
dev.ucp.lodging.policy.cancellation)Extends the core
policies[]primitive with machine-readable terms:refundability: Tri-state classification (refundable,partially_refundable,non_refundable).description: Explicit human-readable penalty schedules, cutoff times, and financial effects.url: Direct link to the property's legal policy document.{ "policies": [ { "type": "dev.ucp.lodging.policy.cancellation", "refundability": "partially_refundable", "description": { "text": "Cancel before July 10 for full refund. Cancellations between July 10 and July 14 incur a 1-night penalty fee." }, "url": "https://business.example.com/cancellation" } ] }4. Documentation Engine Enhancement (
main.py)When generating schema reference tables (
auto_generate_schema_reference,extension_schema_fields),allOfcompositions that specialized a base field (such astypeinpolicy_cancellation.json) previously generated duplicate rows for the same field._render_embedded_tableand_render_table_from_schemainmain.pywere updated to deduplicate rows by field name and prefer the outermost/specialized definition, ensuring generated markdown tables display a single, authoritative row per field.Category (Required)
Please select one or more categories that apply to this change.
ucp-schematool (resolver, linter, validator). (Requires Maintainer approval)Related Issues
#543
Checklist
!for breaking changes).Screenshots / Logs (if applicable)