Skip to content

feat: Introduce Lodging Booking Capability and Cancellation Policy Extension - #780

Open
jingyli wants to merge 7 commits into
mainfrom
lodging/booking
Open

feat: Introduce Lodging Booking Capability and Cancellation Policy Extension#780
jingyli wants to merge 7 commits into
mainfrom
lodging/booking

Conversation

@jingyli

@jingyli jingyli commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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:

  1. Compound Offer Binding (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).
  2. Buyer vs. Occupant Separation: The legal contracting and paying party (booker) frequently differs from the individuals physically occupying the rooms (guests), especially in corporate travel, proxy bookings, family reservations, and multi-room bookings.
  3. Upper-Funnel to Transaction Continuity: Room rates and property availability discovered upstream during search must be ingested cleanly into booking sessions, where real-time inventory and pricing are validated and expanded authoritatively.
  4. Pre-Purchase Cancellation Transparency: Cancellation terms vary widely across rate plans. Providing standardized machine-readable refundability classifications alongside human-readable deadline schedules allows platforms to answer buyer questions ("Is this refundable?", "What is the penalty cutoff?") deterministically without scraping or parsing external web pages.

Proposal Scope

This PR focuses on the core pre-purchase through booking completion lifecycle for lodging reservations.

In Scope

  • Core Booking Capability (dev.ucp.lodging.booking): Full session lifecycle (incomplete, requires_escalation, ready_for_complete, complete_in_progress, completed, canceled) with deterministic handoffs via continue_url.
  • Data Models & Types:
    • Shared date_interval primitive in common/types/.
    • Lodging entities: accommodation, room_rate, room_details, rate_plan, occupancy, capacity, booker, guest, guest_assignment, and booking_confirmation.
  • Cancellation Policy Extension (dev.ucp.lodging.policy.cancellation): Pre-purchase cancellation terms decorating the core policies[] primitive with tri-state refundability (refundable, partially_refundable, non_refundable).
  • Transport Bindings: Complete REST (OpenAPI 3.1) and MCP (OpenRPC / tool definitions) specifications and examples.
  • Payment Extension Interoperability: Wiring of common payment capabilities (payment_terms, payment_split_payments, payment_authentication, payment_ap2_mandate) onto dev.ucp.lodging.booking.
  • Documentation & Tooling Enhancements: Enhanced main.py schema macro renderer to cleanly deduplicate overridden fields in allOf compositions, plus test scaffolds for lodging contracts.

Out of Scope

  • Upstream lodging search/discovery service.
  • Post-purchase reservation management (modifications, cancellations, and check-in workflows), which will be addressed in post-purchase order extensions.

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:

  • Root Guest Pool (guests[]): A flat array of individual guest profiles. Each guest.id (e.g., "gst_01") is generated and supplied by the Platform in the platform namespace.
  • Room Assignments (room_rates[].guest_assignments[]): Granular mappings referencing guest.id and designating roles (primary_guest, additional_guest).
  • booker vs. 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), allOf compositions that specialized a base field (such as type in policy_cancellation.json) previously generated duplicate rows for the same field.

_render_embedded_table and _render_table_from_schema in main.py were 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.

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

Related Issues

#543

Checklist

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

Screenshots / Logs (if applicable)

lodging-booking

@jingyli jingyli changed the title feat!: Introduce Lodging Booking Capability and Cancellation Policy Extension feat: Introduce Lodging Booking Capability and Cancellation Policy Extension Aug 28, 2026
@damaz91 damaz91 added the status:needs-triage Signal that the PR is ready for human triage label Aug 28, 2026
Comment thread source/schemas/lodging/policy_cancellation.json Outdated
Comment thread main.py
Comment thread source/schemas/lodging/booking.json
Comment thread docs/specification/lodging/booking/index.md Outdated
Comment thread docs/specification/lodging/booking/index.md Outdated
Comment thread source/schemas/lodging/policy_cancellation.json Outdated
Comment thread source/schemas/lodging/policy_cancellation.json
Comment thread source/schemas/common/types/date_interval.json Outdated
Comment thread source/schemas/common/types/date_interval.json
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[]`).

@amithanda amithanda Aug 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Suggested change
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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!

Comment thread source/schemas/lodging/types/guest_assignment.json
Comment thread docs/specification/lodging/booking/rest.md Outdated

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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread docs/specification/lodging/booking/index.md
Comment thread docs/specification/lodging/booking/mcp.md Outdated
Comment thread docs/specification/lodging/booking/mcp.md Outdated
Comment thread docs/specification/lodging/booking/mcp.md Outdated
Comment thread docs/specification/lodging/booking/mcp.md Outdated
Comment thread docs/specification/lodging/booking/mcp.md Outdated
Comment thread mkdocs.yml Outdated
Comment thread source/schemas/lodging/policy_cancellation.json
Comment thread source/schemas/lodging/types/booker.json Outdated
Comment thread docs/specification/lodging/booking/index.md Outdated
Comment thread docs/specification/lodging/booking/index.md Outdated
"properties": {
"id": {
"type": "string",
"description": "Stable, opaque unique identifier of the room rate binding.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Suggested change
"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?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@yairsabag

Copy link
Copy Markdown

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 refundability classification. The cutoff, timezone, sequence of penalty windows, financial outcome, and no-show consequence remain exclusively in description.

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. refundability is also time-dependent, but the structured object does not state the anchor, evaluation instant, or transition schedule.

I raised this tiered, anchor-relative policy class while policies[] was being designed in #572, where it was confirmed as an appropriate policy extension. I subsequently validated the shape against approximately 40 real cancellation policies across lodging, ticketing, and services, and published an executable schema, evaluator, and test vectors:

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:

  1. Keep refundability as a classification-only v1 and explicitly track structured deadlines and penalties as a follow-up; or
  2. Add an optional structured schedule alongside refundability, while retaining description as the universal human-readable fallback.

I would be happy to contribute a focused patch or adapt the existing test vectors to the Lodging TC's preferred shape.

@carolinerg1 carolinerg1 added status:under-review and removed status:needs-triage Signal that the PR is ready for human triage labels Aug 31, 2026
Comment thread source/schemas/lodging/booking.json Outdated
Comment thread source/schemas/lodging/booking.json Outdated
"items": {
"$ref": "types/guest.json"
},
"description": "List of guest profiles associated with the booking.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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[].

@jingyli
jingyli requested a review from amithanda September 5, 2026 01:03
@jingyli

jingyli commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

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.

@yairsabag

yairsabag commented Sep 5, 2026

Copy link
Copy Markdown

Thank you @jingyli this is exactly the kind of alignment I was hoping for. I agree with keeping the initial wire format focused on refundability plus the human-readable fallback.

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 dev.ucp.lodging.policy.cancellation.

I’ll keep the scope narrow and bring the draft back here for Lodging DTC review.

@yairsabag

Copy link
Copy Markdown

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.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants