# Reservation delivery — implementation preview and release blockers

**Branch-local implementation preview, not a shipped or certified API.** The current worktree implements a bounded outbound event outbox, pull feed, acknowledgement, and an operator-reviewed property activation behind a default-off environment gate. Property and ARI uploads alone remain non-bookable. The reservation routes are intentionally absent from the released OpenAPI and Postman collection until the end-to-end reservation/inventory, financial, privacy, and activation gates pass. An authorized first-client test can use the separate `reservation-preview-openapi.yaml`, `postman/reservation-preview.postman_collection.json`, and `reservation-preview-error-matrix.json` from the checkout or operator-only downloads. Public publication remains on hold; anonymous users and ordinary accounts cannot download these artifacts or the reservation protocol scripts. The Postman pull request never advances its cursor and neither acknowledgement request runs automatically; the receiver must prove durable inbox storage and application itself. A locally passing endpoint test or a true preview capability flag is not authorization to route live bookings.

## Implemented preview contract

`GET /api/v1/supply/reservations/events?after_sequence=0&limit=100` requires a credential with `supply:reservations:read` on an approved or sales-suspended connection. `POST /api/v1/supply/reservations/events/{event_id}/ack` requires `supply:reservations:ack` under the same account rule. Existing `supply:read` and `supply:write` do **not** imply either permission. New credentials default to content read/write only; inspect `granted_scopes` in `GET /connection` and explicitly request a dedicated, least-privilege reservation key. A reservation-only key can inspect its own connection during a sales suspension, but cannot read property content or ARI. Both endpoints inherit Supply Bearer authentication, the 1 MiB JSON-body limit, `Cache-Control: no-store`, RFC 9457-style errors, and redacted request logging. A connection's `suspended` state stops new sales and content/ARI access but preserves pull/ack for already-committed booking obligations; a revoked connection, suspended partner, or revoked/expired credential cannot access the feed. For credential compromise, revoke or rotate the credential and coordinate a replacement with OutReserve rather than relying on a sales suspension as a security boundary.

`after_sequence` is the last **durably stored read position** in the connection-wide feed, defaults to `0`, and must not exceed the latest published sequence. `limit` defaults to `100` and accepts `1..100`. A cursor beyond the head returns `reservation_cursor_ahead` with numeric `context.latest_published_sequence` and `context.submitted_sequence`; do not parse the localized message. The feed is ordered and replayable while protected guest data is retained. `next_sequence` is the last event returned, **not an acknowledgement**. A receiver may fetch the next page before applying all events only after it has durably stored each event in its own protected inbox together with the read position; otherwise it must replay from its earlier persisted position. An older position replays events by `event_id` unless the requested page crosses a manually erased guest payload; then the entire page returns `409 reservation_cursor_expired` and requires supervised reconciliation, never an implicit skip. Supplying `after_sequence` at or beyond an erased event also returns `reservation_cursor_expired` unless OutReserve has recorded a supervised checkpoint for **each** erased event at or before that cursor. A missing committed event in the sampled portfolio sequence similarly fails the entire page with `409 reservation_stream_gap` and typed expected/head sequence values; it never returns later events as if the missing event had been delivered. Stop polling and contact OutReserve for supervised reconciliation. Non-personal event history remains. An automatic retention floor and erasure schedule are not yet implemented.

An operator can record a **one-event cursor checkpoint** in the Supply Workbench for an already acknowledged and erased event while that property's reservation outlet is suspended. This requires exact `from_sequence = event.sequence - 1` and `to_sequence = event.sequence`, a persisted platform administrator, uppercase bounded incident/partner/inventory-reconciliation case IDs, an external PMS ledger manifest SHA-256 digest, and explicit confirmation that the partner's durable ledger and inventory were reconciled. The operator must verify the evidence outside the API; a digest alone does not prove PMS application. The checkpoint is unique and immutable per event, account-scoped, and audited without guest data. It does **not** recreate erased guest content, acknowledge an event, alter any reservation or inventory cursor, or open sales. The partner must durably store the authorized new read position before polling with `after_sequence=to_sequence`. Polling from before the erased event still fails closed, even after the checkpoint: there is no automatic skip or truncated payload. If multiple erased events precede the chosen cursor, each requires its own checkpoint. A pre-existing partner read position beyond an event may temporarily fail after erasure until the operator completes this reconciliation; this is intentional, including when that event had previously been acknowledged. Coordinate the durable partner position carefully rather than treating a caller-supplied cursor as proof of application. This preview does not yet enforce a server-owned cursor or prove the partner's inbox is durable for **non-erased** events.

An applied acknowledgement body is `{"ack":{"sequence":1843,"result":"applied","partner_reservation_id":"PMS-20881","partner_revision":"7"}}`. `partner_reservation_id` is required, `partner_revision` is optional. Both must be strings of at most 255 bytes. The event sequence must be a JSON integer equal to the path event's sequence. An exact retry returns the same logical success; a changed partner identity/revision on that retry returns `409 acknowledgement_conflict`. A modification or cancellation must use the same `partner_reservation_id` as the preceding applied revision; a different ID returns `409 reservation_partner_identity_mismatch` with pointer `/ack/partner_reservation_id`, without advancing the property's applied cursor. Only the next unacknowledged event **for that property** can be applied (`409 reservation_predecessor_unacknowledged` otherwise); its numeric `context` identifies the property's last applied, predecessor, and submitted sequences. The acknowledgement and property application cursor advance atomically. Another property's rejected event does not prevent application or sales on this property. Receipt without durable PMS application must not be acknowledged.

The optional revision is compared without coercion on retry: an omitted `partner_revision` is different from `"partner_revision":""`. Persist and resend the original acknowledgement fields verbatim.

A semantic rejection uses the same endpoint with `{"ack":{"sequence":1843,"result":"rejected","rejection_code":"unknown_unit","rejection_detail":"Mapping not found"}}`. It returns `status: rejected`, records the failure in a redacted event audit, immediately suspends new sales for that property, and **does not** advance its application cursor. A matching retry repeats that result; an applied acknowledgement of the rejected event conflicts until an OutReserve operator reviews partner remediation and explicitly requeues the same immutable event in the Supply Workbench. That action requires safe incident, partner-confirmation, and inventory-reconciliation references plus explicit confirmation; it does not clear the rejection history, advance the cursor, or reopen sales. The partner must replay from before the event sequence and durably apply/acknowledge it. OutReserve must then separately review a fresh ARI backfill and reservation reactivation. An unrelated property on the same connection remains independent.

The branch also schedules `supply:alert_reservation_feed_lag` every five minutes. Once the oldest unacknowledged event for an activated outlet is at least 15 minutes old, a bounded task emits a guest-free error log and Sentry message with the property link, oldest event UUID/sequence, latest published sequence, and last applied sequence. It records the latest sequence included in that alert to avoid repeat pages for the same backlog; a subsequent publication can trigger a new alert after the age threshold. Sales are already fail-closed while the feed is behind, but an alert is **not** an acknowledgement or partner retry. Operators should compare the Workbench timeline, the PMS durable inbox, the affected property's cursor, and ARI watermarks, then follow the rejection/requeue or cursor-recovery path as appropriate. Fifteen minutes is a provisional operational threshold, not a certified partner SLA. The scheduler, alert routing, on-call acknowledgement, and high-volume backlog behavior still require deployed validation.

The outbox reuses `pms_booking_deliveries` with `provider=Supply`; these rows are excluded from the existing provider-push dispatcher. Publishing a booking event must occur inside the booking mutation's database transaction. A dedicated row-lock-protected counter per connection allocates sequence without rollback gaps, without taking an exclusive lock on the connection foreign-key parent; the immutable snapshot and signed unit/day allocation changes commit with it. A day-use unit includes its departure date in `inventory_dates`; an overnight unit does not. `new`, `modified`, and `cancelled` snapshots produce +1, -old/+new, and -1 demand movements. Cross-property reservation reassignment is unsupported in this preview and must be rejected by booking admission.

The frozen snapshot now includes `financial` version 1 from the booking's persisted pricing lock, `cancellation_terms` from the booking-owned policy snapshot, and `guests_count` as the explicitly supplied total number of people. `quantity: 1` remains the number of booked inventory units, not people. New Supply bookings require a positive integer `guests_count` no greater than the unit's positive integral `max_occupants`; existing bookings with no recorded party size remain unknown and are not backfilled. Mobile quotes bind `guests_count` to the accepted quote; web instant and request checkouts collect it when the Supply booking outlet is active. Accepted amendments freeze the booking's count and recheck it against the requested unit at submission and application, including after payment capture; a capacity loss reconciles the captured payment rather than publishing an over-capacity revision. Publication fails closed if party size, pricing lock, or policy snapshot is missing or inconsistent. The financial object contains the ISO currency and integer minor-unit OutReserve accommodation, tax, fee, discount, donation, and per-inventory-date nightly amounts. A locked nightly rate must also be numeric, nonnegative, and exactly representable in currency minor units; a corrupt value stops publication with `reservation_nightly_rates_missing`. `outreserve_charge_excluding_donation_minor` excludes the separately reported donation; partners must not infer their own settlement or payout from these guest-facing amounts. The current booking tax calculator yields zero, so this preview must not be interpreted as a certified tax-collection contract. Cancellation terms include `cancellation_policy`, `guest_cancellation_mode`, `guest_cancellation_window_hours`, and the applicable optional fee fields. The object is a frozen policy input, **not** a computed refund quote. The total-person count does not distinguish adults and children or support occupancy-tier prices. Payment-guarantee semantics and provider acceptance SLA also remain uncertified; a PMS/channel manager must not treat this branch-local shape as production certification. Guest/contact fields are encrypted separately from the JSON allocation snapshot. An administrator can now erase one acknowledged event's ciphertext from the active database without deleting its non-personal history, and old feed pages fail closed afterward. This is **not** a retention policy or backup erasure: ciphertext still uses an application-wide key until removal, old backups may retain it, and no scheduled purge runs. Define a lawful retention interval, backup/key-destruction policy, and supervised replay-after-expiry workflow before transmitting live guest data.

The code now validates and stores a unit/date inventory-incorporation watermark and computes a bounded net-inventory readback. Operator activation/suspension, rejected-event requeue, and supervised one-event erased-cursor checkpoint paths exist in the Workbench, and the connection/property/unit capability fields report the gated outlet state. These are not certification or a date-specific quote. There are no webhook hints, automatic guest-retention schedule, released reservation OpenAPI/Postman entries, certified client, or live partner cutover. The [local reservation examples](examples/README.md#reservation-delivery-preview) demonstrate protocol calls but do not constitute a production PMS adapter. The rest of this document states the intended end-to-end design and remains a release gate where it exceeds the implemented preview.

The direction of this proposed reservation feed is **OutReserve → PMS/channel manager** for bookings created or revised through OutReserve. Reservations accepted by the provider through its other channels are not imported as OutReserve guest bookings in Supply v1; their inventory effect belongs in the partner's absolute net `available_count`. A partner cannot silently amend or cancel an OutReserve booking through the Supply API. Any partner-originated correction, reassignment, or cancellation needs an explicit versioned and supervised contract with ownership checks, guest communication, and audit before it is permitted.

## Delivery invariant and source of truth

OutReserve must commit one immutable reservation revision to the durable delivery ledger in the same transaction as the booking change. An outbound event is never assembled later from mutable booking rows. Existing `Pms::BookingDelivery` provides the foundation: immutable `payload`, unique `idempotency_key`, `new`/`modified`/`cancelled` operations, `pending`/`processing`/`retrying`/`succeeded`/`failed` states, predecessor ordering, and stale-dispatch recovery. Supply should extend that outbox lifecycle, not introduce a second source of truth. The current provider-specific payload and integer row ID are **not** the public event contract.

The connection owns a monotonic, gap-free `sequence` for every committed event. Allocate it by locking a per-connection counter *inside the booking/outbox transaction*; an ordinary database auto-increment alone can leave gaps after rollback and make portfolio pagination ambiguous. Each property separately records its latest published and latest durably applied connection sequence; the latter may skip numbers belonging to other properties. Each event has a UUID `event_id`, a stable `reservation_id` UUID, a per-reservation `revision` starting at 1, and an immutable `snapshot`. Its target connection, property, operation, sequence, and snapshot cannot change after commit. A migration requires an operator-reviewed new mapping, never retargeting an existing event. A new revision cannot overtake an unacknowledged predecessor. A cancellation may supersede a permanently failed modification only under an explicit audited recovery rule; it cannot erase failed history.

## Proposed feed

`GET /api/v1/supply/reservations/events?after_sequence=1842&limit=100` requires `supply:reservations:read` and an approved or sales-suspended connection in the implementation preview. `after_sequence` is the last durably stored portfolio read position, default `0` for first pull; `limit` is 1–100. Results are ascending and stable across retries:

```json
{
  "data": {
    "events": [{
      "event_id": "1f95c3a0-3924-45d9-84de-b9cfe342e98a",
      "sequence": 1843,
      "reservation_id": "eaa0e502-967e-43b7-aacd-5a9cb38129c9",
      "revision": 2,
      "operation": "modified",
      "occurred_at": "2026-10-03T14:02:00Z",
      "property_id": "1ae3dfca-068a-49a8-9bd6-1b834d6ab37b",
      "snapshot": {
        "status": "confirmed",
        "unit_id": "d2c9f2a5-9bc0-4eb9-b9dc-88aee617808d",
        "arrival_date": "2026-10-15",
        "departure_date": "2026-10-18",
        "inventory_dates": ["2026-10-15", "2026-10-16", "2026-10-17"],
        "quantity": 1,
        "guests_count": 2,
        "previous_allocation": {"unit_id": "d2c9f2a5-9bc0-4eb9-b9dc-88aee617808d", "inventory_dates": ["2026-10-16", "2026-10-17"], "quantity": 1},
        "financial": {
          "version": 1,
          "currency": "USD",
          "outreserve_charge_excluding_donation_minor": 50500,
          "accommodation_minor": 50000,
          "taxes_minor": 0,
          "fees_minor": 1000,
          "discount_minor": 500,
          "donation_minor": 0,
          "nightly_rates_minor": {"2026-10-15": 18500, "2026-10-16": 18500, "2026-10-17": 13000}
        },
        "cancellation_terms": {
          "cancellation_policy": "Full refund before the stated window",
          "guest_cancellation_mode": "refund_before_window",
          "guest_cancellation_window_hours": 48
        },
        "guest": {"given_name": "Example", "family_name": "Guest"},
        "contact": {"email": "guest@example.invalid"}
      }
    }],
    "next_sequence": 1843,
    "has_more": false
  }
}
```

`new`, `modified`, and `cancelled` are the only operations in this proposal. Every operation carries a *complete* post-change snapshot, not a partial diff. A cancellation has `status: "cancelled"` and its previously allocated unit, dates, and quantity so release is idempotent. A modification has `previous_allocation` so the PMS can atomically release old inventory and reserve new inventory. The financial, cancellation, and total-person fields are copied from the booking's frozen records, including on amendments; a missing or mismatched source blocks publication instead of omitting them. The example values above illustrate structure only; they are not a quotation or guaranteed cancellation-policy enum. The exact guest/contact minimum and any adult/child breakdown still require certification. No card data, government ID, free-text notes, or unnecessary payment details enter the feed. Personal fields should live in a separately encrypted payload with a documented operational retention period; expiry destroys or redacts that protected payload while immutable non-PII event identifiers, allocation facts, hashes, and audit records remain. Replay after PII expiry requires supervised recovery rather than silently sending a truncated event.

The feed needs consistent pagination while events arrive, bounded cursor replay, and a machine-readable `cursor_expired` response when history has aged out. An expired cursor stops bookability and triggers supervised full reconciliation; silently skipping events is forbidden.

Optional webhooks may notify a partner that the feed advanced, but are hints only: receivers always pull from the durable ordered feed, deduplicate, and periodically poll as a backup. They must never apply a webhook payload as the reservation source of truth or assume callback order. This follows the failure mode called out in [Channex's webhook guidance](https://docs.channex.io/api-v.1-documentation/webhook-collection) and its [PMS integration guidance](https://channex.io/start-integration); it is a proposed Supply design rule, not a shipped webhook feature.

## Proposed acknowledgement and replay behavior

`POST /api/v1/supply/reservations/events/{event_id}/ack` with `{ "ack": { "sequence": 1843, "result": "applied", "partner_reservation_id": "PMS-20881", "partner_revision": "7" } }` records *durable application* in the PMS, not receipt alone. A success is `{ "data": { "event_id": "...", "sequence": 1843, "status": "acknowledged", "acknowledged_at": "..." } }`. An exact duplicate ack returns the same logical success. A conflicting partner ID/revision or skipped predecessor is `409` with a specific problem code; a transient PMS failure remains unacknowledged and is retried. A semantic rejection is structured and visible to operators, and fails closed until resolution; it is not an acknowledgement.

Partners must deduplicate by `event_id` and retain `reservation_id` + `revision`. Replay must not create a second reservation, and an older revision must not overwrite a newer one. In the preview, partners poll; OutReserve does **not** push/redeliver these events with an exponential-backoff worker. The portfolio read cursor may move independently of application, but each property's applied cursor never advances past its own gap. After a semantic rejection, an operator may requeue only the first unresolved event following documented remediation. The partner must poll with `after_sequence` no greater than that event's sequence minus one; its durable inbox must deduplicate the same `event_id`. The outlet remains suspended after acknowledgement until a separate reviewed reactivation with fresh ARI succeeds.

## Inventory-applied watermark handshake

Transport ack alone does not prove an ARI count includes a booking. `Pms::Newbook::InventoryGuard` demonstrates the safety rule: provider inventory is evaluated with locally consumed unpaid holds and confirmations after `inventory_received_at`, and a locked final capacity check. Supply Phase 2 must provide the same protection for physical and pooled units, including amendments and cancellations.

**Unit-scoped** ARI entries now require `incorporated_reservation_sequence` beside each absolute `available_count`. It means the count for **each expanded unit/date row in that entry** includes all OutReserve reservation allocation changes for that property through the given connection-wide sequence. The value cannot exceed the property's applied cursor; unrelated properties may have intervening sequence numbers. The persisted watermark belongs to each unit/date inventory row, not merely to a property or request. Partial batches cannot claim incorporation for dates they did not update. A separate atomic complete-property snapshot mode could be designed later, but must prove coverage before using one snapshot-level watermark. The same explicit count write also refreshes `inventory_received_at`, including an unchanged numerical count; rate/restriction-only writes cannot refresh inventory freshness.

The ARI write boundary checks the claimed sequence is no greater than that property's applied cursor and is nondecreasing for every affected unit/date. The count, watermark, and receipt timestamp commit in the same transaction as the calendar revision. A rate-only or property-level update cannot advance inventory incorporation. The server cannot independently prove a partner's count actually includes all acknowledged changes merely from its claim; partner certification and active reconciliation must establish that operational guarantee before booking activation. A booking moving between properties requires coordinated reconciliation of both properties' affected unit/dates, or both remain closed.

For each unit/date, effective sellable inventory is reported count minus OutReserve-local demand whose reservation event sequence exceeds that row's incorporated watermark (plus active unpaid holds), clamped at zero. Demand whose event sequence is covered by that row's watermark is not subtracted again. Missing data, expired freshness, event backlog, failed delivery, or inconsistent watermarks close sales. Final checkout locks relevant inventory/demand and rechecks the complete stay. Property stop sell always overrides counts.

## State machine and activation gate

The proposed connection lifecycle is `draft → certified_nonbookable → bookable`, with `suspended` and `reconciliation_required` fail-closed states. Property approval and publication do not grant bookability. A bookable property needs an approved connection, mapped units, complete fresh ARI over its marketed horizon, active reservation delivery, zero event gap, and verified unit/date incorporation watermarks. An OutReserve operator explicitly activates it and can suspend it independently of publication. A partner semantic rejection now suspends only its property outlet automatically; reviewed event requeue and a later applied ack do not resume that outlet.

Persisted activation proof must contain all six bounded, non-personal case references (`reference`, `mapping`, `delivery`, `pricing`, `privacy`, and `reconciliation`) and an integer `horizon_days` from 1 through 730, with no extra fields. Model validation and booking-readiness checks enforce that shape independently of the Workbench action. A sparse historical or manually written record cannot open sales. If an active record has incomplete proof, suspend it with a safe incident case reference, then complete a fresh reviewed activation with current ARI and complete evidence; do not repair it by flipping status or adding a general reference directly.

After a property is active, a newly created unit stays `pending`, uses `supply_pending`, and has its stop sell enabled. The partner must backfill fresh count and rate proof for that unit across the property's existing full sellable horizon. An operator then selects that exact unit in the Supply Workbench, records fresh mapping, delivery, pricing, privacy, and reconciliation case IDs (bounded identifiers without free text or personal data), and explicitly confirms opening its stop sell. Publication rechecks the active outlet, ownership, property feed cursor, pricing rules, and every unit/date under the same property-link lock used by ARI writes. It changes only the selected unit to `active` / `instant_book` with stop sell cleared and writes a unit-level audit event containing the safe case IDs; it does not change the property's activation evidence, horizon, or any other unit. The action is unavailable while the reservation activation deployment gate is off. A missing day, stale count, absent rate, feed lag, invalid mapping, suspended outlet, or missing confirmation leaves the unit staged and closed. This is an internal operator action, not a partner API status transition.

Certification must exercise create, duplicate replay, modification of dates and unit, cancellation after success and transient failure, out-of-order/gap handling, worker crash/recovery, cursor expiration, stale inventory after booking, pooled capacity, payment holds, timezone/day-use boundaries, and concurrent locked checkout. It must demonstrate no oversell or lost/duplicate reservation, measured end-to-end latency, and a tested manual reconciliation runbook. The private preview may advertise `reservations: true` when the deployment gate allows the feed and `booking_ready: true` when an operator has activated at least one property. Those flags report current runtime state, **not** partner certification or public release. Only after certification should reservation operations enter released OpenAPI/Postman.
