0028. Durable Channex booking import
- Status: Accepted
- Date: 2026-10-27
Observable acceptance criteria
Section titled “Observable acceptance criteria”An administrator links a local property and its room types to identifiers read using the workspace’s Channex connection, registers a secret-protected booking notification with send_data=false, and requests a drain. Mapping and sync health survive reload. Other roles may inspect health but cannot configure or request writes. A notification only wakes the feed worker; notification order and duplicates have no booking effects. The worker imports new, changed and cancelled OTA rooms into the existing front desk, including bookings exceeding capacity. Unknown types remain visibly quarantined. In the default shared mode, revisions of unmapped, inactive or unlinked properties stay upstream without a local archive or receipt; exclusive mode archives valid unmapped revisions as non-routable (see Shared accounts). Each routed revision is archived once, local effects commit before acknowledgement, and failed acknowledgements remain retryable independently. A stale revision cannot roll back a newer projection or resurrect cancellation. Assignment, notes and check-in/out remain local; OTA commercial fields are read-only. Production-built browser acceptance uses synthetic simulator data, both languages and mobile/desktop themes. The assigned brief substitutes for a separate feature issue.
Decisions
Section titled “Decisions”Migration 0008 follows the rates migration 0007 in the integrated release. No applied migration changes. Workspace-scoped mapping, immutable revision archive and mutable processing receipts are distinct. The archive encrypts personal fields with the independent integration keyring; domain events contain identifiers and safe flags only. Receipts distinguish application/quarantine/non-routable/stale from acknowledgement. Mapping replacement is refused once revisions reference the property; additions resolving previously unmapped types remain possible. Rate-plan mapping and outbound ARI were planned here; ADR 0034 implements them.
Shared mode reads each active property’s filtered feed; exclusive mode without retained unlinks reads the connection-wide feed, as Channex recommends (see Shared accounts). Each entry is parsed independently and a valid outer resource ID can replace a missing attribute ID. Entries without a trustworthy identity, property or ordering timestamp remain untouched upstream. Rejected bodies are discarded; rejected revisions with complete safe metadata receive a quarantine receipt only for an active link (see the round-four section below). An unreadable entry marks health failed without hiding a later valid booking. Pages contain at most 100 records. A bounded worker returns to page one after acknowledgements and reads later pages of the same feed when a full page makes no progress; work remaining schedules a durable continuation. Safety-net scheduling recovers missed notifications after 20 minutes. A wake-up request is coalesced through a flag with its request time; a request older than the same 20-minute window counts as lost and is renewed, so a job that never ran cannot block later signals or the safety net. Retrying pending acknowledgements uses at most half the call budget and a refused acknowledgement does not stop the feed read; it keeps the drain unsuccessful (failure shown, last completed drain unchanged) until it succeeds. Discovery, feed, recovery and webhook calls never occur within a database transaction. The two deliberate, bounded exceptions hold the dedicated ownership fence: the booking acknowledgement POST and the DELETE of each webhook a removal retry listed (acknowledgement ownership fence). Property writer gates serialize projection and inventory updates against front-desk mutations. Duplicate revision IDs cannot repeat local effects. Equal inserted_at values require an authoritative booking read; ambiguity is quarantined rather than arbitrarily ordered by UUID or delivery time. Cancellation was terminal in v1; ADR 0048 reinstates rooms a newer revision makes active again, unless their stay happened.
Outbound ownership requires a production environment, an initialized matching database instance and the connection’s persisted owner instance. Restored copies and previews cannot acknowledge or register webhooks. No customer credentials are required in CI.
Channex supports custom authentication headers for webhooks; this workflow uses a random secret in X-GuestCraft-Channex-Secret and HTTPS, rather than assuming Channex emits the client’s optional HMAC convention. Source: webhook collection. The receiver stores only a fixed signal event, verification result and receive time. It never reads the request body, so it stores no property identifier either: the drain is connection-wide, so a property hint would change nothing, and an unread body cannot reach storage or logs. This is a reasoned deviation from the brief, which listed the property.
Planned and limits
Section titled “Planned and limits”Outbound availability/rates/restrictions, rate-plan mapping, takeover barriers, historical booking reconciliation, guest erasure orchestration and automated Channex certification were planned here. ADR 0034 implements outbound publication, rate-plan mapping and takeover barriers; ADR 0048 implements historical booking import; guest erasure orchestration and Channex certification remain planned. This branch must not claim those capabilities from a successful import. Final implementation decisions, verification results and remaining gaps follow below.
Implementation notes for coordinator review
Section titled “Implementation notes for coordinator review”Property and room mappings are normalized tables with composite foreign keys, including a room type’s property, rather than JSON references. The next ARI round can reference these identities and query uncapped degraded-property state without reshaping the archive. Queue metadata is prepared before tenant transactions; this was required by a real one-connection PgBouncer regression. Pending ack receipts are drained separately from Channex’s feed to recover an applied ack whose response was lost.
OTA changes conflicting with an active local stay, including in-house cancellation, are archived, quarantined and acknowledged. The operator resolves the local stay and retries. A cancellation of a stay that already happened is applied instead (see Cancelling a stay that happened). Reinstatement of cancelled rooms that nobody stayed in follows ADR 0048. Guest metadata is read-only at the API as well as on the reservation detail screen.
Review findings and remaining gaps (second pass)
Section titled “Review findings and remaining gaps (second pass)”The second review fixed: a wake-up flag that could stay set forever when a drain job was
lost; one acknowledgement Channex refuses blocking every later feed read; the reservation
details PATCH contract advertising source ota and an unused overbooking field; the
Today banner rendered twice and the Planning banner placed below the grid; degraded
properties missing from the health screen. Each has a regression test.
Known gaps at that time: the client validated a whole feed page, so one revision that failed
the schema failed every drain. PR 51 replaced this with per-entry parsing and
invalid_revision receipts (round-four section below). The
health screen lists overbooked reservations by identifier, while the Reservations
overbooking filter shows them with guest and dates. Unverified signals are stored for
diagnosis without a retention job. Guest notes on an OTA-owned guest are preserved but
not editable, because the whole guest record is read-only.
Round-three integration combines migrations 0007 and 0008. The temporary loader exception for the missing 0007 was removed; migration numbering is contiguous again as ADR 0011 requires. Imported OTA inventory changes enqueue availability rows in the ARI outbox in the same transaction as the local projection. ADR 0034 publishes those rows to Channex.
Shared accounts
Section titled “Shared accounts”shared is the persisted default for existing connections and every credential replacement.
An administrator changes account_mode on PATCH /v1/channex/connection using the strong
ETag from GET as If-Match. Stale edits return 412. The mode change, audit entry (old/new
mode and actor) and thin event commit together. The Channels screen explains the scope in
English/Italian and requires an explicit warning confirmation before exclusive mode.
Migration 0018 adds the mode/version and a durable property recovery cursor; existing
account-wide recovery cursors are discarded. Applied SQL remains unchanged.
In shared mode, each mapped property is polled even when no property barrier is pending.
There is no account-wide fallback, including with zero mappings. History recovery pages
under the documented filter[property_id] on
GET /booking_revisions. Shared mode skips the current/future GET /bookings phase
entirely: that endpoint documents no property filter. This avoids downloading foreign
bookings or fetching their revisions by ID. Exclusive mode retains bookings reconciliation.
Recovery persists both property and page across jobs, then drains the mapped feeds.
A filtered feed or history response containing any other property stops that drain
after one page and sets the existing failure health alert before booking/current reads,
archive,
projection or acknowledgement. Pending ack queries and the final ack guard check the
archived remote property, so returning from exclusive to shared cannot acknowledge an
old unmapped receipt. An administrator cannot retry its upstream booking while shared.
Property-less bookings (booking_without_property_id) are handled only in exclusive
mode. A verified signal without an identifier wakes only active mapped feeds in shared mode. Exclusive mode deliberately retains the account-wide non-routable queue
and acknowledgement behavior; it is appropriate only for a sole account consumer.
Webhook list/create/check/update calls always use a mapped property, property_id,
is_global=false, send_data=false and the authenticated signal endpoint. Matching a
callback URL alone cannot update an existing global or other-property hook. Ownership
is read from relationships.property.data.id, as the provider documents; old attribute
shapes remain a compatibility fallback. Re-registration updates the owned hook rather
than creating duplicates. A verified
global delivery for an unknown property is recorded without a shared drain. A delivery
without routing metadata wakes all active mapped properties, using filtered feeds. Authentication happens before parsing the
bounded routing hint; bodies and personal content never enter signal storage.
Call-site audit: core/channex/import.ts scopes feed, recovery, retry/current, pending
ack and signal routing; channex/index.ts forwards recovery filters; the portable
booking-revisions and sync resources encode them; the independent simulator filters
history; bookings remain account-wide as documented. Shared property summaries come
from direct mapped-property
reads. Administrators link a specific Channex property ID first (validating that one
summary), then discover/map its room types. The bilingual link warning states that GuestCraft
reads/acknowledges the property’s bookings and publishes its availability/prices; no other
system should manage it. The mapping audit/event includes channex_property_id. Account-wide property discovery
is exclusive
only. Credential validation uses a read-only limit: 1 authentication/count probe; only the
total is persisted, never property titles. The UI labels it as properties visible to the key;
it never reads bookings, changes inventory or acknowledges a feed.
A mode version change stops an existing drain before its next feed request and prevents its old recovery cursor from advancing. Requests already sent to Channex cannot be cancelled retroactively; changing mode is not a substitute for coordinating takeover with the previous account consumer. Old exclusive-mode receipts remain visible locally; their upstream retries are suspended in shared mode. No migration deletes their history.
Acceptance evidence includes the idle-drain simulator regression that fails on main 24b77f5 with two unfiltered feed reads; foreign revisions remain pending and absent from the local archive. Real PostgreSQL tests cover persisted mode/audit/version/isolation, filtered recovery, failed foreign ack suspension and exclusive consumption. Built-browser checks cover the property-link workflow, warning, keyboard cancellation, mode persistence and bilingual accessible Channels screens. Tests never require a customer Channex key.
Integration with ADR 0036 uses the dedicated channex.manage_account_mode permission,
granted to built-in administrators only. An unrestricted workspace role may be explicitly
delegated that administration capability; channex.configure alone cannot change account
mode. Selected-property memberships cannot receive workspace-wide connection data or
change mode, even with that grant. Migration 0018 updates the immutable built-in template;
API revision 2.2.0 exposes the new operation and permission in generated contracts.
Confirmed, inactive and reversible property links (PR 47, round 3)
Section titled “Confirmed, inactive and reversible property links (PR 47, round 3)”Observable acceptance: a caller with channex.configure and the workspace-wide
channex.manage_account_mode grant reads a candidate title/ID, reviews the acknowledgement
warning, and explicitly confirms that ID and its connection version before the first shared
link. Cancelling makes no write. The saved empty mapping survives reload and permits room
discovery, but feed/history/current reads, application and acknowledgements remain inactive
until at least one room type is mapped. Room mapping can still be delegated with
channex.configure. Stale ownership confirmations fail with 412. Linking alone cannot
quarantine or acknowledge a booking.
GET /channex/properties/{id} returns a bounded candidate summary, connection_version
and its strong connection-version ETag.
PUT /channex/mapping accepts confirmed_property_id and connection_version for a first
shared link. Mapping writes increment the connection version, fencing stale drains as well
as confirmations. DELETE /channex/mappings/{property_id} requires a strong If-Match
using the version from GET /channex/connection, and both ownership/configuration grants.
All paths above are relative to /api/v1.
Unlink commits the intake fence, audit entry and domain event together, then removes only
property-scoped booking hooks whose callback belongs to this workspace/instance. Unrelated
consumers’ hooks survive. Failed removal leaves a retained inactive mapping with pending
cleanup, exposed as pending_unlinks in health. The Channels screen can retry the same
conditional DELETE after reloading the connection version. Relinking is blocked while
cleanup is pending. A later confirmed link starts with empty room mappings again. Replacing an existing
upstream property always requires unlink first, even before the first receipt, so its
webhook cannot be orphaned by overwriting the old property identifier.
Migration 0018 adds the retained link and cleanup flags, with active-target uniqueness. Reservation identities, stays, folios, revision archives, receipts, audit/event history and ARI send diagnostics are preserved. Pending acknowledgements are fenced by the archived remote property; they resume only when that property is linked and mapped again. Old receipts never move to a new local property. Imported reservations remain operator-visible after unlink.
Unlink disables publication, invalidates its lease generation, clears room/rate mappings,
discovery and published-value caches, retires pending/claimed ARI rows as completed with
last_error_code=property_unlinked, and marks unsettled sends failed with the same code.
This terminal queue status means retired work, not verified upstream delivery. A relink
needs fresh rate mapping and explicit publication takeover/full realignment. Local changes
made while disconnected remain local until that new takeover. Existing imported room and
reservation history are unchanged. The application role gains update on ari_changes and
ari_sends, and delete on ari_published, for this transaction; forced workspace/property
RLS remains in force and the inventory/catalog matrix verifies those grants.
Migration 0024 keeps each retired upstream target in a separate workspace-scoped table, so relinking the same local property to a different target cannot erase its fence. Relinking the retired target itself removes that fence in the same mapping transaction. Once any target is retained as unlinked, exclusive intake also falls back to explicitly mapped feeds/history rather than reading the retired target through account-wide lists. Unlink restarts an in-progress history recovery for the remaining linked properties at page one. A concurrent unlink during an authoritative booking read skips that target, without a later revision read or a drain failure. In-flight HTTP requests already sent cannot be recalled. Version/lease checks fence local application, subsequent reads and late ARI completion. A transaction-local ownership lock covers one acknowledgement POST (see the 2026-10-29 amendment); unlink does not undo previously acknowledged bookings or reverse already delivered inventory changes.
An inactive link permits room discovery but cannot register an upstream booking webhook.
API revision 3.0.1 returns channex.mapping_inactive (409) for that operation; the Channels
screen disables it until a room type is mapped. New unknown_room_type quarantines are
acknowledged after their encrypted archive and receipt commit, then retried from that archive
once per connection version when the same local and upstream property has room mappings.
Replay failures are isolated per receipt and recorded for health (2026-10-29 amendment); normal
intake always continues. Receipts present before migration 0024 require manual retry, even
after later version changes.
Amendment, 2026-10-28: acknowledged archive recovery
Section titled “Amendment, 2026-10-28: acknowledged archive recovery”Channex expects every owned booking revision to be acknowledged after receipt. An unknown
room type therefore leaves a durable encrypted archive and quarantine and receives an
acknowledgement while its property remains linked with at least one mapped room type.
Acknowledgement does not mean the reservation was applied. Inert and retired properties
remain outside acknowledgement scope in both account modes, and so do unmapped (“foreign”)
properties in shared mode. Exclusive mode consumes an unmapped property’s valid revisions
as non_routable and acknowledges them, because the operator declared the whole account
GuestCraft’s. An unacknowledged owned receipt can otherwise be sent repeatedly and cause an
account warning.
Automatic replay considers only receipts explicitly written by the new runtime. Existing
rows and writes from an old runtime after migration 0024 receive a permanent -1 sentinel;
later connection-version changes do not re-arm them. The new runtime explicitly inserts 0
and an upsert never changes an existing sentinel.
Operators can inspect and retry these in sync health after mapping. For new receipts, see the
2026-10-29 amendment for failures. When the authoritative current revision is newer than the
archived one, replay marks the old receipt stale. This avoids projecting a stay that Channex
has already cancelled or changed. The 2026-10-29 amendment narrows this to the replayed
receipt alone.
Migration 0024 is declared expand (ADR 0038): its SQL keeps the previous runtime’s reads
and writes working, so that runtime may start against it. Its behaviour does not survive such
a rollback, though: the previous runtime does not maintain the separate retired-target fence.
Do not roll back across it while any workspace is in exclusive account mode; restore a
pre-upgrade database backup if a rollback is necessary there.
Backlog count and oldest pending timestamp use exactly the pending-ack scope, so foreign receipts retained after exclusive → shared do not appear as a stuck active import.
Amendment, 2026-10-29: replay isolation, held acknowledgements and the acknowledgement fence
Section titled “Amendment, 2026-10-29: replay isolation, held acknowledgements and the acknowledgement fence”Only the replayed receipt becomes stale. When the current revision Channex returns is
newer than the archived one, replay marks that one receipt stale, under the connection lock
and after rechecking ownership. It never marks the booking’s other receipts. A newer
revision that is already archived keeps its own quarantine and is replayed or applied on its
own. Applying a revision still supersedes the booking’s quarantines that are not newer than
it. A newer revision that is not in the archive arrives through the feed while it is
unacknowledged. If something else already acknowledged it, only history recovery can bring
it in, and until then the booking has no reservation in GuestCraft. Replay and the manual
retry accept only quarantined and non_routable receipts; for an applied or stale one,
POST /channex/retry answers request.invalid without any read or write.
Round 1 applied a booking modified while its room type was unmapped correctly. Round 2 regressed it: after the mapping, the older receipt’s replay marked both revisions stale and left no reservation. The PostgreSQL regression maps the type after the modification and expects the reservation at the latest revision.
Withheld acknowledgements do not loop. In exclusive account-wide mode an inert link’s
quarantine stays unacknowledged, so every feed read returns it. Only a known receipt in the
pending-ack scope (the scope an acknowledgement is sent in) asks for another drain. Sync health
counts the withheld ones in backlog.held. They are acknowledged once a room type is mapped,
when the receipt is also replayed.
Replay failures are isolated per receipt.
- An archive that cannot be decrypted or parsed is recorded as
archive_unreadablefor the connection version. It never stops the drain, which used to fail before the feed. Rotation keeps archives readable:credentials:reencryptalso rewrites the webhook secrets and the revision archives. - A permanent Channex refusal (for example 404 or 410), or a current revision Channex
returns unreadable, is recorded as
upstream_rejected. - Only these failures of one receipt, its archive or Channex’s answer for it, are recorded.
The booking remote reports Channex failures, and only those, as
ChannexUpstreamError: the client types every failure it knows asChannexError(including a list longer than the 50 pages it reads), and normalizing a revision runs after that translation. Any other error, such as a local database failure or a bug, keeps its type and stops the drain without recording an attempt (ADR 0008): the job runner retries a retryable failure, and a bug surfaces instead of looking like a receipt problem. - The same rule applies to every acknowledgement a drain owes: after a replay, the pending
ones and the one after intake. Only a
ChannexUpstreamErrorfrom the acknowledgement POST marks the receiptack_failedand lets the drain carry on; the drain then fails at its end, and the job runner retries it. A local failure, such as the fence’s lock timeout or the bookkeeping write, or a bug stops the drain at once, before the replay attempt is recorded or another receipt is processed. - A transient failure (5xx, timeout, network) is recorded as
upstream_unavailable. The four automatic delays are 1, 2, 4 and 8 minutes; later receipts are still replayed in the same drain. The fifth failure in a row records the connection version, so the receipt waits for the next connection change or a manual retry. - Saving a mapping clears the affected quarantines’ attempt count and active delay in the same transaction as the connection version change. A corrected mapping is retried immediately.
- When Channex asks for a pause (429 or
Retry-After), the receipt is deferred for at least that long and the drain makes no further replay reads; recovery, acknowledgements and the feed continue. - A completed read clears the recorded failure. Sync health exposes each quarantine’s
replay_failureand itspre_upgradesentinel, and the Channels screen shows the manual-retry hint only for pre-upgrade receipts.
Migration 0024 adds replay_attempts, replay_after and replay_failure to
channex_receipts for this backoff.
Acknowledgement ownership fence. An acknowledgement takes FOR SHARE on a dedicated
channex_ack_fences row, rechecks property ownership, credential and mode, and holds it
through one acknowledgement POST. Every ownership writer takes FOR UPDATE first: unlink,
forget, relink, mapping save, webhook registration, reconnect, exclusive → shared and
shared → exclusive. They return channex.connection_busy (503, Retry-After: 3)
after a two-second lock timeout, with no state change. No statement runs while the POST is in
flight, so the statement timeout does not bound it: the transaction is idle, and the 25-second
idle_in_transaction_session_timeout ends the session and releases the fence. The
acknowledgement client’s 10-second abort, and the 4-second abort of a webhook DELETE (below),
stay clearly below it; keep that coupling when changing any of these bounds. Waiters, whether
writers or other acknowledgements, give up after the two-second lock_timeout. Timeouts are
transaction-local for PgBouncer transaction pooling.
Only acknowledgements, webhook DELETEs and ownership writers use the fence; webhook signals,
safety requests, intake and ARI use their separate short sync transactions and do not wait on
the POST. Intake rechecks ownership under its own sync lock.
The fence row is created lazily, because a connection written by an older runtime has none. An acknowledgement commits a missing row in a short transaction of its own before its fenced transaction. Inserted inside that transaction, the row would stay uncommitted across the POST, and every other acknowledgement of the workspace would wait on the insert and be refused after the lock timeout, failing its drain. A writer may still insert the row inside its own transaction: it holds the row exclusively anyway, and only for that short write.
The fence holds while the worker retains its database session and the client request remains active. After a lost session or the 10-second client abort, Channex may still complete one acknowledgement. It hides that revision from the account feed and sends nothing to a guest. History recovery can rediscover it; an acknowledgement cannot be undone. The fence protects only once every runtime is new: a runtime from before migration 0024 acknowledges and changes ownership without it, so during a rolling deploy an old runtime’s acknowledgement can still race a new runtime’s ownership change, as it could before.
Reconnect and pending webhook removals. GuestCraft still has upstream state on every
linked target and on every unlinked property whose webhook removal is pending. Reconnect
refuses (channex.reconnect_requires_unlink, 409) a key that cannot see all of them, both
before the fence and again inside it; the operator unlinks those properties and finishes
their webhook removal first. A new key that sees every one of them still works, for example a
rotated key of the same account. Visibility is checked against the list of properties the
new key returns, never by reading a property by ID, so another account’s key is sent no
property ID. The check scans that list page by page and stops once it has seen every
required property; inside the fence it rechecks the current targets against the pages it
read. It reads at most 50 pages of 100, the supported boundary of 5,000 properties: a key
whose list does not show every required property within them is refused with
channex.reconnect_unverified (409), which says that verification could not be completed.
The operator retries, uses the key of a Channex user with access to fewer properties, or
unlinks properties first. A list that ends without them is the ordinary 409 above.
Retrying a webhook removal uses the key read under the fence, and an empty webhook list
proves the removal only when that key is the one the removal started under. A reconnect with
a different key marks every pending removal (channex_mappings.cleanup_key_changed); after
that, only a listed and deleted webhook completes it. Otherwise
channex.webhook_removal_unverified (409) keeps it pending, and connecting again with the key
used at unlink time lets a retry find the webhook.
Each unlink starts a removal of a new generation (channex_mappings.cleanup_generation). A
retry captures the target and the generation with the key under the fence, then ends exactly
that removal with a conditional update on workspace, property, target and generation, under
the fence in share mode (it changes no ownership, so it never waits for an acknowledgement,
but every writer of the removal takes the fence for update). Only then is
channex.webhook_removed recorded. If the removal ended or a newer unlink replaced it while
Channex answered, even an unlink of the same target, the retry answers
channex.webhook_removal_superseded (409) and records nothing.
The webhook DELETE is the second network call inside a transaction. A retry lists the
target’s webhooks outside any transaction. It then sends each DELETE in a transaction of its own
that holds the fence in share mode, after rechecking that the captured removal, target and
generation, is still pending; otherwise it deletes nothing and answers
channex.webhook_removal_superseded. Forget, relink and webhook registration take the fence for
update, so while a DELETE is in flight none of them can end the removal, link its target again
or register a webhook: they wait, or answer channex.connection_busy after the
two-second lock timeout. A relink refuses a target whose removal is pending, so under the fence
a pending removal and an active link of the same target exclude each other, and a DELETE never
reaches the webhook of a newer link, even when that link’s registration updated the retained
webhook and kept its ID. Nothing else runs in that transaction: the booking client aborts the
DELETE after 4 seconds, below the 25-second idle-in-transaction timeout. The unlink’s own
admission has committed the fence row, so the DELETE’s transaction never holds an uncommitted
insert of it. In share mode a DELETE neither waits for an acknowledgement nor holds one up.
Forgetting a pending webhook removal. A removal can never be confirmed when, for
example, Channex applied the DELETE but GuestCraft lost the answer and the key changed since.
An administrator can then forget it: DELETE /channex/mappings/{property_id}/cleanup, with
the strong If-Match of the connection (every unlink changes it, so it also names the
generation the operator saw) and the unlink permissions, serialized under the fence. It ends
only that pending removal and its mark, through the same conditional update, and records
channex.webhook_removal_forgotten with the target, never channex.webhook_removed. The
retired-target intake fence, imported reservations and their history and the audit trail stay;
the property can then be linked again. GuestCraft cannot verify the upstream cleanup: the
confirmation in Channels says so, and the operator removes any remaining GuestCraft webhook in
Channex. A retry that was already waiting on Channex then deletes nothing and answers
channex.webhook_removal_superseded.
With Messages (ADR 0044, 2026-10-29), an unlink first finishes the property’s Messages stop and
only then removes the booking webhooks; forget also takes the property’s lock and answers
channex.messages_stop_pending (409) while that stop or an install attempt is unfinished, so it
never leaves GuestCraft’s Messages installation behind.
Residuals:
- The visibility check proves read access only. A key that can read every target but may not write is accepted; each drain then fails to acknowledge, and sync health shows the failure until a key that can write is connected.
- List membership is not account identity. Another account’s key that also lists this account’s properties is accepted, and a removal retry then sends that key a webhook list filtered by a property ID it already listed. Its empty answer proves nothing after the key change, so the key-change mark keeps the removal pending: no false cleanup is recorded. GuestCraft sends a candidate key no property ID that the key did not list itself.
- A Channex list that repeats a page with plausible page numbers can hide a property; the scan then refuses a key that does see it, never the reverse.
- A webhook registered under an earlier key of another account that can also see the property is not tracked: an unlink under the current key removes only what that key lists.
- Registration is admitted under the fence but reads and writes the webhook after its transaction. An unlink whose removal ends during that call can leave a GuestCraft webhook on the unlinked target; its signals are out of scope and enqueue nothing, and the next registration for that target updates it in place.
- When Channex rate-limits the visibility check (429), the reconnect’s
server.unavailablecarries the pause Channex asked for asRetry-After. - Like the fence, the key-change mark and the removal generation hold only once every runtime is new: an older runtime neither sets nor reads them.
- The fingerprint registry of rotation (see the installation guide) lets whoever reads that
owner-only table check a guess of a root key with one HMAC, a few times less work than
against a stored ciphertext. GuestCraft accepts this constant factor rather than a slow key
derivation because
ENCRYPTION_KEYSvalues are generated high-entropy keys.
The documented webhook payload
contains top-level property_id and, for booking data, payload.property_id. The receiver
accepts both with top-level precedence, authenticates before bounded parsing, stores neither
body nor routing metadata, and never trusts revision identifiers from the signal.
The independent simulator records query keys and redacted request-body shapes. Offline contract checks compare the actual client’s requests (including integration drains) with the dated upstream parameter and request-body snapshot. Unfiltered lists are account-wide; unknown webhook PUT/DELETE return 404. No customer credentials are used for these checks. The optional repeated-filter-violation circuit breaker remains planned; the existing one-page fail-closed fence and health alert remain the supported behavior.
Rejected and unidentifiable revisions under shared-account fences (PR 51, round 4)
Section titled “Rejected and unidentifiable revisions under shared-account fences (PR 51, round 4)”PR 51 changed booking intake against the pre-PR-47 main; this section records how its per-entry parsing combines with the shared-account fences above.
- Rejected placeholder. The client keeps only revision/booking IDs, property and
insertion timestamp of a body that fails full parsing. Core archives it, saves an
invalid_revisionquarantine and acknowledges it only when its property is linked, room-mapped and not unlinked, in either account mode. Exclusive mode normally consumes unmapped revisions as non-routable, but a placeholder carries no booking facts to route, so it stays upstream for an unmapped or inert target. The scope is checked before any by-ID read and again under the connection lock in the applying transaction. - Unidentifiable entry. An entry without a usable identity, a readable property ID or
an insertion timestamp, or a placeholder whose property is null, is never archived,
quarantined, acknowledged or applied. It cannot be compared with the property filter, so
it does not trip the fence; a readable entry of another property still stops the drain.
It sets the health failure flag, keeps
last_drainunchanged and holds the availability barrier of the property whose feed returned it (account-wide: every property). Later entries and other active properties continue; when a whole page of 100 makes no progress, the worker reads the next page of that feed. - Every path. The feed drain and webhook-triggered drains read only active targets in shared mode. History recovery skips unidentifiable entries and placeholders outside that scope, and makes no current-booking read for a placeholder. Retry checks the same scope before refetching; a corrected body replaces a placeholder only under the same revision and property IDs. A tie whose authoritative current revision is unreadable leaves the newer revision upstream untouched. Pending acknowledgements of placeholder receipts require an active link even in exclusive mode, so a later relink of the local property (which erases the unlink tombstone) cannot acknowledge a retired target’s placeholder.
PostgreSQL + PgBouncer + simulator regressions in
packages/server/test/integration/booking-import-scope.integration.test.ts cover unmapped,
inert, unlinked and unreadable-property cases, exclusive mode with an unlinked former target,
history recovery, retry, tie reads, signal-triggered drains and a 100-entry stuck page.
Migration 0024 closes the former exclusive-mode relink gap: a retired target remains fenced
after its local property is linked elsewhere. The shared-account live test still uses only
the dedicated test property and keeps the company account in shared mode.
Cancelling a stay that happened
Section titled “Cancelling a stay that happened”Stayed nights are facts: a channel cannot un-stay a guest. A room whose guest is in the house
or has departed has stayed (hasStayed in core/reservations/rules.ts, the one rule).
- In house: an OTA cancellation or omission remains a
local_stay_conflictquarantine. - Departed: the cancellation or omission is applied as an OTA fact, not quarantined: no
local action could resolve a departed stay. The cancellation applies to the room as it was
locally: the room gains
ota_cancelledand keeps its nights at their frozen prices, occupancy,released_on,checked_out_on, unit history, sold inventory and the charges of its stayed nights. The revision’s amounts, occupancy, dates and room type for that room are not applied, so a cancellation carrying other or zero prices reverses or re-prices nothing, and one that also changes the dates is not quarantined. Later revisions that still send the room are ignored for it, prices included. The operator’s way out of the in-house case ends here too: check the guest out, then retry the quarantined revision. - Nobody stayed (expected or no-show): the room is released from its arrival, as before.
- Reservation header: it becomes
cancelledonly when every room is OTA-cancelled and no guest stayed. This is the narrowest change that keeps the stay workable: folio postings, inventory, reports and Planning already treat a non-cancelled header with a departed room correctly, whereas a cancelled header would need an exception in each of them (extras refusal, room-charge reconciliation, inventory deltas, the reports’ void rule, Planning history). Local cancellation already works this way: a departed room keeps its own outcome and the reservation is not cancelled (ADR 0032). So the folio accepts extras, payments, refunds and reversals, and reports keep the stayed nights and revenue. Reports count cancellations by cancelled header (ADR 0031), so, like any partial OTA cancellation, such a booking is not counted as a cancellation. OTA reservations receive no e-mail either way. - Terminal for the stay: the cancelled stayed room stays as it happened whatever later revisions send. Since ADR 0048, a later revision can reinstate the rooms nobody stayed in, and add live ones.
- Visible: the reservation summary and the room show Cancelled by channel; stayed nights are not shown as released; local room actions, including undo check-out, stay refused. Planning and Today keep the departed stay; only rooms nobody stayed in are hidden.
Limits: an OTA date or room-type change of a departed stay the channel has not cancelled is
still a local_stay_conflict quarantine that no local action resolves; its price amendments
keep the append-only reconciliation of ADR 0029. Reservations whose departed rooms were cancelled
under the earlier rule (stayed-night charges reversed, header cancelled) are not repaired. An
OTA cancellation after a no-show still moves that room’s release back to its arrival, which
releases nights the no-show had kept as sold; OTA no-shows carry no fee, so no money moves.
Retired Channex targets (2026-11-01)
Section titled “Retired Channex targets (2026-11-01)”A live room type or rate plan is retired through ADR 0034’s verified workflow, which records each
retired Channex target in channex_retired_targets (migration 0073), keyed by workspace, kind and
Channex identifier so that relinking the local property does not erase it.
- A revision whose room names a retired Channex room type is quarantined as
retired_target, never projected as a normal amendment and never into a replacement room type. It is acknowledged, kept as an encrypted archive and replayed automatically once per connection version exactly likeunknown_room_type; the retirement’s switch re-arms the property’s such receipts once. - An administrator with
channex.configureresolves it withPOST /channex/retryandapply_to_former_room_type: true: the revision is read again and applied to the room type the retired target was mapped to, audited aschannex.revision_former_room_type_applied. The option is refused (request.invalid) for any other quarantine. Mapping the Channex room type again removes its fence in the same transaction, and the next replay imports normally. - A retired Channex rate plan keeps resolving to the local plan it was sold under
(
ratePlanMappingsadds the retired plans not mapped now): plan identity retention for the package split and the sold counts, never an attribution to the replacement plan. - Unlinking the property cancels its active retirement; the fences stay.