0042. Move stays in Planning and split a stay across rooms
- Status: Proposed for owner review
- Date: 2026-10-29
Observable acceptance criteria
Section titled “Observable acceptance criteria”A front-desk user opens Planning and drags an expected stay’s bar to another room of the same type, to a room of another type or to other dates of the same length. Before anything is saved, a dialog shows what changes: room, room type (as an upgrade or downgrade), dates and, for new dates, the price kept or re-quoted with its difference. The user confirms, the bar shows the new position, and a reload shows the same persisted state. The same move is available without dragging: Enter on a bar opens the stay, and Move… offers room and arrival fields with the same preview and confirmation.
Split… divides a stay at a night: the nights from that night to the end of the room the guest occupies then go to another room. Planning shows one bar per room, all of one reservation, and the split survives a reload. An in-house guest is moved from tonight (see Dates); earlier nights stay in the room they were spent in, and the room left is marked dirty. Channel (OTA) stays can change rooms, including upgrades, but not dates. A viewer can inspect but not drag and gets no move or split action; the API refuses them with 403. Moving into a room that another stay holds shows a field-linked error naming the holding reservation; the user chooses another room and the move succeeds. A stale version offers a reload. Invalid nights are field errors. Two concurrent moves into the same room produce one success and one 409; two writers of one reservation produce one success and one 412.
Context
Section titled “Context”ADR 0024 separates the reservation room (sold capacity: booked type, dates, frozen nights, plan and terms) from stay segments (physical assignment), but kept one segment per room and refused upgrades and mid-stay moves. ADR 0025 left moving bars and split actions planned. The domain chapter of the analysis sets the rules adopted here: each booked room has one or more segments; a room move during a stay closes one segment and opens another, even for a different room type; capacity use follows the assigned unit, while the price and commercial reference stay those sold, and both room types are republished. The interface chapter sets the drag rules: OTA dates and room type cannot be dragged, the unit can; every drag has a non-drag alternative; the server is the only source of truth and a refused move returns the bar to its place with an explanation.
Decision
Section titled “Decision”A split is modelled as segments of one reservation room, not as a new reservation room.
A new room would double-count arrivals, departures and rooms per booking, break the OTA
booking_room_id identity used by later revisions, and post folio reversals for an
operational change. Segments keep all commercial facts on the one room.
- An assigned room has one or more consecutive, non-overlapping segments that together cover
[arrival, departure); an unassigned room has none. Adjacent segments always have different units: a move back to the previous room merges them. All segments of a live stay are active; check-out, no-show and cancellation make them all inactive history, as before. - Each segment records the room type whose capacity its nights consume (
room_type_id), which is its unit’s type when assigned. Each sold night consumes the capacity of the type of the unit assigned to it, or of the booked type when unassigned. A unit of another type is an upgrade (or downgrade): the booked type, plan, nightly prices and policy snapshot stay those sold; only the inventory ledger moves between types, in the same transaction, and both types receive ARI availability changes through the existing outbox and booking barrier. Moving back is subject to the same availability check. A same-type room change leaves the ledger and ARI untouched. - Migration
0022_stay_segment_moves(contract; renumbered to follow the merged chain, after ADR 0043’s0021_night_audit) drops the one-segment-per-room unique key, adds the segment’sroom_type_id(backfilled from its room’s booked type, the type those nights consumed) with a composite tenant foreign key, an exclusion constraint keeping one room’s segments disjoint and an index for per-room reads. Two triggers keep a live segment on its unit’s current type (stay_segments_unit_room_type): one refuses such a segment, the other a change of a unit’s type while live segments use it. Kept segments keep the type their nights were sold as, so a unit can still change type once its stays are over, which a composite foreign key to the unit’s type would forbid. Both triggers take the property gate first, so a concurrent assignment and change of type serialize. The saver applies the same rule as a domain refusal. The previous runtime would read several segments per room as duplicate summary rows, so the migration is notexpand.
Operations
Section titled “Operations”POST /properties/{property_id}/reservations/{id}/rooms/{room_id}/move and …/split follow the
existing room actions: strong If-Match, validate_only=true previews that persist nothing,
the reservation as response with its new ETag, audit entry and thin domain event in the same
transaction (reservation.room_moved@1, reservation.room_split@1, with identifiers and safe
flags only). Both require reservations.modify; acting on an in-house room also requires
stays.check_in. A change that changes nothing returns the reservation unchanged, without a new
version, audit or event; its validate_only preview is a preview like any other, with
validate_only, availability and the current version.
Move { unit_id, arrival?, pricing?, quoted_amounts?, quoted_policy_id?, quoted_policy_version? }
targets one unit (any active type) or null for unassigned.
- Expected stays move as a whole: the result has one segment on the target unit for the new dates (same length), or none. A move of a split expected stay therefore joins it into one room.
- New dates are refused before the business date for non-administrators, as booking and room
editing already do.
pricing: "keep"(default) keeps the nightly amounts in order on the new nights, the plan and the policy snapshot. Keeping a rate-plan price on new dates is a price override: it requiresreservations.override_priceand marks the room overridden.pricing: "quote"re-quotes the room’s own plan for the new dates with the room’s own capacity excluded and re-freezes the policy terms, reusing the room edit’s quote rules; the preview returns the quoted nights and policy version, which the save must send back (quote.changedotherwise). A unit change never re-prices, including an upgrade. - In-house stays move from tonight: earlier nights are immutable and keep their
segment; dates are refused (
reservation.invalid_state). The target must not be dirty (assignment.unit_not_ready); the room left tonight becomes dirty, audited as a housekeeping change. - OTA stays may change unit, including an upgrade; new dates are refused with
reservation.channel_managedbecause they belong to the channel.
Split { date, unit_id } gives the nights from date to the end of the segment that covers
date to unit_id; later segments are unchanged, so a split at an existing boundary reassigns
that segment. The room must be assigned (assignment.required), date must lie inside the stay,
and it may not precede tonight for an in-house stay, or the business date for an expected stay
unless the member is an administrator (historical entry). An in-house split tonight has the move’s
readiness and housekeeping effects; a future split of an in-house stay changes no housekeeping.
Every resulting segment is checked against other stays and physical blocks (assignment.conflict
with the holding reservation code, assignment.blocked) under the property writer gate, and the
database exclusion constraint remains the final guard. Positive ledger changes are checked
against availability (inventory.unavailable with full nights), so neither an upgrade nor a
date move can oversell the last room of a type. Cancelled, no-show, departed and OTA-cancelled
rooms are not movable (reservation.invalid_state).
Moves and splits use the dates of ADR 0043. Tonight is the first night an in-house guest has not stayed: the property’s business date, or its local date while the business date lags behind it during a pending night audit. Nights before tonight have been spent, whatever the business date says, so an in-house move starts tonight, an in-house split may not precede it, and the room left tonight becomes dirty; check-out releases inventory from the same night and cleans the unit of the night before it. Undoing a check-out returns the guest to the unit of the night before the recorded release, independently of the clock. Expected stays follow the desk’s business date, as booking and room editing do: new arrivals and split nights before it are historical entry, reserved to administrators.
A move or split never needs the audit, so it never answers night_audit.required: during a lag
it changes only nights from tonight on, as an early check-out releases them. An in-house stay
whose last night is before tonight has no night left to move; it is refused with
reservation.invalid_state during the lag and after the audit alike, as shortening across stayed
nights is, because catching up the audit cannot reopen those nights. Its check-out is what needs
the audit (night_audit.required). The web app computes tonight from the property’s
business_date and time zone with the same rule.
Effects on existing behavior
Section titled “Effects on existing behavior”- Check-in readiness and blocks use the unit assigned to tonight; check-out dirties
the unit of the guest’s last night; same-day undo restores that unit. Undoing a departure or a
no-show onto a unit that changed type since is refused (
assignment.room_type_mismatch). - The existing assignment endpoint still requires the booked type and replaces all segments with one. Room editing keeps the segments when dates and booked type are unchanged; an in-house departure change extends or shortens the last segment. Changing the dates or type of an expected split or upgraded stay through room editing needs a unit of the booked type or none.
- The night audit’s checklist names an arrival’s first room and a departure’s last room.
- Folios are unchanged: charges follow frozen nights, not units. Reports keep attributing nights and revenue to the booked type. Quotes for an existing room add back only nights consumed on the quoted type.
- Calendar and front-desk stays add
segmentswith unit, type and dates (clipped to the effective departure for departed stays).unit_idis the unit assigned on the first night of the requested window that the stay occupies; reservation summaries use the property’s local date. Reservation rooms’ segments addroom_type_id. - OTA revisions that keep a room’s booked type and dates keep its segments; a revision that
changes them while a unit is assigned remains a
local_stay_conflict(ADR 0028).
Planning interface
Section titled “Planning interface”PlanningGrid adds an opt-in pointer drag layer (onMoveStay, per-stay move: "any" | "unit"
and bookedRoomTypeId). Following the interface chapter it uses pointer events delegated to
the grid, converts the pointer position arithmetically to row and night, renders one preview
and emits an intent only on release over a different valid position. Local checks reject
occupied target nights and blocks with an announcement; Escape, pointer cancellation and
leaving the grid cancel. Coarse (touch) pointers keep native scrolling and use the dialogs.
The web app maps each segment to a bar, opens the confirmation dialog for every drop, and offers
Move… and Split… in the stay detail as the keyboard and single-pointer alternatives.
The dialogs show room, type, dates and price changes from the server preview, link errors to
their fields and keep the draft for recovery.
Consequences
Section titled “Consequences”Upgrades and mid-stay room moves become ordinary, audited ledger operations, and the planning
grid stays a data-agnostic component. The ledger now depends on segment types, which
inventoryDeltas reads from the reservation itself, so every writer (desk, OTA import, policies)
uses one rule. Unit tests, property tests and real PostgreSQL tests (concurrency, ledger, RLS,
memory parity) check it, and the browser workflow covers drag, reload, the dialogs, denial and
recovery.
The original delivery excluded the following items; the wave-1 amendment below supersedes the keyboard, resize, flag and Undo exclusions. Originally not included: a keyboard move mode inside the grid (the dialogs are the keyboard path), resizing bars to change the length of a stay, a “do not move” flag, automatic housekeeping for a planned future room move on its day, room-move cleaning tasks, an admin readiness override for moves, undo toasts for moves, re-selling a stay as another booked type from Planning (room editing does that), and moves of group blocks. Manual screen-reader certification remains release work.
Alternatives considered
Section titled “Alternatives considered”- A new reservation room per part. Rejected for the counting, OTA identity and folio reasons above.
- Segments limited to the booked type, with type changes re-pricing the stay. Simpler ledger, but it contradicts the analysis, forbids in-house upgrades and charges a different plan for an operational move.
- Native HTML drag and drop. The interface chapter rejects it for wide previews and resizing; it also cannot preview a whole stay across virtualized rows.
Verification
Section titled “Verification”Local evidence on the shared machine before review (counts are not product completeness):
core 631 unit/property tests, including the move/split use cases, segment rules and a per-night
model property test; PostgreSQL integration through PgBouncer for persistence, the ledger
recount, ARI rows, audit/event rows, concurrent moves into one room (one success, one 409),
concurrent writers of one reservation (one success, one 412), per-room exclusion, tenant-safe
segment types, cross-workspace denial and memory-fake parity (db integration 590, server
integration 207); API route contracts (690), SDK (55), UI component (220) and web (421) suites;
and the production browser workflow planning.spec.ts (drag upgrade, reload, keyboard Move,
split with a field-linked conflict and recovery, denied viewer, axe) with the existing desk,
team and role workflows. Manual screen-reader checks remain release work. The second review
round added regressions for the live segment type (PostgreSQL trigger and domain refusal), no-op
previews over HTTP, the default split night and the grid’s drag edge cases.
The third round guards a unit’s change of type as well (single and bulk updates, history left
valid, a coordinated race in both orders) and brings the memory fake to the same constraint and
the same in-house occupancy rule, checked by a shared fake/PostgreSQL contract.
Integrating the night audit (ADR 0043) added regressions for in-house moves and splits while the
business date lags and after the audit catches up, for a stay with no night left, for the stay
dialog’s in-house move date and for the audit checklist’s names of a split stay’s rooms.
Amendment 2026-10-31: complete Planning interactions and direct booking rooms
Section titled “Amendment 2026-10-31: complete Planning interactions and direct booking rooms”This amendment supersedes the exclusions above for edge resizing, keyboard move mode, Do not move and Undo. Automatic housekeeping on a future move day belongs to the housekeeping track; this change keeps the existing move/date-change outcomes and events so that it can react to them. No housekeeping write or webhook catalog is added here.
Observable acceptance
Section titled “Observable acceptance”An operator drags either outside edge of a direct stay, or opens Change dates…, reviews the server’s proposed dates and price, and confirms. Arrival and nights already stayed are immutable for an in-house guest; channel dates remain channel managed. A split stay resizes only its outside commercial edges. Its internal boundaries remain split operations.
With focus on a stay, M enters a mode announced to assistive technology. Up/Down chooses a room, Left/Right shifts nights, Shift+Left/Right changes departure, Alt+Shift+Left/Right changes arrival, Enter requests the same server preview and Escape cancels. The proposed room and dates remain visible and are announced. Touch retains native scrolling and uses the dialogs. Resize targets stay within their own bar. Bars at least 54 px wide use 24 px handles; narrower bars reserve a 6 px center for moving and divide the remaining width between their edge handles. This is a reasoned deviation from the research’s blanket 24 px target rule: a one-night bar at month/two-month zoom cannot contain two 24 px targets and a move body. Extending the targets outside it overlaps adjacent stays and changes the wrong stay. The full-size Change dates… dialog remains the equivalent single-pointer and keyboard path, and coarse touch uses that path without precision handles. All interactions are parent-owned intents: the reusable grid cannot authorize or save a change, and the server checks availability and concurrency.
Each reservation room holds its Do not move flag. The stay detail exposes a permissioned control, every segment bar shows the marker, and moving or changing dates of a flagged stay requires explicit acknowledgement. The flag is a warning requiring deliberate confirmation, not a permanent inventory lock. Setting and clearing it is version checked and audited.
A successful move or resize offers Undo for ten seconds using the existing toast’s hover/focus pauses. Ctrl/⌘ Z in Planning invokes the same inverse, except while editing text or inside a modal. The inverse carries the version returned by the saved action; it never fetches a newer ETag to overwrite someone else’s edit. An inverse conflict stays visible with an explicit reload/review path. Undo belongs to the current property/workspace and disappears when that scope closes.
Direct booking creation keeps independent room drafts for type, occupancy, assignment, rate plan and price. Each room shows its availability and quote. One validation-only request previews the whole reservation; one create persists all rooms atomically. Any room edit invalidates the final preview. Existing multi-room detail and segment rendering remain the representation, so these screens introduce no second reservation model.
API and inverse safety
Section titled “API and inverse safety”API revision 6.1.0 adds room resize and undo transitions and a versioned do_not_move
sub-resource. The flag grant is reservations.set_do_not_move, assigned to administrators,
front desk and revenue managers and available to property-scoped custom roles. Movement still
requires its existing grants; changing the flag never grants permission to move a stay.
Resize and Undo run as room actions through the existing reservation save, inventory, folio,
audit and event pipeline. Resize emits reservation.room_changed. An inverse emits the
ordinary event for the change it actually makes, with undo_of set to the consumed
planning_change_id: reservation.room_moved for a move back, and
reservation.room_changed for a date change back, reservation.room_moved for joining a split,
and reservation.room_split when restoring split assignments. Unit and date facts
describe the current state being changed and its restored result, not the original forward
action. Every move, split and resize includes previous_arrival, previous_departure,
arrival, departure, dates_changed, repriced and in_house. Move and split events also
include previous_unit_id, unit_id, segment_count and upgraded; unit identifiers name
the changed assignment boundary, including a split join. Resize events have the ordinary
room action’s room_id and current unit_id, plus the date facts; they omit
previous_unit_id, segment_count and upgraded. Inverse events add undo_of and omit
undo and planning_change_id. OPS can consume these ordinary changes without interpreting
an undo flag. No webhook event catalog or housekeeping scheduler is changed here.
A resize must overlap the existing stay; a disjoint window is a move. Every retained night
keeps its frozen price, for expected and in-house stays. Removing nights needs neither a
quote nor a price override and does not set priceOverridden. Added nights default to the
room’s own plan quote; the preview shows the added prices and the save echoes its nights
and policy version. An explicit keep choice instead copies the first sold price onto
added arrival nights and the last onto added departure nights. For a planned room that
choice requires reservations.override_price and marks the price overridden. A manual
stay has no plan and uses edge prices by default.
For existing expected and in-house stays on the same plan, restrictions follow what
changes. An arrival moved outside the contracted stay (including a same-length date move
or an earlier arrival) checks CTA and the new arrival day’s minimum and maximum against
the new length. A changed departure checks CTD on the new departure. Stop-sell applies
only to added nights. Extensions check the arrival-day maximum and through minimums on
the new total length; through minimums cover every occupied night, including retained
nights. An unchanged arrival does not recheck its arrival minimum. Shortening within the
contracted dates bypasses these bookability restrictions, including a later arrival or
earlier departure onto a closed edge, within lifecycle and stayed-night rules. Later
nightly maxima never shorten an admitted stay. An override changes price, never
bookability. These rules apply independently of price retention, including room edits
and quote/keep moves onto new dates. Every refused quote includes the plan’s stable
reasons, shown in the operator’s language. In-house resizing keeps arrival and all
spent nights immutable and allows only departure after tonight; quoting added nights
never reprices retained nights.
Planning quotes prices and restrictions from the booked type’s own plan, but validates capacity against the resulting assignment segments in the reservation saver, for previews and saves alike. An upgrade onto new dates therefore consumes the destination type rather than requiring spare capacity in the booked type as well. Unassigned nights consume the booked type. The property writer gate and positive inventory-delta checks still prevent overselling either type.
The server stores a one-time prior-room receipt for a successful move, split or resize and returns only its opaque identifier. It is bound to actor, workspace, property, reservation, room and the exact resulting reservation version. The prior room contains the sold facts and assignments needed to restore that action, without guest contact data. Receipts expire logically after ten minutes; consumed receipts are deleted atomically, and expired rows are removed in batches of at most 100 for the property when another receipt is saved. This is logical expiry, not a scheduled physical-deletion deadline; an idle property’s expired rows remain until a later write. The UI exposes just the accessible ten-second action window. API revision 6.1.0 exposes these planning operations. Migration 0040 is a contract boundary: it adds the persisted room flag and actor-bound receipt table, tenant/property RLS, grants, and a composite foreign key associating each receipt with its actual reservation room. Receipt foreign keys cascade parent deletion, so ephemeral inverses cannot prevent property, workspace, reservation, room or actor cleanup. Deploy the matching runtime after applying it.
The inverse requires current reservation-modification access (and check-in access in house), then checks inventory, blocks, active target units/types, current occupancy capacity, lifecycle, historical-entry rules and newly spent nights. Restoring the original frozen sale is authorized by the receipt and does not require a new price override grant. For an immediate in-house move, it restores only the original room’s housekeeping state when its current dirty state and timestamp still identify that move’s own side effect. Attribution reconstructs the saved dirty timestamp with the shared housekeeping helper from the prior unit timestamp and receipt creation instant; the monotonic helper may advance beyond that instant when the clock repeats or moves backwards. Restoration uses the same helper and advances the current housekeeping version and timestamp. It preserves unrelated unit edits, refuses an intervening housekeeping change and marks the unit just left dirty through the existing audited path. Neither a stale reservation nor a changed readiness state is overwritten.
The Planning screen has its own route entry. Booking and stay-change dialogs load through dynamic imports only when opened, keeping their bodies out of shared initial route chunks. A failed import shows the translated loading error and Retry action; retry keeps the parent’s proposed stay change and callbacks. The loader handles import failures explicitly, following the account screen’s existing pattern, rather than relying on the App Router’s dynamic loading callback, which does not receive rejected imports.
Decisions for coordinator review
Section titled “Decisions for coordinator review”- Keep the flag on the reservation room, shared by its segments, rather than protecting a physical unit. It follows the guest’s sold stay through upgrades and splits.
- Keep all money and OTA effects behind the authoritative preview/confirmation used by the existing workflow. Explicit flag acknowledgement is an additional gate.
- Use one pending inverse per desk scope. A later planning action replaces its predecessor; the UI has no persistent client history containing guest or price snapshots.
- Keep Direct reservations distinct from groups/allotments and the public booking engine, which remain planned. Planning’s future-move housekeeping dependency stays explicit.
Verification and the final API/SQL details are recorded with this amendment’s implementation report; manual screen-reader certification remains release work.