Skip to content

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.
  • 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@1 and channex.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.

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.

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 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.

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.

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.

  • 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.

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).