diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 734aefefd..d9af152b9 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -78,6 +78,12 @@ jobs: - name: Run validator unit tests run: uv run python scripts/test_validate_examples.py + - name: Validate cancellation schedule vectors + run: uv run python scripts/test_cancellation_schedule.py + + - name: Validate cancellation lab fixtures + run: uv run python scripts/test_cancellation_lab.py + build_and_verify_main: needs: lint runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 70049f80e..3880b6191 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,6 +44,33 @@ repos: pass_filenames: false files: ^scripts/(validate_examples|test_validate_examples)\.py$ stages: [pre-commit, pre-push] + - id: validate-cancellation-schedule + name: Validate cancellation schedule vectors + entry: uv run --frozen python scripts/test_cancellation_schedule.py + language: system + pass_filenames: false + files: | + (?x)^( + source/schemas/ + |scripts/test_cancellation_schedule\.py$ + |scripts/fixtures/lodging_cancellation_schedule\.json$ + |docs/specification/lodging/extensions/cancellation-policy\.md$ + ) + stages: [pre-commit, pre-push] + - id: validate-cancellation-lab + name: Validate cancellation lab fixtures + entry: uv run --frozen python scripts/test_cancellation_lab.py + language: system + pass_filenames: false + files: | + (?x)^( + source/schemas/ + |scripts/test_cancellation_(schedule|lab)\.py$ + |scripts/fixtures/lodging_cancellation_(schedule|lab)\.json$ + |scripts/fixtures/lodging_cancellation_lab\.md$ + |docs/specification/lodging/extensions/cancellation-policy\.md$ + ) + stages: [pre-commit, pre-push] - repo: https://github.com/streetsidesoftware/cspell-cli rev: v9.3.3 hooks: diff --git a/docs/specification/lodging/extensions/cancellation-policy.md b/docs/specification/lodging/extensions/cancellation-policy.md index d819194d3..75df95010 100644 --- a/docs/specification/lodging/extensions/cancellation-policy.md +++ b/docs/specification/lodging/extensions/cancellation-policy.md @@ -20,31 +20,28 @@ The Cancellation Policy Extension defines the `dev.ucp.lodging.policy.cancellation` policy type on the core -[`policies[]`](../../overview/index.md#policies) primitive for the lodging service. It -adds pre-purchase, machine-readable cancellation terms to policies that -carry this type, so platforms can answer questions like "Can I cancel this booking?", -"Is it free cancellation or is there a fee?", and "Is this rate completely -non-refundable?" without leaving to parse external policy pages. +[`policies[]`](../../overview/index.md#policies) primitive for the lodging +service. It adds pre-purchase machine-readable cancellation terms so Platforms +can answer whether cancellation is free, which cutoff applies, and what outcome +follows without parsing legal prose. **Key features:** -- Tri-state refundability classification (`refundability`) to signal whether - cancellation is currently free (`refundable`), incurs a penalty - (`partially_refundable`), or is disallowed (`non_refundable`) -- Human-readable summary in `description` carrying detailed property cutoff - times, timezone deadlines, and penalty schedules -- Optional direct link to the property's complete legal terms (`url`) +- Tri-state `refundability` classification for the current high-level state +- Optional deterministic `schedule` with an exact anchor and relative cutoffs +- Structured percentage, fixed-fee, and lodging-unit outcomes +- Required human-readable `description` and optional legal-terms `url` **Dependencies:** -- The core `policies[]` primitive (see [Policies](../../overview/index.md#policies)). -- Any parent capabilities this type extends: Booking - (`dev.ucp.lodging.booking`). +- The core `policies[]` primitive (see + [Policies](../../overview/index.md#policies)). +- Booking (`dev.ucp.lodging.booking`), which this policy type extends. ## Discovery Businesses advertise cancellation policy support in their profile. The type -extends any surface that carries `policies[]`: +extends any Booking surface that carries `policies[]`: ```json @@ -70,92 +67,360 @@ extends any surface that carries `policies[]`: ## Schema When this type is active, a `policies[]` entry whose `type` is -`dev.ucp.lodging.policy.cancellation` carries additional attributes -(e.g., `refundability`) in addition to the base `type`, -`description`, `applies_to`, and `url`. +`dev.ucp.lodging.policy.cancellation` carries `refundability` and may carry a +`schedule`, in addition to the base `type`, `description`, `applies_to`, and +`url` fields. {{ extension_schema_fields('policy_cancellation.json#/$defs/cancellation_item', 'lodging/extensions/cancellation-policy') }} -## Cancellation terms +### Cancellation Schedule + +{{ extension_schema_fields('policy_cancellation.json#/$defs/cancellation_schedule', 'lodging/extensions/cancellation-policy') }} + +### Cancellation Tier -### Refundability classifications +{{ extension_schema_fields('policy_cancellation.json#/$defs/cancellation_tier', 'lodging/extensions/cancellation-policy') }} -The `refundability` field provides a standardized high-level classification: +### Cancellation Outcome -- **`refundable`**: Free cancellation is currently available. The booker can - cancel without penalty before the deadline stated in `description`. -- **`partially_refundable`**: The booking can be cancelled, but a cancellation - fee applies (e.g., a one-night room charge or fixed administrative fee), or - the booking is currently inside a partial penalty window. Specific cancellation - fees, penalty schedules, and refund effects are outlined in the policy's - `description` field. -- **`non_refundable`**: The reservation cannot be refunded upon cancellation - (the full booking price is retained by the business, subject to local regulation). +{{ extension_schema_fields('policy_cancellation.json#/$defs/cancellation_outcome', 'lodging/extensions/cancellation-policy') }} + +## Cancellation terms + +### Refundability classification + +`refundability` provides a standardized current high-level classification: + +- **`refundable`**: cancellation without penalty is currently available. +- **`partially_refundable`**: the current cancellation result is strictly + between a full refund and no refund; a penalty applies, but some refundable + value remains. +- **`non_refundable`**: no refund is currently available upon cancellation, + subject to applicable law. + +Platforms **MUST** tolerate unknown classification values and use `description` +when they cannot interpret the value. + +`schedule` does not replace this classification. When both are present, the +Business **MUST** ensure that `refundability` accurately summarizes the outcome +selected at the response-generation instant for the policy's governed scope. A +full refund is `refundable`, no refund is `non_refundable`, and a result strictly +between those endpoints is `partially_refundable`. The Business determines this +classification from its authoritative terms and pricing. A Platform **MUST** +treat `refundability` as authoritative for that snapshot and **MUST NOT** +replace it with a classification derived from an outcome kind or an inferred +monetary basis. + +`refundability` is a point-in-time summary. For evaluation at an explicit `at` +instant, `schedule` governs: a Platform **MUST** select the applicable schedule +outcome and **MUST NOT** extrapolate `refundability` to that instant. Crossing a +cutoff later does not make the earlier response contradictory. + +### Deterministic schedule + +`schedule` is optional. Its absence means that structured timeline evaluation +is unavailable; it does not mean that the booking is non-refundable. When +present, `anchor`, a non-empty `tiers` array, and `after_last_tier` form one +atomic value. + +#### Representation + +`anchor` is an RFC 3339 instant and **MUST** include `Z` or a numeric UTC +offset. The timestamp identifies an instant; a numeric offset does not declare a +recurring property timezone. For lodging, the anchor normally represents the stated +arrival or check-in cutoff. + +Schedule arithmetic uses Unix time (POSIX): each date contributes exactly +86400 seconds, excluding leap seconds. The supported timestamp profile for +`anchor` and `at` therefore requires seconds from `00` through `59`; +leap-second labels with `:60` are unsupported. Offsets, including `-00:00`, +identify the UTC instant according to RFC 3339; no property timezone is inferred. + +Each tier's `until` is an ISO 8601 elapsed duration before `anchor`. This version +supports nonnegative whole-number days, hours, minutes, and seconds only. A day +is exactly 24 elapsed hours. Calendar months, calendar years, local-calendar +days, and business days are not supported. + +A Business **MUST** order `tiers` from the farthest cutoff before `anchor` to +the nearest cutoff. Businesses and Platforms compare durations after exact +normalization to elapsed seconds. The durations **MUST** be strictly decreasing +and **MUST NOT** repeat; for example, `P1D` and `PT24H` denote the same cutoff. +An invalid order makes structured evaluation unavailable. + +The wire schedule represents resolved instants for a particular reservation, +not a recurring property-local policy template. For a rule such as "6:00 PM two +days before check-in," the Business first resolves the local check-in and +cutoff using the property's IANA timezone, then expresses their difference as +an elapsed duration. That difference can change across daylight-saving time +transitions. `P2D` always means 48 elapsed hours, not two local-calendar days. +This producer-side explanation does not add a timezone or policy-template +field, or define a general template compiler. + +#### Evaluation + +For a tier, its cutoff is the `anchor` instant minus its `until` elapsed +duration. `at` is the exact hypothetical or current buyer-cancellation instant +the Platform is evaluating. A Platform evaluates tiers in array order and +selects the first tier for which `at` is strictly earlier than the cutoff. If no +tier matches, `after_last_tier` applies. + +The cutoff belongs to the following interval. At exactly `anchor - until`, that +tier no longer applies. Neither party rounds, shifts, or reinterprets `at`, the +anchor, or a computed cutoff. Implementations use exact checked arithmetic. If +a duration or resulting cutoff cannot be represented exactly, structured +evaluation is unavailable. + +Before selecting any tier, a Platform **MUST** validate the entire schedule, +including ordering, and the calendar validity of `anchor` and `at`. Passing +JSON Schema validation alone is insufficient when `format` is annotation-only: +for example, February 30 is not a valid timestamp. An invalid or unsupported +timestamp makes structured evaluation unavailable; it **MUST NOT** be repaired, +rounded, or silently normalized to a different instant. + +For example, with an anchor of `2026-12-22T15:00:00-05:00` and `until` of +`PT48H`, the cutoff is `2026-12-20T20:00:00Z`. An evaluation at +`2026-12-20T19:59:59Z` selects the tier; an evaluation at +`2026-12-20T20:00:00Z` advances to the following interval. + +#### Outcome vocabulary + +`kind` is an open string discriminator. This specification defines these +well-known values: + +- **`percentage`** requires `buyer_bps`, an integer from 0 through 10000. It is + the Business-stated refund percentage under the policy. `0` means no refund + and `10000` means a full refund. This version does not identify a monetary + basis for the percentage. +- **`fixed_fee`** requires `penalty.amount`, expressed as an integer in the + minor units of the root `currency` field of the Booking response. Currency is + not repeated in the outcome. +- **`unit_deduction`** requires `penalty.measure` using the shared UCP measure + representation, with a positive `value`, stable `unit`, and required + `display_text`. `night` is the well-known lodging unit and has an effective + `scale` of `0`. + +Percentage and unit-deduction outcomes are deterministic symbolic terms, but +they do not necessarily imply a cash amount. A Platform **MUST NOT** calculate +money unless the targeted Booking data supplies an unambiguous basis. + +A `night` measure specifies a count, not a machine-readable pricing basis. Its +`display_text` does not identify which night's rate applies or encode whether +taxes and fees are excluded. If the Business has resolved a reservation's +penalty to USD 150.00, a `fixed_fee` with `penalty.amount: 15000` and root +Booking `currency: "USD"` can carry that concrete amount. This does not encode +a portable "one night's room rate excluding taxes and fees" formula; the +complete terms still belong in `description`. + +A Business-resolved `fixed_fee` amount reflects the reservation terms used in +its calculation; it is a snapshot, not a reusable formula. Changes to dates, +room rates, or other governing terms may require an updated Business-provided +amount, even within the same booking session. + +For a well-known `kind`, a Business **MUST** emit only the fields defined for +that kind. A Platform evaluates only those fields and ignores unrelated outcome +members. Additional `kind` values **SHOULD** use reverse-domain identifiers. +Unit identifiers and display behavior follow the shared UCP unit rules. + +A Platform that encounters an unsupported `kind` **MUST** tolerate the value, +**MUST NOT** infer its meaning or claim a deterministic outcome, and **MUST** +use `description` as the fallback. ### Non-refundable bookings -To signal that a booking or rate is non-refundable, a business **MUST** set -`refundability` to `"non_refundable"`. +To signal that a booking or rate is currently non-refundable, a Business +**MUST** set `refundability` to `"non_refundable"`. + +When a Business requires that the current non-refundable classification be +shown to the Booker before confirmation, it **MUST** emit a `messages[]` warning +with `presentation: "disclosure"` and a `code` of +`dev.ucp.lodging.policy.cancellation`. It sets `path` to the affected room-rate +node, or omits `path` for a response-wide policy. See [Presenting +policies](../../overview/index.md#presenting-policies). -When a business requires the booker to be shown that a booking is non-refundable -prior to confirmation, it emits a `messages[]` warning with -`presentation: "disclosure"` and `code` equal to -`dev.ucp.lodging.policy.cancellation`, targeting the item. The disclosure pairs -with the governing cancellation policy at that node, as defined in -[Presenting policies](../../overview/index.md#presenting-policies). +### Human-readable terms and fallback -### Human-readable descriptions +`description` remains the universal human-readable and legal fallback. Whether +or not `schedule` is present, a Business **MUST** articulate the full +cancellation timeline and terms in `description`. The point-in-time +`refundability` value **MUST NOT** contradict the outcome that `description` +states applies when the response is created. When present, `schedule` +**MUST NOT** contradict `description`. -Because lodging cancellation rules frequently incorporate specific property -local cutoff times (e.g., "by 3:00 PM property time 2 days before check-in") -and seasonal rules, businesses **MUST** articulate the full timeline and terms in -`description`. The `description` and `refundability` field **MUST NOT** contradict -each other. +Detecting contradictions does not require a Platform to parse legal prose. If a +contradiction is independently known, the schedule is invalid, ordered-tier +constraints are violated, or a selected outcome is unsupported, the Platform +**MUST NOT** present a guessed structured result and **SHOULD** present +`description` and `url`. ## Targeting and precedence -Targeting and precedence are provided by the `policies[]` primitive and are not -redefined here. In short: a policy with no `applies_to` is the response-wide -default; a policy that targets specific room overrides will result in the -narrowest same-type target winning. See +Targeting and precedence are supplied by `policies[]` and are not redefined +here. A policy without `applies_to` is the response-wide default. A policy that +targets a room rate overrides a less-specific policy of the same type. See [Targeting](../../overview/index.md#targeting) and [Precedence](../../overview/index.md#precedence). -For lodging bookings, a common example is when a business states a single default cancellation -policy once, then adds targeted overrides only for specific exceptions (such as -a non-refundable room rate or promotional upgrade). +An outcome states the Business's penalty terms for the governed scope. +Targeting determines which policy governs each node; it does not define +whether a charge repeats per room, per night, or per cancelled reservation. +A Platform **MUST NOT** derive an aggregate cancellation quote by multiplying +a fixed fee or unit deduction solely by the number of matched nodes. For +example, a USD `7500` fixed fee governing two room rates does not by itself +establish an aggregate charge of USD `15000`. Aggregation requires explicit +Business terms and sufficient Booking data; otherwise the Platform presents +the declared terms without an inferred aggregate. ## Responsibilities -Cancellation policies are business-stated facts. They are response-only data -that a platform never submits. They carry no user-asserted claims and no PII. +### Business + +Cancellation policies are Business-stated response-only facts. When a Business +emits `schedule`, it **MUST** emit a valid ordered schedule and keep it +consistent with `description`. Its outcome at response generation **MUST** +agree with the `refundability` snapshot. The Business preserves the complete +legal summary in `description` and **SHOULD** link the full terms with `url`. + +### Platform -A business **SHOULD** accurately summarize cancellation timelines, applicable -penalties and cutoff deadlines in `description` and link to full policy terms -via `url`. A platform **SHOULD** surface `url` alongside the policy description -so the booker can review full dynamic property policies. +A Platform supplies the evaluation instant, applies the exact elapsed-time and +boundary rules above, and respects policy targeting before evaluation. It +**MUST NOT** turn a symbolic percentage or unit deduction into money without an +unambiguous basis, infer a result from an unsupported outcome, or execute +cancellation or refund behavior solely from this pre-purchase policy. It +**SHOULD** refresh the authoritative Booking response before presenting a stale +`refundability` snapshot as current. ## Examples - +### Free cancellation followed by a one-night penalty + + ```json -[ - { - "type": "dev.ucp.lodging.policy.cancellation", - "description": { - "plain": "Free cancellation until Dec 20, 2026, 3:00 PM EDT (48 hours before check-in). 1 night penalty thereafter." - }, - "refundability": "refundable", - "url": "https://example.com/cancellation-terms" +{ + "type": "dev.ucp.lodging.policy.cancellation", + "description": { + "plain": "Free cancellation until Dec 20, 2026 at 3:00 PM UTC-05:00; one-night penalty thereafter." }, - { - "type": "dev.ucp.lodging.policy.cancellation", - "description": { - "plain": "Non-refundable promotional rate. This room reservation cannot be cancelled or modified for a refund." - }, - "applies_to": ["$.room_rates[0]"], - "refundability": "non_refundable", - "url": "https://example.com/cancellation-terms#non-refundable" - } -] + "refundability": "refundable", + "schedule": { + "anchor": "2026-12-22T15:00:00-05:00", + "tiers": [ + { + "until": "PT48H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "unit_deduction", + "penalty": { + "measure": { + "value": 1, + "unit": "night", + "display_text": "night" + } + } + } + }, + "url": "https://example.com/cancellation-terms" +} ``` + +### Property-local cutoff with a resolved penalty + +This non-normative example is adapted from the +[public synthetic policy contributed in #780](https://github.com/Universal-Commerce-Protocol/ucp/pull/780#issuecomment-5607887511). +For a two-night reservation, cancellation is free strictly before 6:00 PM +property-local time two calendar days before check-in. At or after the cutoff, +the penalty is one night's room rate, excluding taxes and fees. The Business +supplies a nightly room rate of USD 150.00 and resolves this reservation's +penalty to `fixed_fee`, `penalty.amount: 15000`, in root Booking currency USD. +The schedule does not infer this amount from a `night` measure. + +For `America/Phoenix`, check-in at October 16, 2026, 15:00 local is +`2026-10-16T22:00:00Z`. The cutoff at October 14, 2026, 18:00 local is +`2026-10-15T01:00:00Z`. Their difference is 162000 seconds, so the free tier +uses `until: "PT45H"` and a `percentage` outcome with `buyer_bps: 10000`. +The fixed fee is `after_last_tier`. At `2026-10-15T00:59:59Z`, cancellation is +free; at `2026-10-15T01:00:00Z`, the fixed-fee terms apply. + +The following derived `America/New_York` variants illustrate why the same +local-clock template does not imply a fixed elapsed offset: + +- Spring: check-in on March 9, 2026, at 15:00 UTC-04:00 is + `2026-03-09T19:00:00Z`; the cutoff on March 7 at 18:00 UTC-05:00 is + `2026-03-07T23:00:00Z`. The free tier uses `PT44H`. +- Autumn: check-in on November 2, 2026, at 15:00 UTC-05:00 is + `2026-11-02T20:00:00Z`; the cutoff on October 31 at 18:00 UTC-04:00 is + `2026-10-31T22:00:00Z`. The free tier uses `PT46H`. + +These examples use unambiguous local times. They do not define how a Business +resolves a template that lands in a nonexistent or repeated local-clock hour. + +### Classification-only policy + + +```json +{ + "type": "dev.ucp.lodging.policy.cancellation", + "description": { + "plain": "Non-refundable promotional rate." + }, + "applies_to": ["$.room_rates[0]"], + "refundability": "non_refundable", + "url": "https://example.com/cancellation-terms#non-refundable" +} +``` + +## Evaluation examples + +The following human-readable cases define selection behavior. `tier[n]` uses +zero-based array indexing. An expected result summarizes the selected wire +outcome; it is not a cancellation or refund instruction. Executable vectors +are provided in `scripts/fixtures/lodging_cancellation_schedule.json` and +checked by `scripts/test_cancellation_schedule.py`. With `ucp-schema` on +`PATH`, run: + +```sh +python3 scripts/test_cancellation_schedule.py +``` + +The portable fixture contains named schedules and evaluation cases with an +expected selection or fallback. Its result status, reason, and JSON Pointer +are test metadata, not protocol fields. The runner is a test-only oracle for +already-targeted policies; it does not calculate money, classify refunds, or +resolve policy targeting. The fixture also covers invalid dates and ordering, +exact fractional instants, mixed duration components, and POSIX arithmetic +across a leap-second boundary. + +The fixture additionally includes the synthetic Phoenix policy and derived +New York daylight-saving variants above, with complete expected wire outcomes +immediately before and exactly at each cutoff. A symbolic one-night variant +retains its measure instead of inferring a cash amount. Separate test-only +template checks use IANA timezone data to verify the local-to-instant +derivations before testing schedule selection. Running these checks requires +IANA timezone data; they are not a general policy-template compiler or tests +of nonexistent/repeated-hour resolution. + +A separate non-normative agent-disclosure study package is provided in +`scripts/fixtures/lodging_cancellation_lab.json`, with usage and interpretation +notes in `scripts/fixtures/lodging_cancellation_lab.md`. Its ten synthetic +scenarios separate agent-visible policy data from expected results and cover +paired prose-only/schedule inputs, missing monetary bases, malformed schedules, +and a prose-conflict probe. These are experiment inputs, not agent results or +an official conformance suite. Check their consistency with +`python3 scripts/test_cancellation_lab.py`. + +| ID | Schedule and evaluation instant | Expected result | +| --- | --- | --- | +| `before_cutoff` | Free-cancellation example; `at = 2026-12-20T19:59:59Z` | `tier[0]`; `percentage`, `buyer_bps = 10000` | +| `exact_cutoff` | Free-cancellation example; `at = 2026-12-20T20:00:00Z` | `after_last_tier`; `unit_deduction`, `penalty.measure.value = 1`, `penalty.measure.unit = night`, `penalty.measure.display_text = night` | +| `after_cutoff` | Free-cancellation example; `at = 2026-12-20T20:00:01Z` | `after_last_tier`; `unit_deduction`, `penalty.measure.value = 1`, `penalty.measure.unit = night`, `penalty.measure.display_text = night` | +| `middle_tier` | `anchor = 2026-12-31T12:00:00Z`; tiers `P7D -> buyer_bps 10000`, `PT48H -> buyer_bps 5000`; `at = 2026-12-24T12:00:00Z` | `tier[1]`; `percentage`, `buyer_bps = 5000` | +| `last_cutoff` | Same two-tier schedule; `at = 2026-12-29T12:00:00Z`; after-last `buyer_bps = 0` | `after_last_tier`; `percentage`, `buyer_bps = 0` | +| `fixed_fee` | `Booking.currency = USD`; `anchor = 2027-01-10T12:00:00Z`; tier `PT24H -> buyer_bps 10000`; `at = 2027-01-09T12:00:00Z`; after-last fixed fee amount `7500` | `after_last_tier`; `fixed_fee`, `penalty.amount = 7500` minor units in USD | +| `elapsed_day` | `anchor = 2026-03-09T02:30:00-04:00`; tier `P1D -> buyer_bps 10000`; `at = 2026-03-08T06:29:59Z` | Because `P1D` is 24 elapsed hours, the cutoff is `2026-03-08T06:30:00Z`; select `tier[0]` | +| `unsupported_kind` | Selected outcome has `kind = com.example.voucher`; otherwise valid schedule | Tolerate the value; structured outcome unavailable; fall back to `description` | +| `schedule_absent` | Policy has no `schedule` | Timeline evaluation unavailable; use `refundability` and `description` | diff --git a/scripts/fixtures/lodging_cancellation_lab.json b/scripts/fixtures/lodging_cancellation_lab.json new file mode 100644 index 000000000..706dd1ecc --- /dev/null +++ b/scripts/fixtures/lodging_cancellation_lab.json @@ -0,0 +1,1423 @@ +{ + "format_version": 1, + "description": "Ten synthetic pre-purchase disclosure scenarios. This envelope is test metadata, not a Booking wire schema. Only policy is a wire cancellation_item. Do not expose expected values, reference_schedule, category, defect, source notes, or other answer metadata to an agent.", + "proposal_snapshot": "185397d2195cedd4db6a678fc6c102f11be1bf0c", + "scope": "buyer_initiated_pre_purchase_disclosure", + "pairing": "Serve identical context and policy in both arms; remove only policy.schedule for the classification-plus-prose arm. Root Booking currency comes from context.currency. Context timestamps and timezone are test/reservation facts, not proposed new wire fields.", + "evaluation_scope": "Every query is hypothetical and earlier than check-in; no booking completion, cancellation or refund execution is authorized.", + "fixtures": [ + { + "id": "phoenix_resolved_fee", + "category": "valid", + "context": { + "response_generated_at": "2026-10-01T12:00:00Z", + "check_in": "2026-10-16T22:00:00Z", + "property_timezone": "America/Phoenix", + "stay_nights": 2, + "currency": "USD" + }, + "policy": { + "type": "dev.ucp.lodging.policy.cancellation", + "description": { + "plain": "Free cancellation strictly before 6:00 PM property-local time two calendar days before check-in. Cancellation at or after that time incurs one night's room rate, excluding taxes and fees. For this two-night reservation, the Business has resolved that penalty to USD 150.00 in total." + }, + "refundability": "refundable", + "url": "https://example.com/cancellation-terms#p01", + "schedule": { + "anchor": "2026-10-16T22:00:00Z", + "tiers": [ + { + "until": "PT45H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + } + } + }, + "schema_valid": true, + "source": "https://github.com/Universal-Commerce-Protocol/ucp/pull/780#issuecomment-5607887511", + "source_note": "Adapted from the public synthetic example; one-second-after query added.", + "local_policy_example": "phoenix_local_template", + "evaluations": [ + { + "id": "before_cutoff", + "at": "2026-10-15T00:59:59Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + }, + "selected_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + }, + "intended_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + }, + "penalty": { + "status": "resolved", + "amount": 0, + "currency": "USD" + }, + "handling": "terms" + } + }, + { + "id": "at_cutoff", + "at": "2026-10-15T01:00:00Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "selected_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "penalty": { + "status": "resolved", + "amount": 15000, + "currency": "USD" + }, + "handling": "terms" + } + }, + { + "id": "after_cutoff", + "at": "2026-10-15T01:00:01Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "selected_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "penalty": { + "status": "resolved", + "amount": 15000, + "currency": "USD" + }, + "handling": "terms" + } + } + ] + }, + { + "id": "new_york_spring_resolved_fee", + "category": "valid", + "context": { + "response_generated_at": "2026-03-01T12:00:00Z", + "check_in": "2026-03-09T19:00:00Z", + "property_timezone": "America/New_York", + "stay_nights": 2, + "currency": "USD" + }, + "policy": { + "type": "dev.ucp.lodging.policy.cancellation", + "description": { + "plain": "Free cancellation strictly before 6:00 PM property-local time two calendar days before check-in. Cancellation at or after that time incurs one night's room rate, excluding taxes and fees. For this two-night reservation, the Business has resolved that penalty to USD 150.00 in total." + }, + "refundability": "refundable", + "url": "https://example.com/cancellation-terms#p02", + "schedule": { + "anchor": "2026-03-09T19:00:00Z", + "tiers": [ + { + "until": "PT44H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + } + } + }, + "schema_valid": true, + "source": "https://github.com/Universal-Commerce-Protocol/ucp/pull/780#issuecomment-5607887511", + "source_note": "Derived spring-forward variant, not a separate contributor-supplied policy.", + "local_policy_example": "new_york_spring_local_template", + "evaluations": [ + { + "id": "before_cutoff", + "at": "2026-03-07T22:59:59Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + }, + "selected_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + }, + "intended_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + }, + "penalty": { + "status": "resolved", + "amount": 0, + "currency": "USD" + }, + "handling": "terms" + } + }, + { + "id": "at_cutoff", + "at": "2026-03-07T23:00:00Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "selected_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "penalty": { + "status": "resolved", + "amount": 15000, + "currency": "USD" + }, + "handling": "terms" + } + }, + { + "id": "after_cutoff", + "at": "2026-03-07T23:00:01Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "selected_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "penalty": { + "status": "resolved", + "amount": 15000, + "currency": "USD" + }, + "handling": "terms" + } + } + ] + }, + { + "id": "new_york_fall_resolved_fee", + "category": "valid", + "context": { + "response_generated_at": "2026-10-01T12:00:00Z", + "check_in": "2026-11-02T20:00:00Z", + "property_timezone": "America/New_York", + "stay_nights": 2, + "currency": "USD" + }, + "policy": { + "type": "dev.ucp.lodging.policy.cancellation", + "description": { + "plain": "Free cancellation strictly before 6:00 PM property-local time two calendar days before check-in. Cancellation at or after that time incurs one night's room rate, excluding taxes and fees. For this two-night reservation, the Business has resolved that penalty to USD 150.00 in total." + }, + "refundability": "refundable", + "url": "https://example.com/cancellation-terms#p03", + "schedule": { + "anchor": "2026-11-02T20:00:00Z", + "tiers": [ + { + "until": "PT46H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + } + } + }, + "schema_valid": true, + "source": "https://github.com/Universal-Commerce-Protocol/ucp/pull/780#issuecomment-5607887511", + "source_note": "Derived fall-back variant, not a separate contributor-supplied policy.", + "local_policy_example": "new_york_fall_local_template", + "evaluations": [ + { + "id": "before_cutoff", + "at": "2026-10-31T21:59:59Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + }, + "selected_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + }, + "intended_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + }, + "penalty": { + "status": "resolved", + "amount": 0, + "currency": "USD" + }, + "handling": "terms" + } + }, + { + "id": "at_cutoff", + "at": "2026-10-31T22:00:00Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "selected_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "penalty": { + "status": "resolved", + "amount": 15000, + "currency": "USD" + }, + "handling": "terms" + } + }, + { + "id": "after_cutoff", + "at": "2026-10-31T22:00:01Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "selected_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "penalty": { + "status": "resolved", + "amount": 15000, + "currency": "USD" + }, + "handling": "terms" + } + } + ] + }, + { + "id": "multiple_resolved_tiers", + "category": "valid", + "context": { + "response_generated_at": "2026-12-01T12:00:00Z", + "check_in": "2026-12-31T12:00:00Z", + "property_timezone": "Etc/UTC", + "stay_nights": 2, + "currency": "USD" + }, + "policy": { + "type": "dev.ucp.lodging.policy.cancellation", + "description": { + "plain": "Cancellation is free strictly before December 24, 2026 at 12:00 UTC. From that instant until, but not including, December 29, 2026 at 12:00 UTC, the total cancellation penalty is USD 75.00. At or after December 29 at 12:00 UTC, the total cancellation penalty is USD 150.00." + }, + "refundability": "refundable", + "url": "https://example.com/cancellation-terms#p04", + "schedule": { + "anchor": "2026-12-31T12:00:00Z", + "tiers": [ + { + "until": "P7D", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + }, + { + "until": "PT48H", + "outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 7500 + } + } + } + ], + "after_last_tier": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + } + } + }, + "schema_valid": true, + "evaluations": [ + { + "id": "before_first_cutoff", + "at": "2026-12-24T11:59:59Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + }, + "selected_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + }, + "intended_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + }, + "penalty": { + "status": "resolved", + "amount": 0, + "currency": "USD" + }, + "handling": "terms" + } + }, + { + "id": "at_first_cutoff", + "at": "2026-12-24T12:00:00Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/tiers/1/outcome" + }, + "selected_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 7500 + } + }, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 7500 + } + }, + "penalty": { + "status": "resolved", + "amount": 7500, + "currency": "USD" + }, + "handling": "terms" + } + }, + { + "id": "after_first_cutoff", + "at": "2026-12-24T12:00:01Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/tiers/1/outcome" + }, + "selected_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 7500 + } + }, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 7500 + } + }, + "penalty": { + "status": "resolved", + "amount": 7500, + "currency": "USD" + }, + "handling": "terms" + } + }, + { + "id": "before_last_cutoff", + "at": "2026-12-29T11:59:59Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/tiers/1/outcome" + }, + "selected_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 7500 + } + }, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 7500 + } + }, + "penalty": { + "status": "resolved", + "amount": 7500, + "currency": "USD" + }, + "handling": "terms" + } + }, + { + "id": "at_last_cutoff", + "at": "2026-12-29T12:00:00Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "selected_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "penalty": { + "status": "resolved", + "amount": 15000, + "currency": "USD" + }, + "handling": "terms" + } + }, + { + "id": "after_last_cutoff", + "at": "2026-12-29T12:00:01Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "selected_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "penalty": { + "status": "resolved", + "amount": 15000, + "currency": "USD" + }, + "handling": "terms" + } + } + ] + }, + { + "id": "non_refundable_from_confirmation", + "category": "valid", + "context": { + "response_generated_at": "2026-12-01T12:00:00Z", + "check_in": "2026-12-22T20:00:00Z", + "property_timezone": "Etc/UTC", + "stay_nights": 2, + "currency": "USD" + }, + "policy": { + "type": "dev.ucp.lodging.policy.cancellation", + "description": { + "plain": "This promotional reservation is non-refundable from confirmation: cancellation returns none of the prepaid accommodation charge. There is no free-cancellation interval. The prepaid charge amount is not included in this response." + }, + "refundability": "non_refundable", + "url": "https://example.com/cancellation-terms#p05", + "schedule": { + "anchor": "2026-12-22T20:00:00Z", + "tiers": [ + { + "until": "PT48H", + "outcome": { + "kind": "percentage", + "buyer_bps": 0 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "schema_valid": true, + "evaluations": [ + { + "id": "before_cutoff", + "at": "2026-12-20T19:59:59Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + }, + "selected_outcome": { + "kind": "percentage", + "buyer_bps": 0 + }, + "intended_outcome": { + "kind": "percentage", + "buyer_bps": 0 + }, + "penalty": { + "status": "not_resolved", + "reason": "No prepaid monetary basis is supplied; disclose the refund percentage without inventing a cash penalty." + }, + "handling": "terms" + } + }, + { + "id": "at_cutoff", + "at": "2026-12-20T20:00:00Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "selected_outcome": { + "kind": "percentage", + "buyer_bps": 0 + }, + "intended_outcome": { + "kind": "percentage", + "buyer_bps": 0 + }, + "penalty": { + "status": "not_resolved", + "reason": "No prepaid monetary basis is supplied; disclose the refund percentage without inventing a cash penalty." + }, + "handling": "terms" + } + }, + { + "id": "after_cutoff", + "at": "2026-12-20T20:00:01Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "selected_outcome": { + "kind": "percentage", + "buyer_bps": 0 + }, + "intended_outcome": { + "kind": "percentage", + "buyer_bps": 0 + }, + "penalty": { + "status": "not_resolved", + "reason": "No prepaid monetary basis is supplied; disclose the refund percentage without inventing a cash penalty." + }, + "handling": "terms" + } + } + ] + }, + { + "id": "symbolic_night_without_price", + "category": "valid", + "context": { + "response_generated_at": "2026-10-01T12:00:00Z", + "check_in": "2026-10-16T22:00:00Z", + "property_timezone": "America/Phoenix", + "stay_nights": 2, + "currency": "USD" + }, + "policy": { + "type": "dev.ucp.lodging.policy.cancellation", + "description": { + "plain": "Free cancellation strictly before 6:00 PM property-local time two calendar days before check-in. At or after that time, the penalty is the first night's room rate, excluding taxes and fees. No nightly room-rate amounts or resolved cash penalty are supplied in this response." + }, + "refundability": "refundable", + "url": "https://example.com/cancellation-terms#p06", + "schedule": { + "anchor": "2026-10-16T22:00:00Z", + "tiers": [ + { + "until": "PT45H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "unit_deduction", + "penalty": { + "measure": { + "value": 1, + "unit": "night", + "display_text": "night" + } + } + } + } + }, + "schema_valid": true, + "source": "https://github.com/Universal-Commerce-Protocol/ucp/pull/780#issuecomment-5607887511", + "source_note": "Symbolic variant of the public pattern; room-rate amount deliberately withheld.", + "evaluations": [ + { + "id": "before_cutoff", + "at": "2026-10-15T00:59:59Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + }, + "selected_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + }, + "intended_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + }, + "penalty": { + "status": "resolved", + "amount": 0, + "currency": "USD" + }, + "handling": "terms" + } + }, + { + "id": "at_cutoff", + "at": "2026-10-15T01:00:00Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "selected_outcome": { + "kind": "unit_deduction", + "penalty": { + "measure": { + "value": 1, + "unit": "night", + "display_text": "night" + } + } + }, + "intended_outcome": { + "kind": "unit_deduction", + "penalty": { + "measure": { + "value": 1, + "unit": "night", + "display_text": "night" + } + } + }, + "penalty": { + "status": "not_resolved", + "reason": "No room-rate amount or unambiguous monetary basis is supplied." + }, + "handling": "terms" + } + }, + { + "id": "after_cutoff", + "at": "2026-10-15T01:00:01Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "selected_outcome": { + "kind": "unit_deduction", + "penalty": { + "measure": { + "value": 1, + "unit": "night", + "display_text": "night" + } + } + }, + "intended_outcome": { + "kind": "unit_deduction", + "penalty": { + "measure": { + "value": 1, + "unit": "night", + "display_text": "night" + } + } + }, + "penalty": { + "status": "not_resolved", + "reason": "No room-rate amount or unambiguous monetary basis is supplied." + }, + "handling": "terms" + } + } + ] + }, + { + "id": "percentage_without_price_basis", + "category": "valid", + "context": { + "response_generated_at": "2026-12-01T12:00:00Z", + "check_in": "2026-12-22T20:00:00Z", + "property_timezone": "Etc/UTC", + "stay_nights": 2, + "currency": "USD" + }, + "policy": { + "type": "dev.ucp.lodging.policy.cancellation", + "description": { + "plain": "Cancellation is free strictly before December 20, 2026 at 20:00 UTC. At or after that instant, 50% of the prepaid accommodation subtotal is refunded and 50% is retained. That subtotal is not supplied in this response." + }, + "refundability": "refundable", + "url": "https://example.com/cancellation-terms#p07", + "schedule": { + "anchor": "2026-12-22T20:00:00Z", + "tiers": [ + { + "until": "PT48H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 5000 + } + } + }, + "schema_valid": true, + "evaluations": [ + { + "id": "before_cutoff", + "at": "2026-12-20T19:59:59Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + }, + "selected_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + }, + "intended_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + }, + "penalty": { + "status": "resolved", + "amount": 0, + "currency": "USD" + }, + "handling": "terms" + } + }, + { + "id": "at_cutoff", + "at": "2026-12-20T20:00:00Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "selected_outcome": { + "kind": "percentage", + "buyer_bps": 5000 + }, + "intended_outcome": { + "kind": "percentage", + "buyer_bps": 5000 + }, + "penalty": { + "status": "not_resolved", + "reason": "No prepaid monetary basis is supplied; disclose the refund percentage without inventing a cash penalty." + }, + "handling": "terms" + } + }, + { + "id": "after_cutoff", + "at": "2026-12-20T20:00:01Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "selected_outcome": { + "kind": "percentage", + "buyer_bps": 5000 + }, + "intended_outcome": { + "kind": "percentage", + "buyer_bps": 5000 + }, + "penalty": { + "status": "not_resolved", + "reason": "No prepaid monetary basis is supplied; disclose the refund percentage without inventing a cash penalty." + }, + "handling": "terms" + } + } + ] + }, + { + "id": "missing_fixed_fee_amount", + "category": "invalid_schedule", + "context": { + "response_generated_at": "2026-10-01T12:00:00Z", + "check_in": "2026-10-16T22:00:00Z", + "property_timezone": "America/Phoenix", + "stay_nights": 2, + "currency": "USD" + }, + "policy": { + "type": "dev.ucp.lodging.policy.cancellation", + "description": { + "plain": "Free cancellation strictly before 6:00 PM property-local time two calendar days before check-in. Cancellation at or after that time incurs one night's room rate, excluding taxes and fees. For this two-night reservation, the Business has resolved that penalty to USD 150.00 in total." + }, + "refundability": "refundable", + "url": "https://example.com/cancellation-terms#p08", + "schedule": { + "anchor": "2026-10-16T22:00:00Z", + "tiers": [ + { + "until": "PT45H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "fixed_fee", + "penalty": {} + } + } + }, + "schema_valid": false, + "reference_schedule": { + "anchor": "2026-10-16T22:00:00Z", + "tiers": [ + { + "until": "PT45H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + } + }, + "defect": "The fixed_fee penalty is missing its required amount. The whole schedule is invalid even before that outcome would be selected.", + "evaluations": [ + { + "id": "before_cutoff", + "at": "2026-10-15T00:59:59Z", + "expected": { + "schedule_result": { + "status": "unavailable", + "reason": "invalid_schedule" + }, + "selected_outcome": null, + "intended_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + }, + "penalty": { + "status": "resolved", + "amount": 0, + "currency": "USD" + }, + "handling": "prose_fallback" + } + }, + { + "id": "at_cutoff", + "at": "2026-10-15T01:00:00Z", + "expected": { + "schedule_result": { + "status": "unavailable", + "reason": "invalid_schedule" + }, + "selected_outcome": null, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "penalty": { + "status": "resolved", + "amount": 15000, + "currency": "USD" + }, + "handling": "prose_fallback" + } + }, + { + "id": "after_cutoff", + "at": "2026-10-15T01:00:01Z", + "expected": { + "schedule_result": { + "status": "unavailable", + "reason": "invalid_schedule" + }, + "selected_outcome": null, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "penalty": { + "status": "resolved", + "amount": 15000, + "currency": "USD" + }, + "handling": "prose_fallback" + } + } + ] + }, + { + "id": "reversed_tier_order", + "category": "invalid_schedule", + "context": { + "response_generated_at": "2026-12-01T12:00:00Z", + "check_in": "2026-12-31T12:00:00Z", + "property_timezone": "Etc/UTC", + "stay_nights": 2, + "currency": "USD" + }, + "policy": { + "type": "dev.ucp.lodging.policy.cancellation", + "description": { + "plain": "Cancellation is free strictly before December 24, 2026 at 12:00 UTC. From that instant until, but not including, December 29, 2026 at 12:00 UTC, the total cancellation penalty is USD 75.00. At or after December 29 at 12:00 UTC, the total cancellation penalty is USD 150.00." + }, + "refundability": "refundable", + "url": "https://example.com/cancellation-terms#p09", + "schedule": { + "anchor": "2026-12-31T12:00:00Z", + "tiers": [ + { + "until": "PT48H", + "outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 7500 + } + } + }, + { + "until": "P7D", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + } + } + }, + "schema_valid": true, + "reference_schedule": { + "anchor": "2026-12-31T12:00:00Z", + "tiers": [ + { + "until": "P7D", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + }, + { + "until": "PT48H", + "outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 7500 + } + } + } + ], + "after_last_tier": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + } + }, + "defect": "The tiers are reversed: PT48H precedes P7D. Schema validation passes but the required decreasing-duration order fails.", + "evaluations": [ + { + "id": "before_first_cutoff", + "at": "2026-12-24T11:59:59Z", + "expected": { + "schedule_result": { + "status": "unavailable", + "reason": "invalid_schedule" + }, + "selected_outcome": null, + "intended_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + }, + "penalty": { + "status": "resolved", + "amount": 0, + "currency": "USD" + }, + "handling": "prose_fallback" + } + }, + { + "id": "at_first_cutoff", + "at": "2026-12-24T12:00:00Z", + "expected": { + "schedule_result": { + "status": "unavailable", + "reason": "invalid_schedule" + }, + "selected_outcome": null, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 7500 + } + }, + "penalty": { + "status": "resolved", + "amount": 7500, + "currency": "USD" + }, + "handling": "prose_fallback" + } + }, + { + "id": "after_first_cutoff", + "at": "2026-12-24T12:00:01Z", + "expected": { + "schedule_result": { + "status": "unavailable", + "reason": "invalid_schedule" + }, + "selected_outcome": null, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 7500 + } + }, + "penalty": { + "status": "resolved", + "amount": 7500, + "currency": "USD" + }, + "handling": "prose_fallback" + } + }, + { + "id": "before_last_cutoff", + "at": "2026-12-29T11:59:59Z", + "expected": { + "schedule_result": { + "status": "unavailable", + "reason": "invalid_schedule" + }, + "selected_outcome": null, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 7500 + } + }, + "penalty": { + "status": "resolved", + "amount": 7500, + "currency": "USD" + }, + "handling": "prose_fallback" + } + }, + { + "id": "at_last_cutoff", + "at": "2026-12-29T12:00:00Z", + "expected": { + "schedule_result": { + "status": "unavailable", + "reason": "invalid_schedule" + }, + "selected_outcome": null, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "penalty": { + "status": "resolved", + "amount": 15000, + "currency": "USD" + }, + "handling": "prose_fallback" + } + }, + { + "id": "after_last_cutoff", + "at": "2026-12-29T12:00:01Z", + "expected": { + "schedule_result": { + "status": "unavailable", + "reason": "invalid_schedule" + }, + "selected_outcome": null, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "penalty": { + "status": "resolved", + "amount": 15000, + "currency": "USD" + }, + "handling": "prose_fallback" + } + } + ] + }, + { + "id": "description_schedule_amount_conflict", + "category": "description_conflict", + "context": { + "response_generated_at": "2026-10-01T12:00:00Z", + "check_in": "2026-10-16T22:00:00Z", + "property_timezone": "America/Phoenix", + "stay_nights": 2, + "currency": "USD" + }, + "policy": { + "type": "dev.ucp.lodging.policy.cancellation", + "description": { + "plain": "Free cancellation strictly before 6:00 PM property-local time two calendar days before check-in. Cancellation at or after that time incurs one night's room rate, excluding taxes and fees. For this two-night reservation, the Business has resolved that penalty to USD 150.00 in total." + }, + "refundability": "refundable", + "url": "https://example.com/cancellation-terms#p10", + "schedule": { + "anchor": "2026-10-16T22:00:00Z", + "tiers": [ + { + "until": "PT45H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "fixed_fee", + "penalty": { + "amount": 7500 + } + } + } + }, + "schema_valid": true, + "reference_schedule": { + "anchor": "2026-10-16T22:00:00Z", + "tiers": [ + { + "until": "PT45H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + } + }, + "defect": "The prose states USD 150.00 after the cutoff, but the structurally valid schedule states USD 75.00. No independent contradiction signal is supplied to the agent. Detection is an exploratory observation, not a mandated prose-parsing conformance test.", + "evaluations": [ + { + "id": "before_cutoff", + "at": "2026-10-15T00:59:59Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + }, + "selected_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + }, + "intended_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + }, + "penalty": { + "status": "resolved", + "amount": 0, + "currency": "USD" + }, + "handling": "conflict_probe" + } + }, + { + "id": "at_cutoff", + "at": "2026-10-15T01:00:00Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "selected_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 7500 + } + }, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "penalty": { + "status": "resolved", + "amount": 15000, + "currency": "USD" + }, + "handling": "conflict_probe" + } + }, + { + "id": "after_cutoff", + "at": "2026-10-15T01:00:01Z", + "expected": { + "schedule_result": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "selected_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 7500 + } + }, + "intended_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + }, + "penalty": { + "status": "resolved", + "amount": 15000, + "currency": "USD" + }, + "handling": "conflict_probe" + } + } + ] + } + ] +} diff --git a/scripts/fixtures/lodging_cancellation_lab.md b/scripts/fixtures/lodging_cancellation_lab.md new file mode 100644 index 000000000..41cccf137 --- /dev/null +++ b/scripts/fixtures/lodging_cancellation_lab.md @@ -0,0 +1,180 @@ + + +# Lodging cancellation disclosure study fixtures + +This non-normative package supplies **10 synthetic policy scenarios and 36 +evaluation instants** for a paired agent-behavior study. It compares the same +cancellation terms expressed as classification plus prose with those terms +plus the optional schedule proposed in +[#808](https://github.com/Universal-Commerce-Protocol/ucp/pull/808). + +These are inputs for an independent experiment, not experimental results, +production policy data, an official conformance suite, or evidence that the +proposal has been approved. No customer agreements or confidential data are +included. The package does not run models, host an endpoint, complete a +booking, or execute a cancellation or refund. + +## Files and provenance + +- [Portable fixtures](lodging_cancellation_lab.json): policies, reservation + context, explicit evaluation instants, and separately labeled expectations. +- [Fixture validator](../test_cancellation_lab.py): checks the package against + the existing schema and test-only selection oracle. +- [Underlying temporal vectors](lodging_cancellation_schedule.json) and + [oracle](../test_cancellation_schedule.py): exact selection, ordering and + local-time derivation checks already accompanying the proposal. +- [Proposed specification](../../docs/specification/lodging/extensions/cancellation-policy.md): + the normative text being evaluated, still under review. + +The Phoenix pattern adapts the +[public synthetic example supplied in #780](https://github.com/Universal-Commerce-Protocol/ucp/pull/780#issuecomment-5607887511). +The New York spring/fall and symbolic variants are derived examples, not new +policies supplied by that contributor. Other scenarios and deliberate defects +are synthetic additions for this study. `proposal_snapshot` records the +specification commit used when this package was authored; a study should also +record the exact commit containing the fixture files it actually runs. + +## Scenario matrix + +All indices in outcome pointers are zero-based. Each boundary is queried one +second before, exactly at, and one second after it. The two-tier scenarios +have two boundaries. The non-refundable scenario also probes a deliberately +redundant boundary: its terms never become free. + +| ID | Focus | Queries | +| --- | --- | --- | +| `phoenix_resolved_fee` | Local 18:00 cutoff, 45 elapsed hours; USD 150.00 penalty | 3 | +| `new_york_spring_resolved_fee` | Same local rule across spring transition; 44 hours | 3 | +| `new_york_fall_resolved_fee` | Same local rule across autumn transition; 46 hours | 3 | +| `multiple_resolved_tiers` | Free, then USD 75.00, then USD 150.00 | 6 | +| `non_refundable_from_confirmation` | No refund throughout; no cash basis supplied | 3 | +| `symbolic_night_without_price` | First-night room-only penalty; no room-rate amount | 3 | +| `percentage_without_price_basis` | 50% refund after cutoff; no subtotal amount | 3 | +| `missing_fixed_fee_amount` | Required amount missing, even in an unselected outcome | 3 | +| `reversed_tier_order` | Schema-valid JSON, but invalid normalized tier ordering | 6 | +| `description_schedule_amount_conflict` | Prose says USD 150.00; schedule says USD 75.00 | 3 | + +## Paired inputs and isolation + +The JSON envelope is **test metadata, not a proposed Booking wire format**. +Only `policy` is a wire cancellation-policy item. An endpoint adapter places +it in the appropriate Booking `policies[]` array and supplies root Booking +`currency` from `context.currency`. It must supply the other fields needed for +its chosen Booking response independently; this package is not a complete +Booking response or a new endpoint definition. + +For each fixture, construct two otherwise identical inputs: + +1. **Classification plus prose:** copy `policy` and remove only `schedule`. +2. **Schedule added:** copy `policy` unchanged, including deliberate defects. + +Expose the same reservation facts from `context` to both arms using the lab's +chosen Booking representation or clearly labeled test context. The check-in, +property timezone, stay length, currency, response-generation time, policy +description, legal URL, and evaluation instant must not differ between arms. +Do not invent room rates, totals or prepaid amounts to fill optional fields: +their deliberate absence is part of the symbolic/no-basis scenarios. If an +adapter requires additional pricing data, document it and re-check the +expected monetary answers before running the pair. + +Keep `category`, `schema_valid`, `reference_schedule`, `defect`, provenance, +`expected`, and the validator's output **outside the agent-visible input**. +Construct that input by selecting the allowed fields, not by sending the +entire fixture and asking the model to ignore its answers. The example.com +URLs are synthetic; do not allow live retrieval to add unknown policy facts. +If the lab serves a legal page, it should serve only the same visible prose. + +Ask about cancelling at the exact `evaluations[].at` instant. The lab owns +the conversation and prompt wording; a neutral question is: "If I cancel at +[explicit timestamp], what would the cancellation penalty be? Please explain +any limits on what you can determine." Do not disclose the expected tier, +amount, error category, or that a particular case contains a contradiction. + +Freeze the response-generation time to `context.response_generated_at` and +treat each query as hypothetical. These fixtures intentionally retain a +response captured before a cutoff while asking about later instants. A +previously `refundable` snapshot is not a contradiction merely because a +hypothetical later cancellation incurs a penalty. Do not substitute the real +wall clock, or label the captured snapshot as a newly refreshed response at +the hypothetical time. All evaluation instants precede check-in; the +conversation stops before booking completion or any cancellation execution. + +## Expectations and interpretation + +Each evaluation separates two kinds of evidence: + +- `schedule_result` and `selected_outcome` describe what the existing test + oracle can select from the **emitted wire schedule**. A selected result is + not a judgment that the schedule agrees with the prose. +- `intended_outcome` and `penalty` describe the **synthetic policy's intended + terms**. For a defective/conflicting fixture, `reference_schedule` supplies + the hidden, correct schedule used to check that ground truth. It is never + served to the agent and never silently replaces the emitted schedule. + +`penalty.status: resolved` is either an explicit Business-resolved fixed fee +or zero for the stated free-cancellation interval. Amounts are integer minor +units in `context.currency`. They are penalties, not refund amounts. A +`not_resolved` penalty means there is no defensible cash penalty in the +supplied facts; it does not mean free cancellation or zero refund. Correct +disclosure can be symbolic: one night's room rate, a 50% refund, or no refund, +with an explanation that the monetary basis is missing. + +The `handling` labels distinguish: + +- **`terms`:** disclose the applicable terms; quote money only when resolved. +- **`prose_fallback`:** the entire emitted schedule is unusable. Do not present + a guessed structured result; use the complete prose/legal fallback. These + checks include a malformed outcome that would not yet be selected, and + ordering that JSON Schema alone cannot enforce. +- **`conflict_probe`:** a structurally valid schedule disagrees with the + description. The schedule oracle cannot detect prose contradictions; its + selected USD 75.00 is not the intended USD 150.00 policy charge. No separate + contradiction signal is supplied to the agent. Observe whether it notices + and explains the conflict, qualifies its answer, or requests authoritative + clarification. Do not score an appropriately qualified answer as wrong + merely because it declines to assert one definitive amount. + +The proposal says that when a contradiction is **independently known**, a +Platform must not present a guessed structured result and should present the +description and URL. It does **not** require parsing legal prose to discover +every contradiction. This conflict probe therefore measures agent behavior, +not a new universal contradiction-detection requirement. In the prose-only +arm the defective schedule is absent; the intended terms remain answerable +from the same description. + +Model selection, prompts, repetitions, scoring, and publication remain with +the independent lab. For interpretability, results can separate temporal +term selection, monetary accuracy, justified uncertainty, and invalid-input +handling rather than collapsing all answers into one success percentage. +Record the fixture/specification commits, model versions, prompts, adapter +behavior, and repetitions, including cases where adding a schedule does not +help. This package neither prescribes a favorable outcome nor endorses a +particular evaluator or proposal decision. + +## Run the local checks + +From the repository root, with Python 3.10+, `ucp-schema` on `PATH`, and IANA +timezone data available: + +```sh +python3 scripts/test_cancellation_schedule.py +python3 scripts/test_cancellation_lab.py +``` + +These checks validate fixture consistency, not agent performance. The prose +and experimental interpretation still require human review. No network model +calls are made by these test scripts. diff --git a/scripts/fixtures/lodging_cancellation_schedule.json b/scripts/fixtures/lodging_cancellation_schedule.json new file mode 100644 index 000000000..4d1e0a7f6 --- /dev/null +++ b/scripts/fixtures/lodging_cancellation_schedule.json @@ -0,0 +1,1137 @@ +{ + "description": "Test-only schedule selection vectors. Named schedules contain unchanged wire values. Results use test metadata and JSON Pointers relative to a policy containing schedule; they do not calculate money or classify refundability. Omitted schema_valid expectations allow calendar validation to be tested independently of optional JSON Schema format assertions.", + "local_policy_examples": [ + { + "id": "phoenix_local_template", + "source": "https://github.com/Universal-Commerce-Protocol/ucp/pull/780#issuecomment-5607887511", + "description": "Contributor-provided synthetic reservation; fixed fee is the Business-resolved room-only penalty in root Booking currency USD, not money inferred by the selector.", + "schedule": "phoenix_room_only_fee", + "timezone": "America/Phoenix", + "check_in_local": "2026-10-16T15:00:00", + "days_before": 2, + "cutoff_local_time": "18:00:00", + "expected": { + "anchor_epoch": 1792188000, + "cutoff_epoch": 1792026000, + "elapsed_seconds": 162000 + } + }, + { + "id": "new_york_spring_local_template", + "source": "https://github.com/Universal-Commerce-Protocol/ucp/pull/780#issuecomment-5607887511", + "description": "Derived spring-forward variant of the public example, not a separate contributor-supplied policy.", + "schedule": "new_york_spring_room_only_fee", + "timezone": "America/New_York", + "check_in_local": "2026-03-09T15:00:00", + "days_before": 2, + "cutoff_local_time": "18:00:00", + "expected": { + "anchor_epoch": 1773082800, + "cutoff_epoch": 1772924400, + "elapsed_seconds": 158400 + } + }, + { + "id": "new_york_fall_local_template", + "source": "https://github.com/Universal-Commerce-Protocol/ucp/pull/780#issuecomment-5607887511", + "description": "Derived fall-back variant of the public example, not a separate contributor-supplied policy.", + "schedule": "new_york_fall_room_only_fee", + "timezone": "America/New_York", + "check_in_local": "2026-11-02T15:00:00", + "days_before": 2, + "cutoff_local_time": "18:00:00", + "expected": { + "anchor_epoch": 1793649600, + "cutoff_epoch": 1793484000, + "elapsed_seconds": 165600 + } + } + ], + "schedules": { + "phoenix_room_only_fee": { + "schema_valid": true, + "value": { + "anchor": "2026-10-16T22:00:00Z", + "tiers": [ + { + "until": "PT45H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + } + } + }, + "phoenix_symbolic_night": { + "schema_valid": true, + "value": { + "anchor": "2026-10-16T22:00:00Z", + "tiers": [ + { + "until": "PT45H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "unit_deduction", + "penalty": { + "measure": { + "value": 1, + "unit": "night", + "display_text": "night" + } + } + } + } + }, + "new_york_spring_room_only_fee": { + "schema_valid": true, + "value": { + "anchor": "2026-03-09T19:00:00Z", + "tiers": [ + { + "until": "PT44H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + } + } + }, + "new_york_fall_room_only_fee": { + "schema_valid": true, + "value": { + "anchor": "2026-11-02T20:00:00Z", + "tiers": [ + { + "until": "PT46H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + } + } + }, + "free_night": { + "schema_valid": true, + "value": { + "anchor": "2026-12-22T15:00:00-05:00", + "tiers": [ + { + "until": "PT48H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "unit_deduction", + "penalty": { + "measure": { + "value": 1, + "unit": "night", + "display_text": "night" + } + } + } + } + }, + "three_intervals": { + "schema_valid": true, + "value": { + "anchor": "2026-12-31T12:00:00Z", + "tiers": [ + { + "until": "P7D", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + }, + { + "until": "PT48H", + "outcome": { + "kind": "percentage", + "buyer_bps": 5000 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "positive_offset_fee": { + "schema_valid": true, + "value": { + "anchor": "2026-12-22T15:00:00+05:30", + "tiers": [ + { + "until": "PT1H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "fixed_fee", + "penalty": { + "amount": 7500 + } + } + } + }, + "day_across_dst": { + "schema_valid": true, + "value": { + "anchor": "2026-03-09T02:30:00-04:00", + "tiers": [ + { + "until": "P1D", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "zero_duration": { + "schema_valid": true, + "value": { + "anchor": "2026-12-22T15:00:00Z", + "tiers": [ + { + "until": "PT0S", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "mixed_duration": { + "schema_valid": true, + "value": { + "anchor": "2026-12-22T15:00:00Z", + "tiers": [ + { + "until": "PT1H30S", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "equivalent_durations": { + "schema_valid": true, + "value": { + "anchor": "2026-12-22T15:00:00Z", + "tiers": [ + { + "until": "P1D", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + }, + { + "until": "PT24H", + "outcome": { + "kind": "percentage", + "buyer_bps": 5000 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "reversed_durations": { + "schema_valid": true, + "value": { + "anchor": "2026-12-22T15:00:00Z", + "tiers": [ + { + "until": "PT24H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + }, + { + "until": "P2D", + "outcome": { + "kind": "percentage", + "buyer_bps": 5000 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "invalid_calendar": { + "value": { + "anchor": "2026-02-30T15:00:00Z", + "tiers": [ + { + "until": "PT1H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "fractional_anchor": { + "schema_valid": true, + "value": { + "anchor": "2026-12-22T15:00:00.000000002Z", + "tiers": [ + { + "until": "PT1S", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "unknown_after": { + "schema_valid": true, + "value": { + "anchor": "2026-12-22T15:00:00Z", + "tiers": [ + { + "until": "PT1H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "com.example.voucher", + "value": "credit" + } + } + }, + "unknown_before": { + "schema_valid": true, + "value": { + "anchor": "2026-12-22T15:00:00Z", + "tiers": [ + { + "until": "PT1H", + "outcome": { + "kind": "com.example.voucher", + "value": "credit" + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "malformed_after": { + "schema_valid": false, + "value": { + "anchor": "2026-12-22T15:00:00Z", + "tiers": [ + { + "until": "PT1H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "fixed_fee" + } + } + }, + "leap_boundary": { + "schema_valid": true, + "value": { + "anchor": "1991-01-01T00:00:00Z", + "tiers": [ + { + "until": "PT2S", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "leap_second_anchor": { + "schema_valid": false, + "value": { + "anchor": "1990-12-31T23:59:60Z", + "tiers": [ + { + "until": "PT1S", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "newline_anchor": { + "schema_valid": false, + "value": { + "anchor": "2026-12-22T15:00:00Z\n", + "tiers": [ + { + "until": "PT1H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "newline_duration": { + "schema_valid": false, + "value": { + "anchor": "2026-12-22T15:00:00Z", + "tiers": [ + { + "until": "PT1H\n", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "bad_bps": { + "schema_valid": false, + "value": { + "anchor": "2026-12-22T15:00:00Z", + "tiers": [ + { + "until": "PT1H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10001 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "fractional_nights": { + "schema_valid": false, + "value": { + "anchor": "2026-12-22T15:00:00Z", + "tiers": [ + { + "until": "PT1H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "unit_deduction", + "penalty": { + "measure": { + "value": 1, + "scale": 1, + "unit": "night", + "display_text": "night" + } + } + } + } + }, + "calendar_duration": { + "schema_valid": false, + "value": { + "anchor": "2026-12-22T15:00:00Z", + "tiers": [ + { + "until": "P1M", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "exact_large_durations": { + "schema_valid": true, + "value": { + "anchor": "2026-12-22T15:00:00Z", + "tiers": [ + { + "until": "PT9007199254740993S", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + }, + { + "until": "PT9007199254740992S", + "outcome": { + "kind": "percentage", + "buyer_bps": 5000 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "negative_zero_offset": { + "schema_valid": true, + "value": { + "anchor": "2026-12-22T15:00:00-00:00", + "tiers": [ + { + "until": "PT1H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + }, + "known_with_unrelated_fields": { + "schema_valid": true, + "value": { + "anchor": "2026-12-22T15:00:00Z", + "tiers": [ + { + "until": "PT1H", + "outcome": { + "kind": "percentage", + "buyer_bps": 10000, + "penalty": { + "amount": 500 + } + } + } + ], + "after_last_tier": { + "kind": "percentage", + "buyer_bps": 0 + } + } + } + }, + "cases": [ + { + "id": "phoenix_room_only_fee_before", + "schedule": "phoenix_room_only_fee", + "at": "2026-10-15T00:59:59Z", + "expected": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + }, + "expected_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + }, + { + "id": "phoenix_room_only_fee_at_cutoff", + "schedule": "phoenix_room_only_fee", + "at": "2026-10-15T01:00:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "expected_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + } + }, + { + "id": "phoenix_symbolic_night_before", + "schedule": "phoenix_symbolic_night", + "at": "2026-10-15T00:59:59Z", + "expected": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + }, + "expected_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + }, + { + "id": "phoenix_symbolic_night_at_cutoff", + "schedule": "phoenix_symbolic_night", + "at": "2026-10-15T01:00:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "expected_outcome": { + "kind": "unit_deduction", + "penalty": { + "measure": { + "value": 1, + "unit": "night", + "display_text": "night" + } + } + } + }, + { + "id": "new_york_spring_room_only_fee_before", + "schedule": "new_york_spring_room_only_fee", + "at": "2026-03-07T22:59:59Z", + "expected": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + }, + "expected_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + }, + { + "id": "new_york_spring_room_only_fee_at_cutoff", + "schedule": "new_york_spring_room_only_fee", + "at": "2026-03-07T23:00:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "expected_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + } + }, + { + "id": "new_york_fall_room_only_fee_before", + "schedule": "new_york_fall_room_only_fee", + "at": "2026-10-31T21:59:59Z", + "expected": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + }, + "expected_outcome": { + "kind": "percentage", + "buyer_bps": 10000 + } + }, + { + "id": "new_york_fall_room_only_fee_at_cutoff", + "schedule": "new_york_fall_room_only_fee", + "at": "2026-10-31T22:00:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + }, + "expected_outcome": { + "kind": "fixed_fee", + "penalty": { + "amount": 15000 + } + } + }, + { + "id": "before_cutoff", + "schedule": "free_night", + "at": "2026-12-20T19:59:59Z", + "expected": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + } + }, + { + "id": "exact_cutoff", + "schedule": "free_night", + "at": "2026-12-20T20:00:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "after_cutoff", + "schedule": "free_night", + "at": "2026-12-20T20:00:01Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "same_instant_negative_offset", + "schedule": "free_night", + "at": "2026-12-20T15:00:00-05:00", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "first_interval", + "schedule": "three_intervals", + "at": "2026-12-24T11:59:59Z", + "expected": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + } + }, + { + "id": "first_cutoff", + "schedule": "three_intervals", + "at": "2026-12-24T12:00:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/tiers/1/outcome" + } + }, + { + "id": "middle_interval", + "schedule": "three_intervals", + "at": "2026-12-26T12:00:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/tiers/1/outcome" + } + }, + { + "id": "last_cutoff", + "schedule": "three_intervals", + "at": "2026-12-29T12:00:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "at_anchor", + "schedule": "three_intervals", + "at": "2026-12-31T12:00:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "after_anchor", + "schedule": "three_intervals", + "at": "2027-01-01T00:00:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "positive_offset_before", + "schedule": "positive_offset_fee", + "at": "2026-12-22T08:29:59Z", + "expected": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + } + }, + { + "id": "positive_offset_fixed_fee", + "schedule": "positive_offset_fee", + "at": "2026-12-22T08:30:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "elapsed_day", + "schedule": "day_across_dst", + "at": "2026-03-08T06:29:59Z", + "expected": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + } + }, + { + "id": "elapsed_day_cutoff", + "schedule": "day_across_dst", + "at": "2026-03-08T06:30:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "zero_before_anchor", + "schedule": "zero_duration", + "at": "2026-12-22T14:59:59Z", + "expected": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + } + }, + { + "id": "zero_at_anchor", + "schedule": "zero_duration", + "at": "2026-12-22T15:00:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "mixed_components_before", + "schedule": "mixed_duration", + "at": "2026-12-22T13:59:29Z", + "expected": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + } + }, + { + "id": "mixed_components_at_cutoff", + "schedule": "mixed_duration", + "at": "2026-12-22T13:59:30Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "duplicate_normalized_cutoff", + "schedule": "equivalent_durations", + "at": "2026-12-01T00:00:00Z", + "expected": { + "status": "unavailable", + "reason": "invalid_schedule" + } + }, + { + "id": "invalid_order_before_first_match", + "schedule": "reversed_durations", + "at": "2026-12-01T00:00:00Z", + "expected": { + "status": "unavailable", + "reason": "invalid_schedule" + } + }, + { + "id": "calendar_invalid_anchor", + "schedule": "invalid_calendar", + "at": "2026-02-01T00:00:00Z", + "expected": { + "status": "unavailable", + "reason": "invalid_schedule" + } + }, + { + "id": "calendar_invalid_at", + "schedule": "free_night", + "at": "2026-02-30T00:00:00Z", + "expected": { + "status": "unavailable", + "reason": "invalid_instant" + } + }, + { + "id": "missing_offset_at", + "schedule": "free_night", + "at": "2026-12-20T20:00:00", + "expected": { + "status": "unavailable", + "reason": "invalid_instant" + } + }, + { + "id": "sub_microsecond_before", + "schedule": "fractional_anchor", + "at": "2026-12-22T14:59:59.000000001Z", + "expected": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + } + }, + { + "id": "sub_microsecond_equal", + "schedule": "fractional_anchor", + "at": "2026-12-22T14:59:59.000000002Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "sub_microsecond_after", + "schedule": "fractional_anchor", + "at": "2026-12-22T14:59:59.000000003Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "unknown_after_unselected", + "schedule": "unknown_after", + "at": "2026-12-22T13:59:59Z", + "expected": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + } + }, + { + "id": "unknown_after_selected", + "schedule": "unknown_after", + "at": "2026-12-22T14:00:00Z", + "expected": { + "status": "unavailable", + "reason": "unsupported_kind", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "unknown_tier_selected", + "schedule": "unknown_before", + "at": "2026-12-22T13:59:59Z", + "expected": { + "status": "unavailable", + "reason": "unsupported_kind", + "pointer": "/schedule/tiers/0/outcome" + } + }, + { + "id": "unknown_tier_unselected", + "schedule": "unknown_before", + "at": "2026-12-22T14:00:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "malformed_unselected_outcome", + "schedule": "malformed_after", + "at": "2026-12-22T13:59:59Z", + "expected": { + "status": "unavailable", + "reason": "invalid_schedule" + } + }, + { + "id": "posix_across_leap_boundary", + "schedule": "leap_boundary", + "at": "1990-12-31T23:59:58.5Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "leap_second_anchor_unsupported", + "schedule": "leap_second_anchor", + "at": "1990-12-31T23:59:58Z", + "expected": { + "status": "unavailable", + "reason": "invalid_schedule" + } + }, + { + "id": "leap_second_at_unsupported", + "schedule": "leap_boundary", + "at": "1990-12-31T23:59:60Z", + "expected": { + "status": "unavailable", + "reason": "invalid_instant" + } + }, + { + "id": "trailing_newline_anchor", + "schedule": "newline_anchor", + "at": "2026-12-22T13:00:00Z", + "expected": { + "status": "unavailable", + "reason": "invalid_schedule" + } + }, + { + "id": "trailing_newline_duration", + "schedule": "newline_duration", + "at": "2026-12-22T13:00:00Z", + "expected": { + "status": "unavailable", + "reason": "invalid_schedule" + } + }, + { + "id": "trailing_newline_at", + "schedule": "free_night", + "at": "2026-12-20T19:59:59Z\n", + "expected": { + "status": "unavailable", + "reason": "invalid_instant" + } + }, + { + "id": "out_of_range_percentage", + "schedule": "bad_bps", + "at": "2026-12-22T13:00:00Z", + "expected": { + "status": "unavailable", + "reason": "invalid_schedule" + } + }, + { + "id": "nonzero_unit_scale", + "schedule": "fractional_nights", + "at": "2026-12-22T14:00:00Z", + "expected": { + "status": "unavailable", + "reason": "invalid_schedule" + } + }, + { + "id": "calendar_month_duration", + "schedule": "calendar_duration", + "at": "2026-12-22T13:00:00Z", + "expected": { + "status": "unavailable", + "reason": "invalid_schedule" + } + }, + { + "id": "duration_precision_above_safe_float", + "schedule": "exact_large_durations", + "at": "2026-12-22T13:00:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "negative_zero_offset_is_utc", + "schedule": "negative_zero_offset", + "at": "2026-12-22T14:00:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/after_last_tier" + } + }, + { + "id": "known_kind_ignores_unrelated_fields", + "schedule": "known_with_unrelated_fields", + "at": "2026-12-22T13:00:00Z", + "expected": { + "status": "selected", + "pointer": "/schedule/tiers/0/outcome" + } + }, + { + "id": "schedule_absent", + "schedule": null, + "at": "2026-12-22T13:00:00Z", + "expected": { + "status": "unavailable", + "reason": "schedule_absent" + } + } + ] +} diff --git a/scripts/test_cancellation_lab.py b/scripts/test_cancellation_lab.py new file mode 100755 index 000000000..5165bfbf5 --- /dev/null +++ b/scripts/test_cancellation_lab.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Validate synthetic cancellation lab inputs and held-out expected answers. + +Run: python3 scripts/test_cancellation_lab.py (requires ucp-schema on PATH). +This checks fixture consistency, not agent performance or legal prose. Known +semantic conflicts remain exploratory probes, not automatic detection claims. +Money checks use only Business-resolved fees or an explicitly full refund. +""" + +import copy +import json +import re +import shutil +import subprocess +import sys +import tempfile +from functools import cache +from pathlib import Path +from zoneinfo import ZoneInfo + +from test_cancellation_schedule import ( + FIXTURES as SOURCE_FIXTURES, + ROOT, + SCHEMA, + check_local_policy_examples, + evaluate, + instant, + schema_valid, +) + +FIXTURES = ROOT / "scripts/fixtures/lodging_cancellation_lab.json" +HANDLING = { + "valid": "terms", + "invalid_schedule": "prose_fallback", + "description_conflict": "conflict_probe", +} + + +def require(condition: bool, message: str) -> None: + """Reject a fixture invariant without relying on removable assertions.""" + if not condition: + raise ValueError(message) + + +@cache +def valid_json(serialized: str, definition: str) -> bool: + """Validate a policy or schedule, caching repeated mutation-test inputs.""" + if definition == "cancellation_schedule": + return schema_valid(json.loads(serialized)) + with tempfile.TemporaryDirectory(prefix="ucp-cancellation-lab-") as temp: + payload = Path(temp) / "policy.json" + payload.write_text(serialized, encoding="utf-8") + result = subprocess.run( + [ + "ucp-schema", + "validate", + str(payload), + "--schema", + str(SCHEMA), + "--def", + definition, + "--op", + "read", + "--response", + "--json", + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode not in {0, 1} or not result.stdout.strip(): + raise RuntimeError(result.stderr.strip() or "ucp-schema did not respond") + output = json.loads(result.stdout) + if type(output.get("valid")) is not bool: + raise RuntimeError(f"Unexpected schema result: {output}") + return output["valid"] + + +def valid(value: dict, definition: str = "cancellation_schedule") -> bool: + """Validate a JSON value using the repository's source schema.""" + return valid_json(json.dumps(value, sort_keys=True), definition) + + +def selected(schedule: dict, result: dict) -> dict | None: + """Resolve the oracle's test-only JSON Pointer, or return no outcome.""" + if result["status"] != "selected": + return None + value = {"schedule": schedule} + for part in result["pointer"].split("/")[1:]: + value = value[int(part)] if isinstance(value, list) else value[part] + return value + + +def check_penalty(penalty: dict, outcome: dict, currency: str) -> None: + """Forbid cash expectations without a resolved, unambiguous amount.""" + amount = None + if outcome["kind"] == "fixed_fee": + amount = outcome["penalty"]["amount"] + elif outcome == {"kind": "percentage", "buyer_bps": 10000}: + amount = 0 + if amount is not None: + require( + type(penalty.get("amount")) is int, "penalty amount must be integer" + ) + require( + penalty == {"status": "resolved", "amount": amount, "currency": currency}, + "resolved penalty differs from intended outcome or currency", + ) + else: + require(set(penalty) == {"status", "reason"}, "unresolved penalty has cash") + require(penalty["status"] == "not_resolved", "symbolic terms need no cash") + require( + isinstance(penalty["reason"], str) and penalty["reason"].strip(), + "unresolved penalty needs a reason", + ) + + +def check_fixture(fixture: dict, source: dict) -> None: + """Check one envelope, its wire values, and every held-out expected result.""" + category = fixture["category"] + require(category in HANDLING, "unknown category") + require(type(fixture["schema_valid"]) is bool, "schema_valid must be boolean") + policy, context = fixture["policy"], fixture["context"] + require( + set(policy) == {"type", "description", "refundability", "url", "schedule"}, + "policy must contain only the supplied protocol fields", + ) + require(set(policy["description"]) == {"plain"}, "description must be plain") + require( + isinstance(policy["description"]["plain"], str) + and policy["description"]["plain"].strip(), + "empty policy prose", + ) + require( + isinstance(policy["url"], str) + and re.fullmatch( + r"https://example\.com/cancellation-terms#p[0-9]{2}", policy["url"] + ), + "policy URL must be opaque and reveal no fixture category or answer", + ) + require(context["currency"] == "USD", "this fixture set uses USD only") + require( + type(context["stay_nights"]) is int and context["stay_nights"] > 0, + "stay_nights must be a positive integer", + ) + ZoneInfo(context["property_timezone"]) + schedule = policy["schedule"] + prose_only = { + key: value for key, value in policy.items() if key != "schedule" + } + require( + valid(prose_only, "cancellation_item"), "prose-only policy fails schema" + ) + wire_valid = valid(schedule) + require( + wire_valid == fixture["schema_valid"], "wire schema expectation mismatch" + ) + require( + valid(policy, "cancellation_item") == wire_valid, + "policy and schedule schema results differ", + ) + require( + ("reference_schedule" in fixture) == (category != "valid"), + "reference schedule belongs only to invalid/conflict fixtures", + ) + reference = fixture.get("reference_schedule", schedule) + require(valid(reference), "reference schedule fails schema") + generated = instant(context["response_generated_at"]) + anchor = instant(reference["anchor"]) + require( + instant(context["check_in"]) == anchor, "check-in differs from anchor" + ) + require(generated < anchor, "response must precede check-in") + snapshot = selected( + reference, evaluate(reference, context["response_generated_at"], True) + ) + classification = { + 10000: "refundable", + 0: "non_refundable", + }.get(snapshot.get("buyer_bps") if snapshot else None) + require( + classification is not None and policy["refundability"] == classification, + "snapshot classification mismatch or unsupported fixture snapshot", + ) + if "local_policy_example" in fixture: + example = next( + ( + item + for item in source["local_policy_examples"] + if item["id"] == fixture["local_policy_example"] + ), + None, + ) + require(example is not None, "unknown local-policy example") + require( + reference == source["schedules"][example["schedule"]]["value"], + "reference differs from published local-policy example", + ) + require( + context["property_timezone"] == example["timezone"], + "property timezone differs from local-policy example", + ) + evaluations = fixture["evaluations"] + require( + isinstance(evaluations, list) and len(evaluations) >= 2, + "each fixture needs multiple evaluation instants", + ) + ids = set() + divergent = False + for case in evaluations: + require( + isinstance(case["id"], str) and case["id"] and case["id"] not in ids, + "missing or duplicate evaluation id", + ) + ids.add(case["id"]) + require( + generated <= instant(case["at"]) < anchor, "evaluation outside scope" + ) + expected = case["expected"] + require( + set(expected) + == { + "schedule_result", + "selected_outcome", + "intended_outcome", + "penalty", + "handling", + }, + "expected fields mismatch", + ) + result = evaluate(schedule, case["at"], wire_valid) + require( + result == expected["schedule_result"], + f"{case['id']}: wire result mismatch", + ) + require( + selected(schedule, result) == expected["selected_outcome"], + f"{case['id']}: selected outcome mismatch", + ) + intended = selected(reference, evaluate(reference, case["at"], True)) + divergent |= intended != selected(schedule, result) + require( + intended is not None and intended == expected["intended_outcome"], + f"{case['id']}: intended outcome mismatch", + ) + require( + expected["handling"] == HANDLING[category], "handling category mismatch" + ) + require( + (result["status"] == "unavailable") == (category == "invalid_schedule"), + "invalid schedule must fall back; valid/conflict schedules must select", + ) + check_penalty(expected["penalty"], intended, context["currency"]) + if category == "description_conflict": + require(divergent, "conflict probe never differs from intended terms") + + +def validate(bundle: dict, source: dict) -> list[str]: + """Return actionable failures; never modify the supplied fixture bundle.""" + if not isinstance(bundle, dict): + return ["fixture bundle must be an object"] + failures = [] + if ( + type(bundle.get("format_version")) is not int + or bundle["format_version"] != 1 + ): + failures.append("format_version must be 1") + fixtures = bundle.get("fixtures", []) + if not isinstance(fixtures, list) or len(fixtures) != 10: + return failures + ["expected exactly ten fixtures"] + ids = set() + for number, fixture in enumerate(fixtures): + try: + name = fixture["id"] + require( + isinstance(name, str) and name and name not in ids, + "missing or duplicate fixture id", + ) + ids.add(name) + check_fixture(fixture, source) + except ( + KeyError, + IndexError, + TypeError, + ValueError, + AttributeError, + ) as error: + failures.append(f"fixture[{number}]: {error}") + return failures + + +def mutation_checks(bundle: dict, source: dict) -> tuple[list[str], int]: + """Ensure key expected-answer and context corruptions are rejected.""" + mutations = [ + (["fixtures", 1, "id"], bundle["fixtures"][0]["id"]), + (["fixtures", 0, "context", "currency"], "EUR"), + (["fixtures", 0, "context", "response_generated_at"], "not-a-time"), + (["fixtures", 0, "policy", "refundability"], "unknown"), + (["fixtures", 0, "policy", "url"], "https://example.com/invalid-schedule"), + (["fixtures", 0, "evaluations", 0, "expected", "selected_outcome"], None), + ( + ["fixtures", 0, "evaluations", 0, "expected", "penalty"], + {"status": "resolved", "amount": 999, "currency": "USD"}, + ), + ] + cases = [ + (["fixtures", fi, "evaluations", ci, "expected"], fixture, case["expected"]) + for fi, fixture in enumerate(bundle["fixtures"]) + for ci, case in enumerate(fixture["evaluations"]) + ] + path, _, _ = next( + item + for item in cases + if item[2]["intended_outcome"]["kind"] == "unit_deduction" + ) + mutations.append( + ( + path + ["penalty"], + {"status": "resolved", "amount": 15000, "currency": "USD"}, + ) + ) + path, _, expected = next( + item + for item in cases + if item[1]["category"] == "description_conflict" + and item[2]["selected_outcome"] != item[2]["intended_outcome"] + ) + mutations.append((path + ["intended_outcome"], expected["selected_outcome"])) + path, _, _ = next( + item for item in cases if item[1]["category"] == "invalid_schedule" + ) + mutations.append( + ( + path + ["schedule_result"], + {"status": "selected", "pointer": "/schedule/tiers/0/outcome"}, + ) + ) + failures = [] + for path, replacement in mutations: + mutant = copy.deepcopy(bundle) + target = mutant + for part in path[:-1]: + target = target[part] + target[path[-1]] = replacement + if not validate(mutant, source): + failures.append(f"mutation was not rejected: {path}") + return failures, len(mutations) + + +def main() -> int: + """Run fixture validation and negative checks with a concise summary.""" + if shutil.which("ucp-schema") is None: + print("ERROR: ucp-schema is required; no checks were run.") + return 1 + try: + bundle = json.loads(FIXTURES.read_text(encoding="utf-8")) + source = json.loads(SOURCE_FIXTURES.read_text(encoding="utf-8")) + failures = check_local_policy_examples(source) + validate(bundle, source) + if failures: + for failure in failures: + print(f"FAIL: {failure}") + print("Mutation checks not run because fixture validation failed.") + return 1 + failures, mutation_count = mutation_checks(bundle, source) + except (OSError, ValueError, RuntimeError) as error: + print(f"ERROR: {error}") + return 1 + for failure in failures: + print(f"FAIL: {failure}") + count = sum(len(item["evaluations"]) for item in bundle["fixtures"]) + print( + f"{len(bundle['fixtures'])} lab fixtures, {count} evaluation cases, " + f"{mutation_count} mutation checks, {len(failures)} failures" + ) + return int(bool(failures)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_cancellation_schedule.py b/scripts/test_cancellation_schedule.py new file mode 100755 index 000000000..7a3294e94 --- /dev/null +++ b/scripts/test_cancellation_schedule.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Run portable cancellation schedule vectors against a test-only oracle. + +Run: python3 scripts/test_cancellation_schedule.py +Requires ucp-schema on PATH and IANA timezone data for zoneinfo. +No third-party Python packages are required when system timezone data exists. + +These vectors check selection and fallback after a policy has been targeted. +The small oracle uses exact Fraction arithmetic and the specification's POSIX +time profile. It is not a production evaluator, does not resolve targeting or +interpret legal prose, and never calculates money or classifies refundability. +Its result envelope and JSON Pointers are test metadata, not protocol fields. + +Schema checks use ucp-schema. Calendar validity, normalized ordering, exact +boundaries, and unsupported selected kinds are separate semantic checks. +Local-policy examples separately check producer-side resolution of selected, +unambiguous wall-clock templates; they do not extend the wire representation. +""" + +import json +import re +import shutil +import subprocess +import sys +import tempfile +from datetime import date, datetime, timedelta, timezone +from fractions import Fraction +from pathlib import Path +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +ROOT = Path(__file__).resolve().parent.parent +FIXTURES = ROOT / "scripts/fixtures/lodging_cancellation_schedule.json" +SCHEMA = ROOT / "source/schemas/lodging/policy_cancellation.json" +TIMESTAMP = re.compile( + r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]" + r"([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]+))?" + r"([Zz]|[+-][0-9]{2}:[0-9]{2})" +) +DURATION = re.compile( + r"P(?:([0-9]+)D)?" + r"(?:T(?:([0-9]+)H)?(?:([0-9]+)M)?(?:([0-9]+)S)?)?" +) +KNOWN_KINDS = {"percentage", "fixed_fee", "unit_deduction"} + + +def instant(value: str) -> Fraction: + """Parse a fixture instant without rounding fractional seconds.""" + match = TIMESTAMP.fullmatch(value) + if match is None: + raise ValueError("Invalid timestamp syntax") + year, month, day, hour, minute, second = map(int, match.groups()[:6]) + if hour > 23 or minute > 59 or second > 59: + raise ValueError("Unsupported or invalid time") + ordinal = date(year, month, day).toordinal() + fraction, zone = match.groups()[6:] + offset = 0 + if zone not in {"Z", "z"}: + offset_hour, offset_minute = map(int, zone[1:].split(":")) + if offset_hour > 23 or offset_minute > 59: + raise ValueError("Invalid UTC offset") + offset = (offset_hour * 60 + offset_minute) * 60 + if zone[0] == "-": + offset = -offset + whole = ordinal * 86400 + hour * 3600 + minute * 60 + second - offset + part = Fraction(int(fraction), 10 ** len(fraction)) if fraction else 0 + return Fraction(whole) + part + + +def elapsed(value: str) -> int: + """Normalize a supported duration to exact whole elapsed seconds.""" + match = DURATION.fullmatch(value) + if match is None or not any(match.groups()): + raise ValueError("Invalid duration") + if "T" in value and not any(match.groups()[1:]): + raise ValueError("Empty time component") + return sum( + int(component or 0) * unit + for component, unit in zip( + match.groups(), (86400, 3600, 60, 1), strict=True + ) + ) + + +def schema_valid(schedule: dict) -> bool: + """Validate the whole wire schedule using the repository's schema tool.""" + with tempfile.TemporaryDirectory(prefix="ucp-cancellation-vector-") as temp: + payload = Path(temp) / "schedule.json" + payload.write_text(json.dumps(schedule), encoding="utf-8") + result = subprocess.run( + [ + "ucp-schema", + "validate", + str(payload), + "--schema", + str(SCHEMA), + "--def", + "cancellation_schedule", + "--op", + "read", + "--response", + "--json", + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode not in {0, 1} or not result.stdout.strip(): + raise RuntimeError(result.stderr.strip() or "ucp-schema did not respond") + output = json.loads(result.stdout) + if "valid" not in output: + raise RuntimeError(f"Unexpected schema result: {output}") + return output["valid"] + + +def unavailable(reason: str, pointer: str | None = None) -> dict: + """Build a test-only fallback result.""" + result = {"status": "unavailable", "reason": reason} + if pointer is not None: + result["pointer"] = pointer + return result + + +def evaluate(schedule: dict | None, at: str, valid: bool) -> dict: + """Select a wire outcome after validating the entire schedule.""" + if schedule is None: + return unavailable("schedule_absent") + if not valid: + return unavailable("invalid_schedule") + try: + anchor = instant(schedule["anchor"]) + durations = [elapsed(tier["until"]) for tier in schedule["tiers"]] + if any( + left <= right + for left, right in zip(durations, durations[1:], strict=False) + ): + return unavailable("invalid_schedule") + except ValueError: + return unavailable("invalid_schedule") + try: + when = instant(at) + except ValueError: + return unavailable("invalid_instant") + pointer = "/schedule/after_last_tier" + selected = schedule["after_last_tier"] + for index, duration in enumerate(durations): + if when < anchor - duration: + pointer = f"/schedule/tiers/{index}/outcome" + selected = schedule["tiers"][index]["outcome"] + break + if selected["kind"] not in KNOWN_KINDS: + return unavailable("unsupported_kind", pointer) + return {"status": "selected", "pointer": pointer} + + +def local_example_instant(value: str, zone: ZoneInfo) -> datetime: + """Resolve only unambiguous, whole-second local times in test examples.""" + local = datetime.fromisoformat(value) + if local.tzinfo is not None or local.microsecond: + raise ValueError("Example must use a naive, whole-second local time") + aware = local.replace(tzinfo=zone) + if aware.utcoffset() != aware.replace(fold=1).utcoffset(): + raise ValueError("Ambiguous or nonexistent local example time") + round_trip = aware.astimezone(timezone.utc).astimezone(zone) + if round_trip.replace(tzinfo=None) != local: + raise ValueError("Nonexistent local example time") + return aware + + +def check_local_policy_examples(fixtures: dict) -> list[str]: + """Check chosen local templates independently of elapsed-time selection.""" + failures = [] + names = set() + epoch = instant("1970-01-01T00:00:00Z") + for example in fixtures.get("local_policy_examples", []): + name = example["id"] + if name in names: + failures.append(f"duplicate local-policy example id: {name}") + names.add(name) + try: + zone = ZoneInfo(example["timezone"]) + anchor = local_example_instant(example["check_in_local"], zone) + cutoff_date = anchor.date() - timedelta(days=example["days_before"]) + cutoff = local_example_instant( + f"{cutoff_date.isoformat()}T{example['cutoff_local_time']}", zone + ) + anchor_instant = instant(anchor.isoformat()) + cutoff_instant = instant(cutoff.isoformat()) + actual = { + "anchor_epoch": anchor_instant - epoch, + "cutoff_epoch": cutoff_instant - epoch, + "elapsed_seconds": anchor_instant - cutoff_instant, + } + if actual != example["expected"]: + failures.append(f"local_policy[{name}]: got {actual}") + schedule = fixtures["schedules"][example["schedule"]]["value"] + if instant(schedule["anchor"]) != anchor_instant: + failures.append(f"local_policy[{name}]: wire anchor mismatch") + if elapsed(schedule["tiers"][0]["until"]) != actual["elapsed_seconds"]: + failures.append(f"local_policy[{name}]: wire duration mismatch") + except (ValueError, ZoneInfoNotFoundError) as error: + failures.append(f"local_policy[{name}]: {error}") + return failures + + +def main() -> int: + """Check every fixture and return a nonzero status on any failure.""" + if shutil.which("ucp-schema") is None: + print("ERROR: ucp-schema is required; no checks were run.") + return 1 + fixtures = json.loads(FIXTURES.read_text(encoding="utf-8")) + results = {} + failures = check_local_policy_examples(fixtures) + schema_checks = 0 + outcome_checks = 0 + for name, entry in fixtures["schedules"].items(): + results[name] = schema_valid(entry["value"]) + if "schema_valid" in entry: + schema_checks += 1 + if results[name] != entry["schema_valid"]: + failures.append(f"schema[{name}]: got {results[name]}") + names = set() + for case in fixtures["cases"]: + if case["id"] in names: + failures.append(f"duplicate case id: {case['id']}") + names.add(case["id"]) + name = case["schedule"] + schedule = fixtures["schedules"][name]["value"] if name else None + actual = evaluate(schedule, case["at"], results[name] if name else True) + if actual != case["expected"]: + failures.append( + f"{case['id']}: expected {case['expected']}, got {actual}" + ) + if "expected_outcome" in case: + outcome_checks += 1 + if actual["status"] != "selected": + failures.append(f"{case['id']}: no selected outcome to compare") + continue + selected = {"schedule": schedule} + for part in actual["pointer"].split("/")[1:]: + selected = ( + selected[int(part)] if isinstance(selected, list) else selected[part] + ) + if selected != case["expected_outcome"]: + failures.append(f"{case['id']}: selected outcome mismatch: {selected}") + for failure in failures: + print(f"FAIL: {failure}") + print( + f"{len(fixtures['cases'])} selection/fallback vectors, " + f"{schema_checks} schema expectations, " + f"{len(fixtures.get('local_policy_examples', []))} local-policy examples, " + f"{outcome_checks} outcome assertions, {len(failures)} failures" + ) + return int(bool(failures)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/source/schemas/lodging/policy_cancellation.json b/source/schemas/lodging/policy_cancellation.json index b0fd5579b..c37809c61 100644 --- a/source/schemas/lodging/policy_cancellation.json +++ b/source/schemas/lodging/policy_cancellation.json @@ -5,6 +5,180 @@ "title": "Lodging Cancellation Policy Extension", "description": "Extends Booking with structured cancellation policy for lodging reservations.", "$defs": { + "cancellation_outcome": { + "title": "Cancellation Outcome", + "description": "The declared economic result of a buyer-initiated cancellation. `kind` is an open vocabulary. For well-known kinds, a Business MUST emit only the fields defined for that kind, and a Platform evaluates only those fields and ignores unrelated outcome members. Platforms encountering an unknown kind MUST tolerate it, MUST NOT infer its meaning, and MUST use the policy description as fallback.", + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "minLength": 1, + "description": "Open outcome discriminator. Well-known values: `percentage`, `fixed_fee`, and `unit_deduction`. Additional values SHOULD use a reverse-domain identifier. Platforms MUST tolerate unknown values and MUST NOT infer their meaning.", + "examples": [ + "percentage", + "fixed_fee", + "unit_deduction" + ] + }, + "buyer_bps": { + "type": "integer", + "minimum": 0, + "maximum": 10000, + "description": "Business-stated refund percentage under this policy, expressed in basis points. `0` means no refund and `10000` means a full refund. This version does not identify a monetary basis; a Platform MUST NOT convert the percentage to money unless the governing Booking data supplies an unambiguous basis." + }, + "penalty": { + "title": "Cancellation Penalty", + "description": "Cancellation penalty stated by the Business for the governed scope, regardless of whether it is retained from an earlier payment or charged later. Policy targeting alone does not define charge multiplicity across room rates or nights. The fields required within this object depend on the outcome `kind`.", + "type": "object", + "properties": { + "amount": { + "$ref": "../common/types/amount.json", + "description": "Fixed cancellation charge in the minor units of the root `currency` field of the Booking response." + }, + "measure": { + "description": "Positive whole lodging-unit cancellation penalty expressed with the shared UCP measure representation. `scale` is fixed at `0`; `night` is the well-known lodging unit for this policy.", + "allOf": [ + { + "$ref": "../common/types/measure.json" + }, + { + "type": "object", + "properties": { + "value": { + "minimum": 1 + }, + "scale": { + "const": 0 + } + } + } + ] + } + } + } + }, + "allOf": [ + { + "if": { + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "percentage" + } + } + }, + "then": { + "required": [ + "buyer_bps" + ] + } + }, + { + "if": { + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "fixed_fee" + } + } + }, + "then": { + "required": [ + "penalty" + ], + "properties": { + "penalty": { + "required": [ + "amount" + ] + } + } + } + }, + { + "if": { + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "unit_deduction" + } + } + }, + "then": { + "required": [ + "penalty" + ], + "properties": { + "penalty": { + "required": [ + "measure" + ] + } + } + } + } + ] + }, + "cancellation_tier": { + "title": "Cancellation Tier", + "description": "An outcome that applies before one anchor-relative cutoff. Tiers are evaluated in array order; the first tier whose cutoff is strictly later than the evaluation instant applies.", + "type": "object", + "required": [ + "until", + "outcome" + ], + "properties": { + "until": { + "type": "string", + "pattern": "^P(?=[0-9]+D|T[0-9])(?:[0-9]+D)?(?:T(?=[0-9])(?:[0-9]+H)?(?:[0-9]+M)?(?:[0-9]+S)?)?(?![\\s\\S])", + "description": "ISO 8601 elapsed duration before the schedule anchor. This version permits only nonnegative whole-number days, hours, minutes, and seconds; one day is exactly 24 elapsed hours. Years, months, weeks, fractional values, and negative durations are not supported. Implementations compare durations after exact normalization to elapsed seconds.", + "maxLength": 64 + }, + "outcome": { + "$ref": "#/$defs/cancellation_outcome", + "description": "Cancellation outcome that applies when the evaluation instant is strictly earlier than `anchor - until`, after all earlier tiers have failed to match." + } + } + }, + "cancellation_schedule": { + "title": "Cancellation Schedule", + "description": "Deterministic buyer-initiated cancellation terms expressed as ordered elapsed-time cutoffs relative to an absolute anchor. Tiers MUST be ordered from the farthest cutoff before the anchor to the nearest, with strictly decreasing normalized elapsed durations and no duplicate cutoff. Equivalent forms such as `P1D` and `PT24H` denote the same cutoff. At exactly a cutoff, evaluation advances to the next tier; when no tier matches, `after_last_tier` applies.", + "type": "object", + "required": [ + "anchor", + "tiers", + "after_last_tier" + ], + "properties": { + "anchor": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])[Tt](?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]+)?(?:[Zz]|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])(?![\\s\\S])", + "description": "Calendar-valid RFC 3339 instant with `Z` or an explicit numeric UTC offset. Schedule arithmetic uses Unix time, with exactly 86400 seconds per day and no leap seconds; second values of `60` are unsupported. Implementations MUST check calendar validity even when schema format assertions are disabled. For lodging this normally represents the property's stated arrival or check-in cutoff." + }, + "tiers": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/cancellation_tier" + }, + "description": "Ordered cancellation tiers, from the farthest cutoff before `anchor` to the nearest. Ordering and uniqueness are evaluated after exact normalization to elapsed seconds because JSON Schema cannot compare duration values across array entries." + }, + "after_last_tier": { + "$ref": "#/$defs/cancellation_outcome", + "description": "Outcome at or after the nearest cutoff, including at the anchor and afterward." + } + } + }, "cancellation_item": { "allOf": [ { @@ -24,12 +198,16 @@ }, "refundability": { "type": "string", - "description": "High-level refundability classification. Well-known values: `refundable` (free cancellation currently available), `partially_refundable` (cancellable with penalty, or inside penalty window), `non_refundable` (no refund upon cancellation).", + "description": "High-level refundability classification. Well-known values: `refundable` (free cancellation currently available), `partially_refundable` (the current cancellation result is strictly between a full refund and no refund), `non_refundable` (no refund upon cancellation). Platforms MUST tolerate unknown values and use `description` when they cannot interpret the classification.", "examples": [ "refundable", "partially_refundable", "non_refundable" ] + }, + "schedule": { + "$ref": "#/$defs/cancellation_schedule", + "description": "Optional deterministic detail for buyer-initiated cancellation. It MUST agree with the human-readable `description`. The outcome applicable when the response is created MUST agree with the point-in-time `refundability` summary; crossing a cutoff later does not make the earlier snapshot contradictory." } } }