0048. Channex drift repair, provider lookup, alerts, historical import and reinstatement
- Status: Accepted
- Date: 2026-10-31
- Completes the planned parts of ADR 0034 (provider request lookup, nightly remote drift repair, external alerts) and ADR 0028 (historical booking import, reinstatement). Live Channex certification stays the owner’s action: see the certification checklist.
Observable acceptance criteria
Section titled “Observable acceptance criteria”- Every night, from 02:00 property time, GuestCraft reads Channex’s availability, rates and restrictions of each taken-over property over the verified horizon, compares them with what it last published and republishes only the differences through the ordinary publisher. Channels → health shows the last check, the differences found, those still being republished, those Channex rejected, and a failed or waiting check with its reason.
- A send whose response was lost is settled early when Channex holds its values, and otherwise by the bounded repair. The send history shows how many times it was looked up and what was found.
- Sync failures, overbookings, unrepaired drift and quarantined revisions become in-app notices
on Today, Planning, Reservations and Channels, each with a link to where it is fixed, and
webhook events
channex.alert_opened@1andchannex.alert_resolved@1. No e-mail or SMS. - An administrator imports a linked property’s existing future bookings from Channels, after a preview of what would be imported, left unchanged, skipped, reviewed and overbooked.
- A booking the channel cancelled and then made active again is active in GuestCraft, with its
nights sold again, an overbooking flagged if the rooms are gone, an audit entry and event
reservation.reinstated@1, and the date shown on the reservation.
Nightly drift repair
Section titled “Nightly drift repair”Where it runs. The drift check is a phase of the existing publisher run (channex.push_ari),
not a separate writer: it holds the property’s numbered lease, so no send of GuestCraft can change
Channex while it reads, and the repairs go out through the same claims, pacing, retries and
leases as every other change. A system job channex.ari_drift wakes the publisher of each due
property; the resident worker schedules it every five minutes, and a hosted drain enqueues it when
it starts, next to the night audit dispatch. The request pump’s due probe also answers true when a
drift check is due, so ordinary traffic wakes the drain as it does for night audits (ADR 0043).
When. One check per property and local night, from driftLocalHour (02:00) property time,
for properties with publication enabled, not stopped, owned by this instance and with at least one
mapped plan. The check’s row (ari_drift_checks) makes it idempotent: a completed or failed
check is not repeated that night. Beginning an attempt claims it: an attempt that ends without an
outcome (a lost lease, a crash) is retried 10 minutes later rather than at once. Like the night
audit’s schedule (ADR 0043), these deadlines use the injected application clock, the clock that
decides the property’s local night; the publisher’s own deadlines keep using database time. “Nightly” therefore means once per local date: a property whose
publication is enabled in the afternoon is checked at its next run that day.
What is compared. [today, horizon_until): the verified horizon, never queued-but-unverified
days. Channex is read in windows of 92 nights (GET /availability, GET /restrictions); rates are
compared in the currency’s canonical decimal form; a restriction’s explicit closure is Channex’s
stop_sell_manual, because its stop_sell is also true for zero availability or rate. The
expected value is the verified published cache (ari_published), field by field, for the room
types and plans GuestCraft publishes now. A cache cell Channex does not hold, or holds with a
different field, is a difference. Nothing else in Channex is read or changed.
How it repairs. The differing fields are removed from the published cache (ari.forget), and
one drift change per target and run of consecutive nights is queued. The publisher then
computes current values as usual; because the cache no longer claims those fields, its delta sends
exactly them, plus any genuine local change. Availability is always sent as absolute values, as
before. The check runs last in the run, after the ordinary publication, and sends nothing itself:
a send made in that run leaves it waiting, and the continuation publishes its repairs. This
keeps Channex’s certification rule “send only changes; no timer full sync”: the nightly work is a
read, and only differences are written.
Booking safety. Channex lowers availability by itself for OTA bookings, so a read taken while
a booking is still on its way would look like drift and its repair would reopen a sold room. The
check waits (booking_hold) while the booking barrier is held, the property is degraded or
recovery is required, and discards its reads (booking_hold) when the barrier generation moved
while it read: a booking signal arrived. It also waits while a send is open (publication_busy):
the values are still changing. A plan withheld by a plan issue does not make it wait
(ADR 0027, “Occupancy pricing, children and packages”): the check compares the stop sell
published for it and leaves out a Channex plan that cannot be addressed; publication_closed
remains only in checks recorded while a plan issue closed the whole property. The reads renew the lease before each window, and stop when it is lost. A
read that returns nothing at all for a published room type or rate plan (a property the key no
longer reaches, a target removed in Channex) proves nothing about the published values, so the
check fails (target_missing) instead of resending the whole horizon. A Channex failure of the
read is recorded as upstream_unavailable or upstream_rejected; a failed attempt is retried
after 10 minutes and the third failure ends the night’s check as failed. None of these pauses or
stops publication, unlike a failure of a write. A signal that has not reached GuestCraft when the reads
complete remains the ordering risk ADR 0034 already accepts for every availability send.
Provider request lookup
Section titled “Provider request lookup”A send whose outcome is unknown (lost response, timeout, expired sender) is looked up at most three
times, 30 seconds apart by database time, before the existing 120-second bounded repair. Each
lookup reads the send’s nights back (GET /availability or GET /restrictions):
- If Channex holds exactly the send’s values, the desired state is satisfied: the send is settled
(
lookup_values_held), its values enter the verified cache and its changes complete, as for a verified task. This does not prove that the request ran: another writer may have written the same values, which is harmless for publication. The audit outcome, the API description and the screen say exactly that. - Otherwise the lookup waits (
pending), and after three lookups the 120-second bounded repair recomputes and sends the current values, as before.
The lookup never concludes that a send did not run. Channex’s processed tasks carry no request identifier or payload, and an unknown send has no task ID, so no task is evidence about one request: a task listed after an older task of GuestCraft can still precede this send, and clocks cannot order them either. A first version derived “not applied” from such tasks and sent the values again at once; a request Channex processed late then overwrote the newer values with stale ones (review of PR #296, B1). Waiting keeps the endpoint closed to further sends until the lookup finds the values or the bound passes, so a request Channex processes late within the bound is followed by GuestCraft’s next send, computed against the verified cache.
Each lookup is audited (channex.ari_send_looked_up, system actor, with the send and the outcome
values_held, pending or unavailable). A Channex failure of a lookup counts as a lookup that
learned nothing (unavailable): the lookup is an optimization in front of the bounded repair, so it
never pauses or stops publication. Two late-packet cases remain ADR 0034’s accepted risk, bounded
by the write timeout and caught by the nightly drift check: a request Channex processes after the
120-second bound, and a request still in flight when the lookup found its values already written
by another writer.
Alerts
Section titled “Alerts”Alerts are persisted per property and kind (channex_alerts), at most one open per kind, so that a
transition is delivered once as a webhook event and the in-app notice knows when it opened:
| Kind | Opens while | Code |
|---|---|---|
sync_failure |
publication stopped, booking intake held past its 15-minute limit, acknowledgements failing, changes other than drift repairs rejected past the retry limit, a failed target retirement (ADR 0034), or mapped plans withheld by a plan issue (the stored planIssues) |
the first cause: the ARI stop code, booking_feed_unavailable, booking_ack_failed, publication_rejected, retirement_failed or plans_withheld; count is the number of causes |
overbooking |
OTA reservations of the property are above the rooms there are | count of reservations |
drift_unrepaired |
the latest night’s drift check failed for good (until a later night’s check runs), or a drift repair was rejected past the retry limit | the check’s failure code, or rejected |
quarantine |
revisions of the property wait in the review queue | count |
They are level-triggered: a system job channex.alerts (every minute on the resident worker,
enqueued when a hosted drain starts) recomputes each linked property’s conditions and opens,
refreshes or resolves alerts. The conditions come from one purpose-built read of the property’s
own rows (channexAlerts.evidence): whether it is linked, its stop code, booking intake held past
its limit, and uncapped counts of failing acknowledgements, rejected ordinary changes, rejected
drift repairs, overbooked OTA reservations and quarantined revisions, with the latest night’s
failed drift check. It never reads the health screen’s diagnostics, which list at most 100 issues,
and never the workspace’s mappings, so a minute’s sweep costs the same few statements for each
property. Opening and resolving are audited (system actor) and emit channex.alert_opened@1 and
channex.alert_resolved@1, added to INT’s event catalog with a channex_alert object whose
locator finds the property. A property that is no longer linked has its alerts resolved.
Concurrent reconcilers cannot open two alerts of a kind: the open alert is unique in the database
and only the inserting one emits the event. One property’s failure does not hold the others back.
On a hosted installation, alerts move only while a drain or pump runs, like the rest of the
background work (ADR 0034, “Request triggers”).
In-app delivery. The alerts replace the former degraded/overbooking banner of Today, Planning
and Reservations and appear in Channels; each kind links to where it is fixed. A screen showing
them reads them again every minute, and reads the property’s alert transitions every 15 seconds
(channex.alerts.transitions, same permission as the list): the openings and resolutions as the
two domain events recorded them. Each new transition shows once as a notice (a toast that the
screen’s banner or card backs, with the fix link for an opening) and reads the alerts again. A
reconciliation that changes nothing writes no event, so it raises no notice. Each read starts five
minutes before the previous one ended, so an event committed after a later one is still read; the
screen shows each transition once by its event identifier, which the browser keeps per workspace
and property, so a reload or another screen does not show it again. A screen opened for the first
time, or after ten minutes without a read in that browser, marks the last five minutes’
transitions as seen without showing them: what happened meanwhile is history, and the banner shows
the alerts open now. A read never starts more than an hour back. A screen’s own
recovery actions read every query again, alerts included. External delivery is the webhook catalog
only: the owner removed e-mail, and push notifications are out of scope.
Historical booking import
Section titled “Historical booking import”Explicit. An administrator (channex.configure, browser session) of a linked, room-mapped
property previews and then queues the import from Channels. One import at a time per property
(unique unfinished import); starting one is audited.
Reading. GET /booking_revisions?filter[property_id]=…, newest first, 100 per page, at most 10
pages (1,000 revisions). The first revision seen of each booking is its current one; a page that
shifted because a revision arrived meanwhile repeats entries and never skips one. The preview
reads what GuestCraft already holds in batches (receipts by revision, reservations by booking). A revision of
another property refuses the whole read: on a shared account it belongs to someone else. The
account-wide GET /bookings used by exclusive-account recovery is not used, for the same reason.
When the bound does not reach the oldest revision, the preview and the import say so
(complete: false); older future bookings still arrive if Channex sends a new revision.
Applying. Each current revision of a booking still to depart goes through the booking import’s
own apply, so the live feed and the import are idempotent with each other (receipts per revision,
connection lock, booking head). A cancellation of a booking GuestCraft never had is skipped;
conflicts are quarantined into the existing review queue; an import above capacity is applied and
flagged as an overbooking, as every OTA fact is. A revision Channex already acknowledged (no longer
in its feed) is recorded as acknowledged, so the drain does not owe Channex an acknowledgement for
it. That record is an acknowledgement-only update under the connection lock, like every receipt
write: it sets the acknowledgement time only when none is recorded and clears an acknowledgement
failure, and never rewrites the receipt’s state, reason or reservation, which the live feed may
have changed since the revision was applied. A job runs at most 200 bookings, then queues the import
again and continues after a cursor (the last booking in the import’s order): bookings that change
meanwhile arrive through the feed.
Fencing. A runner claims the import with the next claim number (claim, migration 0043) and
holds it for 150 seconds (claimed_until), renewing it before each page it reads and each booking
it imports; another runner leaves an import whose claim has not lapsed alone, and takes over one
whose claim lapsed. Every progress save and every final transition is conditional on the runner’s
claim, and its audit entry and event are written in the same transaction only when that transition
wins. A runner that finds its claim gone, because another runner took the import over or the import
ended, stops at once and writes nothing more. A renewal comes before each booking’s own
transactions; a runner paused for longer than its claim between the two can apply one booking more,
which the booking import’s receipts make idempotent, but cannot record it. A Channex failure or an
unlink ends the import as failed, and an unfinished import that no runner touched for 15 minutes (a
crash, a job that expired) ends as stalled when an administrator starts a new one, so it never
blocks the property; its runner, should it resume, is fenced out. Starting again is safe. The preview
runs the same read and the pure projection, and counts overbookings cumulatively against current
availability, without writing.
Reinstatement
Section titled “Reinstatement”ADR 0028 kept cancellation terminal. Now a newer non-cancelled revision makes active again the
rooms the channel had cancelled, unless their stay happened: stayed nights the channel cancelled
remain a fact no revision undoes. The archive head treats a booking as cancelled only when its
newest revision is a cancellation, so the newer revision is applied rather than marked stale.
A reinstated room has its release cleared (its nights are sold again) and no unit, since its
cancellation released the unit; the inventory delta and the overbooking check are the import’s
usual ones, so capacity is checked and an overbooking is flagged, never refused. The reservation
records ota.reinstatedAt, the import audits reservation.reinstated (event
reservation.reinstated@1, in the catalog), and the reservation screen shows the date.
Decisions for review
Section titled “Decisions for review”- Drift is a phase of the publisher run under its lease, rather than a separate job with its own writer, so that reads and repairs cannot race GuestCraft’s own sends.
- The drift comparison uses the verified cache, as the brief says (“what GuestCraft last published”), and acts on availability only when booking intake was settled before and after the reads.
- The lookup settles only when Channex holds the send’s values, and otherwise waits for the bounded repair, because tasks cannot be matched to requests.
- Alerts are level-triggered and persisted for exactly-once transition events, decided from uncapped per-property aggregates; screens show the transitions once by event identifier.
- The historical import is bounded to 1,000 revisions per property and reads per property only.
- Reinstated rooms come back unassigned.
Remaining limits
Section titled “Remaining limits”Real Channex certification, retirement of live mapping targets, occupancy pricing and distribution caps remain planned. Sandbox workspaces reach Channex staging since ADR 0050, so certification is the owner’s to schedule. Hosted installations still need a 1–5 minute scheduler for timely work between requests (ADR 0034).