feat: Introduce Lodging Booking Capability and Cancellation Policy Extension - #780
feat: Introduce Lodging Booking Capability and Cancellation Policy Extension#780jingyli wants to merge 7 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. |
| "items": { | ||
| "$ref": "types/guest.json" | ||
| }, | ||
| "description": "List of guest profiles associated with the booking.", |
There was a problem hiding this comment.
booking.json's required list is ["ucp", "id", "status", "accommodation", "room_rates", "itinerary", "currency", "totals", "links"]. guests is optional,
booker is optional, and guest_assignments is optional within each room rate. So a
booking can legitimately reach completed carrying no person's name at all.
No property can act on that. A reservation without a lead guest name cannot be created in
any PMS, nobody can be checked in, and there is no one to contact if the room is walked.
The Business Guidelines get close but stop short. They require validating that every
guest_assignments[].guest_id resolves to a guests[] entry, and enforcing capacity
against assigned occupants, but both are vacuously satisfied when the arrays are empty.
Should the spec require a lead guest before completion?
There is a real question underneath about how much identity is genuinely needed. Some
businesses want a full name plus contact for every occupant; others need only a lead name
per room and count the rest. The minimum above is the smallest thing that makes a booking
actionable, and businesses that need more can still ask through messages[]. But some
floor seems necessary, otherwise "completed" does not mean the property can honor it.
What do you think?
There was a problem hiding this comment.
I added a prose guideline + updated relevant examples to express the fact that some kind of entity (either booker or a primary_guest allocated via guests[]) should be present before the booking session can be completed.
DTC is also discussing & considering whether there are fields related to a guest identity that is globally required within the entire lodging ecosystem, but so far we are leaning towards the more dynamic model to negotiate required fields at a per-business level via messages[].
…d due to design iterations.
|
Thank you @yairsabag for the thorough review and for sharing your work and thoughts in this area! The scope of how much structure we want to model as part of this initial policy design was heavily debated within the DTC. Ultimately, we aligned on proceeding with your Option 1: stabilizing the refundability classification enum alongside human-readable description to lock down the core wire format. However, we completely acknowledge and agree that moving from prose penalty descriptions to deterministic, anchor-relative penalty tiers and cutoffs is the right direction for UCP lodging cancellation policy evolution. We welcome you to engage in any follow-up PRs to incorporate the structured schedule and evaluation vectors you have developed! This is also a more forward looking goal within the Lodging DTC. |
|
Thank you @jingyli this is exactly the kind of alignment I was hoping for. I agree with keeping the initial wire format focused on I’ll prepare a small, additive follow-up that preserves that contract while introducing an optional anchor-relative schedule, with lodging-specific examples and the existing evaluator/test vectors adapted to I’ll keep the scope narrow and bring the draft back here for Lodging DTC review. |
|
Thank you, Jing, for the clear direction. I've opened #807 to track the focused follow-up. I kept it additive and response-only: an optional anchor-relative schedule alongside refundability and description, with explicit boundary semantics and evaluation vectors. I'm leaving #780 untouched and will prepare the implementation as a separate stacked Draft until the base lands. |
Implements Universal-Commerce-Protocol#807 as a stacked draft on Universal-Commerce-Protocol#780.
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)