Skip to content

0044. Channex guest messages

  • Status: Accepted
  • Date: 2026-10-27; amended through 2026-10-31 to cover ownership, unlink, reactivation, request decisions, retention and erasure
  • Extended by: ADR 0050: Messages calls reach the workspace’s Channex environment, and attachment links are kept only on its host.

An administrator of a mapped property sees whether Messages is active, its health and last synchronization, and whether a stop or an activation has not finished in Channex. Activation happens only after a dialog that names the property and its Channex ID, states Channex’s extra per-property charge, the retention limits and how to uninstall, and after an explicit acknowledgement; the API requires acknowledge_cost: true and records it in the audit. It is refused on a property where another system already installed the Messages app. An installation that Channex never confirmed to GuestCraft is shown with its ID and two audited choices: confirm it as GuestCraft’s, or leave it alone. Deactivation is confirmed, stops reading and sending at once and uninstalls GuestCraft’s Channex app. A front desk member or administrator sees the workspace or property inbox, undecided requests first, filters and pages it, opens a thread (focus moves to its heading), sees timestamps, the reservation link and request details, marks it read, and sends a reply whose variables the server resolves; a draft with an unknown or empty variable cannot be sent. Requests can be accepted, declined or pre-approved in GuestCraft before their deadline, with a stop barrier and an idempotent resolving state. A reply accepted by Channex reaches the guest once, even if GuestCraft fails to record it. A failure of one property or thread never blocks the others, the outbox, or the booking safety net. Housekeeping cannot see or operate the inbox. Previews and restored instances never issue Channex writes.

The public OpenAPI (SHA-256 in packages/channex-client/test/spec.lock.json) documents GET /live_feed with filter[property_id], filter[event], filter[inserted_at][gte|lte], order[inserted_at] (most recent first by default) and pagination; the message, inquiry, reservation_request and alteration_request payloads; GET /live_feed/{id}; POST /applications/install (“a Property may hold only one installation of the same application”; its body names only the property and the application), GET /applications/installed (the caller’s active installations across their properties, with no parameters; each item carries its ID, property, application and settings, “and neither the install time nor the active flag”) and DELETE /applications/{application_installation_id}/uninstall; and the Booking Messages operations. It also documents POST /live_feed/{id}/resolve, used by the Messages amendment below (the earlier read-only request behavior is superseded by the decision endpoint). The Messages Collection page documents GET /message_threads, GET /message_threads/{id}, its messages, sends and POST /message_threads/{id}/no_reply_needed (Booking.com only), with no query parameters. Channex says the Messages app costs extra depending on property type; the public docs give no price, so GuestCraft cannot quote or absorb it.

Discovery through the property-filtered live feed. Threads are discovered from message events of GET /live_feed?filter[property_id]&filter[event]=message, read in ascending order from a stored cursor; requests from the three request kinds the same way. The inclusive inserted_at filter returns the cursor’s own instant again, so the cursor remembers the events it already processed and pages within one instant only when a full page shares it. Only announced threads are read: metadata once (GET /message_threads/{id}), then messages from the newest towards older ones until a page holds known history: forward from page 2 when the listing is newest first, backward from the last page when its order_direction is asc. Known history means a message stored before the read began (its sequence is at most the thread’s message count at that moment); messages the read stored itself never count, even when new arrivals shift them onto the next page. A history longer than one drain’s ten pages keeps its next page, listing order and that boundary on the thread’s sync row and stays due; later drains read the newest pages again and continue the older history from that page until it reaches the boundary or its end, so each message is stored once even when new messages shift the pages meanwhile. A changed listing order reads the history again. A conversation shows its latest 1,000 messages; older ones stay stored and are read page by page (amendment “History paging”, below). Open requests are re-read by ID every ten minutes because a resolution changes an existing event. Each drain is capped (feed pages, threads, message pages, outbox items); the remainder continues in a follow-up job.

Fail closed on shared accounts. Any item of another property or event kind in a feed, thread, message or request read stops that property before anything of the page is stored, sets the visible filter_violation health and is audited. The installations of a property are known only from the documented, account-wide GET /applications/installed: GuestCraft keeps the IDs of that property’s channex_messages items and drops every other item unread, never storing or logging it. The simulator ignores the undocumented thread-list filter and answers only documented fields; a contract test validates it against the OpenAPI.

Isolation. The outbox is sent first and independently; each property’s ingest is isolated, with health ok, failing (with a safe code) or filter_violation; a 403 means Messages is not active upstream: the property is deactivated locally, its queued replies are cancelled, GuestCraft’s installation is uninstalled and the admin sees why. A thread Channex refuses records its own failure and backoff. The channex.safety job enqueues booking drains first and only schedules channex.messages_drain for workspaces with active Messages, through a narrow security-definer function; a messages error never prevents booking drains. Only verified webhooks whose event is a message kind wake the messages drain.

No double send. Channex documents no idempotency key. Only the remote call is inside the error handling: once Channex accepted a reply, its Channex ID is committed first, in its own transaction, and any later local failure leaves the row sending. A claim older than the two-minute in-flight bound becomes uncertain, audited, and is never sent again. Reconciliation marks it sent when the ingested thread contains its Channex ID; a property message with the same text is only a hint. An operator can dismiss an uncertain or failed reply (audited). Only a definite failure can be retried; a retryable failure waits with exponential backoff and a delayed continuation job. A delivered or dismissed reply keeps no text or upload in the outbox.

Requests (superseded by the Messages amendment below). Requests are stored on their own, with or without a thread (the thread is optional in the payload), and are read-only: the operator accepts, declines or pre-approves them in the channel’s extranet, and the ten-minute re-read by ID records the outcome Channex reports. Requests to book carry the documented 24-hour hold as an estimated deadline; inquiries and alteration requests have no documented deadline. A request counts as actionable (awaiting the operator’s decision in the extranet) while unresolved and before its deadline.

Inbox state. Read state is shared per thread and compares ingestion order with a forward-only marker, so a message ingested after a click stays unread. “To answer” compares the latest guest message with the latest property or agent reply by Channex time and Booking.com’s “no reply needed”; system messages never hide a question; an actionable request is always to answer. Lists order actionable requests first by deadline, then by latest message, with keyset cursors. The thread ETag changes with new messages, queued replies and request updates; a reply can carry it and a newer thread answers messages.thread_changed, which the composer shows as a warning before sending.

Composer. Variables are resolved by the server from the linked reservation (guest name, first arrival, last departure), the property name and, for an inquiry, its documented booking details; dates are formatted for the template’s language by the server’s formatter. The check-in time is typed by the operator (validated HH:MM) because properties store none yet. An unknown variable or a used empty one blocks sending. Templates are versioned resources per property and language.

Activation. GuestCraft operates Messages only on an installation confirmed as its own (see the amendments of 2026-10-29). The webhook is registered before the paid install, so a rejected registration never leaves a billed install behind, and after the install attempt commits, so whatever an activation made at Channex has a durable record; the installation ID is stored, and an active activation always has one. Deactivation stops locally first (reading, sending and queued replies), then uninstalls GuestCraft’s installation and removes its message webhook. Both are audited and versioned (If-Match).

Data. Thread titles, messages, outbox payloads, request details and templates are encrypted with the integration keyring, bound to their record IDs; credentials:reencrypt rewrites all of them in batches. A record that cannot be decrypted is shown as unreadable rather than failing the inbox. Workspace retention settings redact expired message bodies and attachment payloads in bounded batches, and messages.erase records guest-content erasure. Attachments are separate messages (Channex ignores attachment_id when text is present), JPG/PNG/PDF up to 2 MB; inquiry threads without a booking use the thread message form in the simulator; this attachment form still needs upstream certification.

API. Resource-oriented: Messages state is a sub-resource of the mapping (/channex/mappings/{property_id}/messages, PUT with If-Match), threads, requests and templates are collections with item ETags; queued work answers 202 with Location, creation 201; read state is PUT …/read_state; real transitions are actions (retry, dismiss, no_reply_needed, install_attempt/confirm, install_attempt/forget); operationIds differ from permission codes; problems are specific.

Main integration, property access and open item

Section titled “Main integration, property access and open item”

Migration 0025 follows main’s retired-target migration 0024. API revision 3.6.0 includes messages.read, messages.send and messages.configure in the permission catalog; admin has all three, front_desk read and send. They are property codes (ADR 0036): every operation checks its grant on the property of the thread, request or template; the workspace inbox, summary, request list and synchronization request use canAcrossProperties, and selected-property RLS applies to every message table. Active Messages makes the Channels mapping “in use”, which blocks changing the upstream property while it is linked.

Messages activation requires a confirmed linked property with a mapped room type. Shared accounts additionally require channex.manage_account_mode. Unlink stops Messages locally, marks pending replies failed/property_unlinked, uninstalls GuestCraft’s Messages installation (its stored installation ID, or the one Channex’s answer or an operator confirmed for an unfinished activation; a 404 means it is already gone; it waits while an install attempt is not settled) and removes only GuestCraft’s non-global Messages and booking webhooks on that property. Another system’s installation is never touched. The other linked properties keep working. Any upstream cleanup failure leaves the unlink pending, visible in Channels with a retry; a successful uninstall is recorded and audited at once.

Activation generation. Every activation raises the property’s generation. A drain works for one binding (local property, upstream property, generation). Before each Channex request and inside each transaction that stores a result, holds() checks that the binding is still active, linked to that same upstream property and room-mapped; the storing check locks the activation row (FOR NO KEY UPDATE), which unlink also locks, so it serializes with unlink: once unlink commits, no late answer is stored, cursors and health are left as unlink wrote them, and the stop is not reported as a failure. A late 403 of an older generation never deactivates a newer one. A check that commits before its request cannot stop that request; the amendment of 2026-10-29 explains why it need not.

Sends. A claimed reply is bound to its thread’s upstream property. The check runs right before a text send, and again after an attachment upload and before the guest-visible booking message. A send that loses the race is finished failed with property_unlinked (or the deactivation reason), never left pending. If Channex already accepted a reply, its outcome is still recorded as sent, but the copy is not added to a history whose activation ended.

Binding to the upstream property. Threads, sync work, outbox rows and requests store the upstream channex_property_id; a composite foreign key binds each outbox row to its thread’s. An item whose upstream property is not the local property’s linked one is retired: read-only history. It is never due for a drain, never claimed or re-read, never “to answer” or an open request, and send, attach, retry and no-reply-needed answer 409 messages.retired before any Channex call; no-reply-needed checks the activation again right before its call. property_unlinked is terminal: Retry refuses it even after a relink to the same upstream property. Dismiss and the read marker stay available on history because they are local. Relinking to the same upstream property resumes its threads; a different one starts with fresh feed cursors.

Amendment 2026-10-29: Channex enforces the stop

Section titled “Amendment 2026-10-29: Channex enforces the stop”

Why. The admission boundary cannot be proved on the client: a lease, then a transaction-held fence with a watchdog, a reserved recorder connection and a split pool. Testing found new failure modes (an orphaned paid installation after a lost install answer, the reserved connection queued at the pooler behind the calls it served, the watchdog killing a guest-facing process under legitimate load). A client cannot prove that a disconnected process will not send; only the server can refuse. Channex refuses the Messages requests of a property once its Messages application is uninstalled, and GuestCraft can uninstall only an installation it made. This amendment replaces the fence amendments of 2026-10-28 and 2026-10-29: their fence and call tables, transaction-held fences across HTTP calls, transaction_timeout=0, the watchdog, the reserved connection, the pool split and GUESTCRAFT_WORKER_POOL_SIZE, the fence age in Channels health, and the 503 that waited for calls are removed.

GuestCraft operates Messages only on its own installation. Activation first lists the property’s installations (GET /applications/installed, kept to that property’s channex_messages items). If one exists and no install intent of GuestCraft stands, another system already manages the property’s guest messaging, and one property must not have two systems replying to its guests: activation answers 409 messages.installation_foreign and changes nothing. An active activation always records its installation ID (a database check), so no activation on another system’s installation can exist.

Install intent and reconciliation. GuestCraft commits an install intent before the paid install and treats an installation listed while that intent stands as its own. The design accounts for another system installing in the window between the list and the install; the current rule is that only Channex’s answer to GuestCraft’s own install, or an operator, confirms an installation as GuestCraft’s.

One stop path. Unlink, deactivation and a 403 (not_active_upstream) stop the same way:

  1. Commit stopping(g, reason). Admission for generation g closes in that transaction: every call is admitted by a committed check that g is still active, so none is admitted after it. Pending replies of g end failed with their reason, each audited with its prior error.
  2. Uninstall GuestCraft’s installation, then record that it is gone (compare-and-set on g and stopping). Each activation has its own installation ID, so a stale stop never removes a later one; a 404 means it is already gone. If an unfinished activation left an install attempt, the stop settles it as the current rule describes: it removes only an installation confirmed as GuestCraft’s, and waits while the attempt is not settled. Then, after checking that g is still this stop’s, remove GuestCraft’s Messages webhook (and, for unlink, its booking webhook).
  3. Commit stopped(g) (and, for unlink, finish the pending unlink) and report success.

A stop never waits for a call admitted before step 1. If that call reaches Channex after the uninstall, Channex refuses it. If it arrived before, it was already on the wire: its outcome is recorded honestly (sent, or uncertain when the answer was lost), audited, and never resent. A 403’s stop runs in the drain that received it. A request admitted between a 403 and the commit of stopping is covered the same way: once GuestCraft’s installation is gone, that request is refused too. If an upstream step fails, the stop stays pending (stop_pending in the Messages state, a pending unlink in Channels) with admission closed. Deactivating or unlinking again resumes it; an activation finishes it first; a 403’s stop is also resumed by the next drain of the workspace. Only this case, and an install attempt that is not settled, answer 503 (messages.cleanup_failed, channex.unlink_pending). A send claimed longer ago than the in-flight bound (the two-minute bound) without an outcome is ended uncertain by the stop (audited), because it is no longer on its way; a fresh one is left to its worker.

Generation and history invariants. Each activation raises the generation and every work item is bound to it: outbox rows store their generation, and the SQL for claim, automatic retry and operator Retry requires an active property of that exact generation, so a relink, even to the same upstream property, never revives a reply of an ended generation. Each announcement and progress write of a thread’s sync row assigns a fresh claim token, so a stale drain never deletes new work or rewinds a failed page. A long history keeps its history_boundary. Messages of a thread are ingested under the thread’s row lock. A lost send answer is uncertain, never resent. A cap on concurrent Messages calls per workspace is not kept: calls no longer hold a database connection during their HTTP request, so they cannot starve booking intake of connections.

Channex refuses each admitted endpoint request after the installation is removed; this guarantee is endpoint-specific and applies while no new installation exists.

Section titled “Amendment 2026-10-29: install ownership, unlink, reactivation and decisions”

Behavior. Four gaps in the previous rule were: (1) an installation of another system made between GuestCraft’s list and its install was adopted, then uninstalled; (2) unlink could report success while GuestCraft’s paid install was still on its way, and lose the record needed to remove it; (3) a request decision admitted before a stop could take effect after it, because POST /live_feed/{id}/resolve is not refused after an uninstall; (4) the guarantee was stated for every endpoint, and a send of an earlier generation could be accepted by the installation of a reactivation.

Ownership is confirmed, never inferred. Channex offers nothing that ties an installation to the request that made it: the install body names only the property and the application, and the installed list carries the installation’s ID, property, application and settings, but no creation time or creator; no endpoint updates an installation or reads one by ID. So an installation is GuestCraft’s only when Channex’s answer to GuestCraft’s own install named it, or when an administrator confirmed it. Each activation commits an install attempt before it makes anything at Channex (its webhook, then the install); the attempt records what that request may have made until its outcome is settled:

  • installed: Channex answered with the installation’s ID. Activation finishes with it; if the activation loses a race against unlink, it uninstalls exactly that ID, and if that fails the attempt keeps the ID so that the pending unlink removes it.
  • A definite refusal (a 4xx answer) installed nothing: the attempt ends, GuestCraft removes the webhook it registered, and an installation listed afterwards belongs to another system (messages.installation_foreign). It is never adopted or uninstalled.
  • unconfirmed: the answer was lost and a list shows an installation. Messages stays inactive (409 messages.install_unconfirmed), and the Messages card shows the installation ID, explains that Channex did not confirm it and offers two audited actions with messages.configure (and, on a shared account, channex.manage_account_mode to confirm): confirm it as GuestCraft’s (…/install_attempt/confirm, the attempt becomes installed) or leave it alone (…/install_attempt/forget, the attempt ends and nothing changes in Channex; the installation then counts as another system’s).
  • pending: the answer was lost and nothing is listed, so the install may still arrive (503 messages.install_pending). Within the in-flight bound nothing is listed or installed again. After it, the next activation or unlink lists again: nothing listed ends the attempt with nothing installed, and a listed installation makes it unconfirmed.

The in-flight bound. The adapter gives every Channex write one five-second attempt and never repeats it. Two minutes (IN_FLIGHT_MINUTES) after GuestCraft started a write, its request has ended at GuestCraft’s side, and GuestCraft assumes that Channex no longer acts on it. The bound settles pending install attempts and sending replies; the live test measures Channex’s answering times against it.

Unlink waits for an unsettled install. While an install attempt of the upstream property it unlinks may still create an installation (pending within the bound) or lists one nobody confirmed (unconfirmed), the unlink’s stop answers 503 channex.unlink_pending, keeps the pending unlink and the attempt, and never reports success. The Messages card keeps showing that property (linked: false) with its attempt, so an administrator can settle it. The attempt is settled when its installation was confirmed and then uninstalled, when it definitely failed or listed nothing after the bound, or when an administrator left the installation alone.

Reactivation waits for old replies. A new generation’s installation would accept a send of an earlier generation still on its way. Activation therefore answers 503 messages.replies_in_flight while any reply of the property is sending. A reply sending longer than the bound becomes uncertain, is audited (channex.message_uncertain) and is never resent; only then does reactivation proceed. The drain’s own sweep of stale sending replies is audited the same way.

Decisions are removed, and planned (superseded by the Messages amendment below). Accepting, declining and pre-approving requests used POST /live_feed/{id}/resolve, a Live Feed action that the documentation does not tie to the Messages installation and that the simulator accepted after an uninstall. A decision is guest-visible (accepting a request to book charges the guest), so no stop could promise that a decision admitted before it would not take effect afterwards. The API route, the SDK operations, the use case, the UI actions, the client, adapter and simulator support and their tests are removed; no permission code existed only for decisions (they used messages.send). Requests stayed visible, read-only in this earlier decision; the current workflow is defined by the Messages amendment below. Deciding from GuestCraft was planned as a separate feature with its own stop barrier, for example a durable decision outbox that a stop settles before it reports success, and a live check of what Channex does with a decision after an uninstall.

GuestCraft call Channex endpoint Once the property has no Messages installation Evidence Live test
Text send POST /message_threads/{id}/messages refused (403); nothing reaches the guest simulator, the documented 403 for a property without the Messages app must confirm
Attachment send POST /bookings/{id}/messages refused (403) simulator, as above must confirm
Inquiry attachment send POST /message_threads/{id}/messages {attachment_id} simulator refuses (403); upstream support is unverified simulator only; documented booking attachment form does not establish thread support certify supported payload and uninstall behavior
Request decision POST /live_feed/{id}/resolve not protected by uninstall; stop returns retryable 503 while a decision or reconciliation remains in flight simulator finality and stop-race tests; Live Feed documentation record resolve after uninstall, finality, outcomes and stop timing
“No reply needed” POST /message_threads/{id}/no_reply_needed refused (403) by the simulator; its effect on guests is unverified simulator; the documentation describes it as a Booking.com response-scoring marker with an empty payload, which suggests no chat reply but states neither guest notifications nor its status after an uninstall must confirm the status and the guest’s side
Thread read and thread page GET /message_threads/{id}, GET …/messages refused (403) simulator, as above must confirm
Upload POST /attachments not established: the simulator accepts it, while Channex’s documentation lists a 403 for it; GuestCraft promises neither. The file reaches a guest only through the attachment send above simulator; documentation record the actual status
Live Feed read, request re-read GET /live_feed, GET /live_feed/{id} not refused: reaches no guest; a late answer is discarded by the generation check simulator; the Live Feed is not part of the Messages app record

After unlink or deactivation reports success, GuestCraft’s Messages installation for that property no longer exists, no install attempt of GuestCraft can still create one, and no activation begun before it can become active afterwards. While no other installation exists, Channex refuses GuestCraft’s thread reads, text and attachment sends and, by the simulator’s evidence, “no reply needed” for the property; Live Feed reads are not refused and reach no guest, and an upload reaches no guest by itself. A reactivation does not begin while a reply of the earlier generation may still be on its way. A request that Channex accepted before the uninstall may have reached the guest: it is recorded as sent or uncertain, audited, and never resent. GuestCraft never operates on, or uninstalls, an installation that neither Channex’s answer to its own install nor an administrator confirmed as GuestCraft’s.

  • The in-flight bound is an assumption. Two residuals defeat it, and the simulator reproduces both against the simulator. A GuestCraft process frozen for more than two minutes between admitting a write and sending it, and Channex completing a request after the bound: an install attempt settled as “nothing installed” may still produce a paid installation, and a reply already marked uncertain may still reach the guest through a reactivation’s installation, once. When that late install answer reaches the activation and its compensating uninstall fails, GuestCraft records the installation again as installed, with the link’s unlink pending again if it had ended, so it stays visible and the next stop removes it; if the process is gone too, the next attempt’s list shows it as unconfirmed. Measured answering times are evidence about observed latency only; they cannot prove the bound.
  • PostgreSQL’s clock. The bound reads only PostgreSQL’s time, so application clocks no longer matter. It assumes that the database’s clock moves forward at real speed: a step of the database host’s clock, or a failover to a server whose clock differs, shortens or lengthens the bound by that amount. now() is the start of the deciding transaction, which only makes the bound longer.
  • Administrators’ decisions. Confirming another system’s installation makes GuestCraft operate on it and uninstall it on a stop; leaving GuestCraft’s own installation alone keeps it billed until someone uninstalls it in Channex. Both dialogs say so, and both actions are audited with the installation ID.
  • “No reply needed” across a reactivation. It keeps no durable in-flight record (it is admitted and sent, then recorded), so the reactivation barrier cannot include it: a request admitted before a stop and still held when a reactivation installed again can be accepted. The documentation describes it as a Booking.com response-scoring marker with an empty payload; whether it notifies the guest is unverified, and the live test must establish it.
  • An installation replaced behind GuestCraft’s back. If someone uninstalls GuestCraft’s installation in Channex and installs another system’s, GuestCraft’s requests go through that installation until a 403 or a stop, and its uninstall then answers 404.
  • Webhook removal. It is checked against g right before the call; a later activation of the same link starting in between loses its webhook until the next activation, and only real-time wake-ups are delayed (the safety job still drains).
  • The account-wide installations list passes other properties’ installation metadata through memory on a shared account; GuestCraft keeps nothing of it.

PostgreSQL, PgBouncer and simulator tests cover a send held across unlink, deactivation and a 403 (refused afterwards, nothing delivered); a held send that keeps a reactivation waiting until Channex refused it, and the 403-reactivation-unlink probe after the bound; a lost install answer that stays unconfirmed until an administrator confirms it, and one that also lost its list and stays pending, then unconfirmed, then is left alone; another system’s installation made between the list and a refused install, with GuestCraft’s answer delivered or lost, never adopted or uninstalled; an install held across unlink, a failing compensating uninstall and a process lost between install and record, each with the unlink pending until settled and nothing left installed after success; requests shown read-only and updated from the extranet (superseded by the Messages amendment); a foreign installation, a pending stop with admission closed and its retry, the stop flows from stopped, a stale unlink while a later generation’s own unlink is pending, relinks to the same and to another upstream property, and long histories. The live checklist is in docs/developer/channex-live-test.md.

Amendment 2026-10-29: one serialization point, stop revisions and one state revision

Section titled “Amendment 2026-10-29: one serialization point, stop revisions and one state revision”

Behavior. Three gaps in the previous rule were: (1) on a first activation, unlink could commit, find no install attempt and report success between activation’s mapping read and the commit of its attempt, which then installed, and a failed compensation left that installation recorded on a property no screen showed; (2) deactivating an already stopped generation reported success while a held reactivation could still activate afterwards; (3) the Messages state’s strong ETag stayed "0" across install-attempt changes, and confirm and forget took no If-Match. It also found that application clocks decided the two-minute bound.

One serialization point. Every decision about a property’s Messages activation, install attempt and stops is taken under one lock pair: the workspace’s Channex connection row (channex_sync, FOR UPDATE), then the property’s row (properties, FOR UPDATE). Activation’s intent and its final commit, the recording of its install answer, deactivation, unlink, confirm, forget, every step of the stop procedure and the in-flight sweeps (the attempt settlement and the stale-reply sweep, also the drain’s, per property) take it before they read what they decide on. Unlink already took these two rows in this order, and so do the booking import and ARI’s gate, so no cycle exists between them: every transaction that takes the property row as Messages does holds the connection row first, which makes them mutually exclusive per workspace. Within the lock, Messages rows are written in a fixed order: the activation row, then the install attempt, then the revision row, then outbox rows. The drain’s admission and storage transactions lock only the activation row (FOR NO KEY UPDATE) and then write threads, messages and outbox rows, and its 403 stop writes the activation row, the revision row and outbox rows in that same order; none of them waits for the connection or property row afterwards, so they cannot close a cycle with a transaction holding the lock pair. A pending unlink’s retry now takes the property row too.

One stop procedure. Unlink and deactivation stop through the same procedure, the 403 stop as well:

  1. Under the lock, commit a stop revision (stops advances) and, when a generation was active, stopping(g) with the cancellation of its pending replies. Deactivation does this whatever the activation row’s state: active, stopping, already stopped, or no row at all with only an attempt.
  2. Uninstall generation g’s installation, then settle the property’s install attempt whatever its upstream property: while its install may still create an installation, the stop is pending (unlink 503 channex.unlink_pending, deactivation 503 messages.install_pending); an installed attempt is uninstalled by its exact ID and ended; an unconfirmed one stays for the operator (unlink 503, deactivation 409 messages.install_unconfirmed).
  3. Remove the webhook, commit stopped(g) and report success.

An activation reads the stop revision when it checks the operator’s ETag, and rechecks it under the lock when it commits its attempt and before it commits active. If a stop came in between, it never activates: it uninstalls the installation Channex’s answer named and ends its attempt, and answers 412 messages.version_mismatch. If that compensating uninstall fails, the attempt stays installed with the exact ID (recorded again if a stop already ended it, with the ended link’s unlink pending again), so the Messages card shows the property and the stop’s retry removes it: “Remove installation” for a linked property, the unlink’s retry in Channels for one whose unlink waits.

One revision of the Messages state. channex_message_states holds, per property, the revision of its Messages state and its stop revision. Every change of the activation (activated, stopping, stopped, its installation removed), every change of the install attempt (begun, installed, unconfirmed, confirmed, ended, forgotten) and every stop advances the revision in the transaction that makes the change. It replaces the activation row’s own version and is the version and strong ETag of GET /channex/mappings/{property_id}/messages: different states never share an ETag, including no attempt, an unconfirmed one and a confirmed one. Activation, deactivation, confirm and forget require If-Match with it and answer 412 messages.version_mismatch with the current ETag when it is stale; confirm and forget keep {installation_id} as the explicit target, so a fresh ETag cannot make an operator decide about an installation they did not see. This follows the server-version convention of ADR 0026. The synchronization status shown with the state (health, failure_code, failed_at, last_sync) changes with every drain and does not advance the revision, so a drain never makes an operator’s decision stale; it is reported, not decided on.

Database time. PostgreSQL’s clock alone measures the two-minute bound: an install attempt’s requested_at and a claimed reply’s updated_at are now(), a read of an attempt returns now() with it, and every expiry comparison of attempts and replies is made against now() in the transaction that decides. A fast or slow application clock on any instance no longer moves the bound (the regression switches to an application clock 121 seconds ahead: a fresh send stays sending, and reactivation waits). The remaining clock assumption and the residuals of a frozen process and of a provider completing after the bound are listed under residual risk above.

Endpoint evidence. The guarantee table above now says that the simulator accepts uploads while Channex’s documentation lists a 403 for them, and promises neither; and that the effect of “no reply needed” on guests is unverified. “No reply needed” keeps no durable in-flight record, so it stays outside the reactivation barrier, as a residual.

PostgreSQL, PgBouncer and simulator regressions cover these interleavings: an unlink arriving while an activation holds the lock inside its intent transaction waits, then answers pending, with a failed compensation and with the process lost before compensation; a deactivation while a reactivation is held with a pending, an installed and an unconfirmed attempt, where the earlier reactivation never activates; three different ETags for no attempt, an unconfirmed and a confirmed attempt, and 412 for a stale If-Match. Core tests cover the same races, a late install beyond the bound whose compensation fails, and the clock skew.

Amendment 2026-10-29: bounded lock waits, the ETag across mapping changes, crash matrix

Section titled “Amendment 2026-10-29: bounded lock waits, the ETag across mapping changes, crash matrix”

Behavior. (1) a decision waiting for the property’s lock ran into the ten-second statement timeout (SQLSTATE 57014), which is not retryable and answered 500; (2) a mapping change could change the linked upstream property the Messages state shows without advancing its revision, so an ETag read on one upstream property stayed current on another; and (3) no test killed an instance at the six boundaries of an activation and a stop.

Bounded lock waits. Every transaction that takes the per-property serialization point (activation’s steps, deactivation, unlink, confirm, forget, each step of the stop procedure and the in-flight sweeps) runs through one helper, serialized, which asks the unit of work for bounded lock waits (boundedLockWait). The PostgreSQL unit of work then also sets lock_timeout locally to 2 seconds, the bound of the Channex acknowledgement fence, next to the statement and idle timeouts; other transactions keep the server’s own setting. It bounds each lock wait once the transaction began, not the request: a transaction that waits for both rows may wait twice, and the wait for a pooled connection before it begins is not bounded by it. A wait beyond the bound ends with SQLSTATE 55P03, which the unit of work reports as LockUnavailable: retryable, rolled back, with nothing applied. The statement timeout stays not retryable. Where nothing was changed before that transaction, the operator’s request answers 503 channex.property_busy, with Retry-After: 2 and a detail that says nothing was changed: the first decision of deactivation, unlink, confirm and forget, and an activation that changed nothing yet. After a step committed, the existing state answers apply: a stop already committed stays pending (messages.cleanup_failed, channex.unlink_pending). The lock order is unchanged, and still no Channex call runs under the lock. The implementation now (below): the activation registered its webhook before its intent and hid a webhook it failed to remove behind channex.property_busy, answered the same after it had finished an earlier stop, and answered server.unavailable after its install; and this paragraph called every other caller of the two rows a worker job, while an administrator’s mapping save, revision retry and ARI repair, mapping and resync are HTTP requests.

The ETag across mapping changes. The Messages state shows the linked, room-mapped upstream property, or the one whose unlink is pending. A mapping write that changes what it shows advances the property’s Messages revision in the same transaction, creating the state row if absent: a link (room types mapped for the first time), a relink to another upstream property and a finished unlink. A new room type mapping of the same upstream property shows the same link and leaves the revision alone. Unlink’s own change is covered by its stop revision, which it commits in the same transaction after locking the activation row; a failed compensation that makes an ended link’s unlink pending again (keepInstall) already advanced it. The order within the lock is kept: a finished unlink advances the revision after stopped(g), and a mapping save holds the connection row and writes no Messages row before the revision. An operator who read a pending unlink on upstream property A therefore gets 412 with the current ETag after the property was relinked to B, or linked again to A.

Crash matrix. A PostgreSQL, PgBouncer and simulator test runs an instance of the Messages service, with its own pool and Channex transport, that dies at each boundary: intent committed, install sent, install answered, attempt recorded, compensation sent and stop committed. A hook that never resolves stops it there, so none of its code runs afterwards (no finally, no compensation), and every later call of it blocks before it is sent; it is then discarded with its pools. This equals a SIGKILL for the durable state: every boundary lies between two transactions, so the instance holds no open transaction and its idle connections hold no lock, PostgreSQL keeps exactly what it committed, and Channex keeps what the instance had sent. A kill inside a transaction would only add the rollback of that transaction. A fresh instance runs the drain, which never adopts an installation or settles an attempt, and the operator recovers through the API. The end states are: after a lost intent, a pending attempt that a stop ends after the bound with nothing installed; after a lost install answer (sent or answered), a pending attempt that becomes unconfirmed after the bound, never adopted, and whose exact installation the stop removes once the operator confirms it; after a recorded answer, the exact installed attempt, with which activating again finishes without another install; after a lost compensation, the installed attempt and the pending unlink, visible on both screens, whose retry finishes the cleanup; after a lost stop commit, a visible pending stop that reads and sends nothing, and that deactivating again finishes by removing the exact installation. No case leaves an adopted or orphaned installation. An OS-level kill of the composed server was not built.

Amendment 2026-10-29: no false “nothing changed”, every operator lock wait bounded

Section titled “Amendment 2026-10-29: no false “nothing changed”, every operator lock wait bounded”

Behavior. (1) an activation registered its webhook before committing its install attempt: when the attempt’s lock was not granted and removing the webhook failed too, it answered channex.property_busy, whose detail says nothing was changed, and left a callback that nothing recorded; (2) an activation that had finished an earlier pending stop, or whose sweep had ended stale replies, answered the same when a later lock was not granted; (3) an administrator’s Channex mapping save and ARI repair, mapping and resync took the connection and property locks without the bound, so a held lock ended as a 500 after the ten-second statement timeout.

The attempt before anything at Channex. Activation now commits its install attempt under the property’s lock before it registers the webhook, and still registers the webhook before the paid install. The attempt is the durable record of both until it settles, so a lock not granted for the attempt is answered before any Channex call. If Channex does not take the webhook, the install is never sent: the activation removes the webhook (the registration may have reached Channex) and only then ends the attempt, answering server.unavailable. An unlink that commits while the webhook is registered waits for the attempt, as for any pending one; the activation then rereads the link, installs nothing, removes the webhook and ends the attempt, answering messages.mapping_changed. Where the webhook cannot be removed, the attempt stays pending, visible on the Messages card: a stop or the next activation settles it after the in-flight bound like any pending attempt, and a stop or unlink also removes the webhook. After a definite install refusal the attempt still ends when the webhook cannot be removed, as before: kept pending, it would later offer another system’s installation for confirmation. That webhook is GuestCraft’s own message webhook, which costs nothing and wakes no drain for an inactive property; the next activation reuses it and unlink removes it. This is the remaining case of an unrecorded callback, and its answer (messages.installation_foreign or server.unavailable) never says that nothing changed. The implementation now (below): the attempt now records webhook work only as preparing, which settles by removing the webhook and never offers an installation; a refused install returns it to preparing, so that webhook stays recorded too.

Progress across the whole activation. Activation tracks whether the request changed anything: an earlier pending stop it ran, replies its sweep ended as uncertain, an attempt it committed or settled, a webhook it registered. A lock not granted in time answers channex.property_busy only before any such change. After one it answers 503 messages.activation_incomplete, with Retry-After: 2, the current ETag (current_version and the ETag header) and the detail “Part of this request was completed before it stopped, so this property’s Messages state has changed. Reload to see where it stands, then try again in a few seconds.” It applies to every step: the sweep, the attempt and its settling, the record of the install, the final activation and the compensation steps. The Messages card reloads after any answer and shows the stop, the pending attempt or the installed one that remained; activating again with the current ETag continues from there.

Every operator wait bounded. Every HTTP request that takes the connection row and then the property row now waits at most two seconds for each of them once its transaction began: Messages’ decisions through serialized; the Channex mapping save (channex.mapping.put), the revision retry (channex.revision.retry) and ARI’s repair, mapping (both of its transactions) and resync (channex.ari.repair, channex.ari.map, channex.ari.resync) through bounded, or the operator unit of work of the retry, both with boundedLockWait. Each is the request’s only write, so a wait beyond the bound rolls it back and answers 503 channex.property_busy with Retry-After: 2; OpenAPI documents the code and the header with each operation’s 503. The detail now names any Channex change to the property, since a booking import or an ARI change may hold the locks too. Only worker jobs keep the server’s unbounded wait on these rows, bounded by the statement timeout: the booking import’s drain (its revision applications, property settling and finish), its acknowledgement records and recovery, and ARI’s push job (run). No other HTTP request takes the pair: a drain request and a Channex webhook signal lock no property row (a signal updates the connection row, the ARI barrier of the linked properties’ ari_properties rows, and records the signal), and a reservation change locks only the property row. Those waits stay unbounded; every holder of these rows commits without calling Channex.

The revision’s row belongs to the property. The initial schema created a property’s Messages state row with its first room-type mapping, and the row referenced the mapping row, so removing a mapping row by hand (as the ARI integration test’s cleanup does) failed on the foreign key. No GuestCraft path deletes or re-keys a mapping row: unlink keeps it with linked=false, and a relink updates the same row. The row now references the property, and goes only with it: a cascade with the mapping row would restart the revision at 0 when a link is created again, and an ETag read earlier would match again. A PostgreSQL test removes a link’s rows, links again and gets the next revision, and deletes the property with the row.

Regressions: core tests for the attempt before the webhook, the webhook that cannot be removed, an unlink during the registration, a finished earlier stop and a committed sweep before a lock not granted, and the bound of the mapping save, the revision retry and ARI’s three operations; PostgreSQL, PgBouncer and simulator tests for a lock held after the webhook registration (503 messages.activation_incomplete, the attempt pending and visible, then confirmed and removed), the crash at the registered webhook (a pending attempt, then a stop that removes the webhook), and a held property lock during a mapping save and an ARI repair over HTTP (503 channex.property_busy after the two-second bound, nothing audited, the retry succeeds).

Amendment 2026-10-29: webhook work never offers an installation, and bugs are not outages

Section titled “Amendment 2026-10-29: webhook work never offers an installation, and bugs are not outages”

Behavior. An install attempt recorded webhook work and an install that may have been sent the same way, pending. When the webhook failed and could not be removed, or the process died after committing the attempt, before or after the webhook, no install had been sent; settling the attempt after the bound still listed the property’s installations and offered another system’s for confirmation as GuestCraft’s. The earlier implementation put activation in one 338-line function driven by a captured progress flag, and the Messages adapter and activation translated every error, including bugs, into Channex outages or recoverable outcomes.

The attempt’s phases. An attempt is committed as preparing: webhook work only, no install requested. Right before the install request leaves, activation commits under the property’s bounded lock that the install may be sent: the attempt becomes pending, with requested_at the database’s time then, only while the link and the activation the operator confirmed still stand. An unlink or a stop that committed since the webhook therefore wins with nothing installed, and a lock held there answers messages.activation_incomplete with nothing installed either. Settling a preparing attempt after the in-flight bound removes GuestCraft’s message webhook and ends the attempt, whatever the property lists; a failed removal keeps it, visible, for the next try. Within the bound a stop or an activation still waits for it, since its webhook may be on its way (503 messages.install_pending, whose texts now say an earlier activation may still have its webhook or install on its way). A definite install refusal returns the attempt to preparing while its webhook is removed, which closes that rule’s remaining unrecorded callback. A sent install whose answer was lost settles as before: pending, then unconfirmed with the installation Channex lists, for an operator to decide. A webhook-only attempt never records an installation, in the repository too. The API’s install_attempt.state, the SDK, the Messages card (“Remove webhook”) and the catalog texts name the phase. The Messages ETag advances once more per activation, with the install request.

Errors. Following ADR 0008, the adapter classifies only Channex’s own failures; anything else, such as a local validation error, is thrown as it is. Activation and the operator’s stop handle only Channex’s failures, lock timeouts and unsettled attempts, which they answer as before, and read the webhook secret before any Channex call. Anything else is thrown again once the attempt durably records what may have been made: preparing after the webhook, pending after the install, and the installed attempt kept before a failed compensation’s bug is thrown.

Structure. Activation, the operator’s stop and the decision about an unconfirmed installation live in message-lifecycle.ts, in named phases with explicit contexts: recover what an earlier request left, ensure a confirmed installation, commit the activation, and compensate an owned attempt. Recovery returns whether it changed anything, and each locked decision states the progress it follows, so channex.property_busy is answered only while nothing changed. One local stop transition serves deactivation, a 403 and unlink. The in-memory repository of the use-case tests is held to PostgreSQL’s repository contract by one scenario run against both. Synchronization no longer maintains a thread’s announced message IDs, booking ID and version, which nothing read.

Timing precision. The two-second lock_timeout bounds each lock wait once a transaction began, not a request, and a webhook signal locks no property row; both are corrected above.

Regressions: a core test of the normal path (a failed webhook and a failed removal, then another system’s installation: the stop removes the webhook, offers nothing and leaves the installation), and its contrast (a sent install whose answer was lost still offers its installation); PostgreSQL, PgBouncer and simulator crash tests at the committed intent, the registered webhook and the failed webhook rollback, each followed by another system’s installation, that never offer it; an unlink during the intent, after which the activation installs nothing; core tests that a bug of the webhook, the install, the compensation, the stop’s cleanup or the webhook secret is thrown with the attempt or stop durably recorded; the adapter’s local validation error; and the repository contract, which fails against the earlier in-memory repository.

Amendment 2026-10-29: the drain throws its bugs, and settlement reports its progress

Section titled “Amendment 2026-10-29: the drain throws its bugs, and settlement reports its progress”

Why. The design found that the drain still turned local bugs into Channex outcomes: a send’s unexpected exception became an operator-retryable failure, an upload’s a retryable outage, and a thread’s or a property’s was recorded and swallowed unless a duck-typed retryable flag was set, so the job runner never saw it. The design also showed that settling an earlier preparing attempt answered channex.property_busy, whose detail says nothing was changed, when the lock to end the attempt was not granted after its webhook had been removed; and that a failed refresh of the open thread discarded a chosen attachment.

Bugs in the drain. The drain isolates only declared failures, by class: an InfrastructureError (Channex or the database) or a FilterViolation. Anything else is a bug. It is thrown to the job runner once the durable bookkeeping is done, and the runner reports it without its text and moves the job to the dead-letter queue (ADR 0008):

  • a send’s bug ends the reply uncertain, never sent again, because it may have struck after the request left;
  • an upload’s bug, before any send, ends the reply failed with internal;
  • a thread’s bug records the thread’s failure and backoff, and the property’s failing health with internal;
  • the outbox and a refused property’s stop throw it at once, so an earlier temporary failure no longer hides it.

A bug therefore stops the drain, and each later drain meets it again until it is fixed. If the bookkeeping itself fails, its own failure is thrown instead; no reply is sent twice either way. Known Channex failures keep their codes, retries and isolation.

Settlement reports its progress. Settling a preparing attempt removes its webhook, then ends the attempt under the property’s lock. A lock not granted after the removal is now SettlementNotRecorded, an infrastructure failure that the stop paths handle as before. Activation answers it with 503 messages.activation_incomplete and the current ETag, even though the revision did not advance. The attempt stays preparing, and the retry removes the webhook again, which is idempotent, and ends it. channex.property_busy stays for contention before any effect.

Also. MessageRepository.states reads the Messages state of any number of properties in three statements. The overview reads every permitted mapped property and pending unlink at once, and one projection serves it and the single-property reads; the response is unchanged. The thread view keeps the chosen attachment and its sending, as it keeps the reply’s draft. The repository contract is eight named cases, each on a fresh fixture, for the in-memory repository and for PostgreSQL.

Regressions: core tests of a bug in a send, an upload, a thread read and a feed read, and in a refused property’s stop after another stop’s temporary failure; a core test and a PostgreSQL, PgBouncer and simulator test of the lock held after the settlement removed the webhook (503 messages.activation_incomplete with the unchanged ETag, no webhook left, and the retry activates); web tests of a chosen attachment and of a send in flight across a failed refresh; and a PostgreSQL test of the bulk state read.

Amendment 2026-10-29: a failed write never hides a bug, and attachments fit

Section titled “Amendment 2026-10-29: a failed write never hides a bug, and attachments fit”

Behavior. Two gaps remained. A bug’s bookkeeping could still replace the bug: when the activation ended during the failing call, the thread’s failure write refused it and the drain skipped the property as stopped; when the database failed that write, the bug became a retry. And the documented 2 MB attachment did not fit: attachment uploads shared the ordinary 1 MiB body limit, and the vault read back no record above 2,000,000 characters, so a larger queued file ended failed/undecryptable. This ADR was also renumbered from 0038, which main’s expand/contract migrations ADR holds.

A bug comes first. The drain classifies a failure before its bookkeeping. For a bug it then attempts the write (the reply uncertain or failed, the thread’s failure, the property’s health) and throws the bug itself, whatever that write does: a write that fails, because the activation ended meanwhile or the database is down, becomes the bug’s cause when the bug has none. A send whose uncertain write failed stays sending until the in-flight sweep makes it uncertain; it is never sent again. Declared failures keep their stop, retry and isolation handling, so the rule that a failed write is thrown instead still holds for them.

Attachment bounds. A file is at most 2,000,000 bytes, decoded:

  • the attachment route accepts a body of up to 3 MiB, the file’s 2,666,668 characters of base64 with room for the rest; every other route keeps its limit;
  • the core checks the decoded size and refuses a larger file with messages.attachment_too_large (413, with max_bytes);
  • Messages uses its own vault, whose records may reach 3,600,000 characters, the sealed outbox payload of the largest file. Credentials keep their 12,000 characters and booking revisions their 2,000,000; key rotation of Messages records uses the same vault.

Regressions: core tests of a bug whose bookkeeping fails in a send, an upload, a thread read that ended the activation and a feed read whose health write fails; a core test of the decoded-size bound; an API test of each body limit and of the problem; a vault test of the largest queued file and of the unchanged bounds; and a PostgreSQL, PgBouncer and simulator test that delivers a 2,000,000-byte file from the API through the vault and the outbox to Channex and refuses one byte more.

Platform dependencies. The application uses the night audit (ADR 0043), room moves and splits (ADR 0042), the device sessions of the mobile app (ADR 0041), request validation that refuses a missing body, and the Channex follow-ups of ADR 0028: an ownership fence around acknowledgements and webhook removals, retired-target fences, removal generations, forgetting a pending removal, and a cursored, verified key rotation. Main took migration 0021 and API revision 3.2.0: this ADR’s migration is now 0025 and its revision 3.6.0.

Unlink. The admission is one transaction under ADR 0028’s fence: the fence, the connection row, then the property’s row, which is Messages’ serialization point; it commits the stop revision and stopping as before. The one stop path then finishes the Messages stop (the installation uninstalled, the attempt settled, the Messages webhook removed and stopped committed), and only then does ADR 0028’s booking-webhook removal run, with its share-fenced DELETEs and its removal generation. The Messages stop no longer ends the removal: finishUnlink is gone, and ending a removal advances the Messages revision. Any infrastructure failure after the admission answers channex.unlink_pending, now also for the booking webhook. Contention on the property answers channex.property_busy; on the fence, channex.connection_busy.

Forgetting a removal. ADR 0028’s forget could end the removal of an unlink whose Messages stop had not finished. The activation then stayed stopping for property_unlinked, which only that unlink finishes: activation and deactivation answered messages.mapping_changed, no unlink was left to retry, and a paid installation could stay. Forget now takes the property’s lock and refuses with channex.messages_stop_pending (409) while the activation is stopping or an install attempt stands; retrying the unlink finishes the stop first.

Operator retries and the drain. An operator’s retry of a revision waits for the connection and property locks at most the bound. ADR 0028’s automatic replay runs the same apply from the drain, which waits: the caller chooses the bound, not the replay.

Key rotation. Messages records (thread titles, messages, outbox payloads, request details, template names and bodies) are kinds of ADR 0028’s rotation, with its cursor, key fingerprints and verification with the target key alone; each is opened with the Messages vault. The separate Messages pass is gone.

Device tokens. Every Messages operation lists the deviceSession scheme: following ADR 0041, a device token acts with the person’s live permissions and property scope. A front-desk phone can read and reply; activation, deactivation and install attempts still need messages.configure, as in the browser. (API 3.7.0 then made activation, deactivation, both install-attempt decisions and synchronization requests browser-only: see ADR 0041’s amendment on Channex administration.)

Business date and stay segments. Messages filter and order by instants and reply state, never by a stay date, and template variables take the reservation’s stay dates, which room moves and splits leave unchanged. Neither needed a change.

Regressions: core tests of the forget refusal (an installation not yet uninstalled, an unsettled attempt) and of forgetting once only the booking webhook is unproven, of the unlink order, and of the drain’s automatic replay while the property’s lock is held; PostgreSQL tests of the rotation of Messages records and of ending a removal; API tests of every Messages operation’s security and of a missing body.

Amendment 2026-10-31: decisions, inquiry attachments, retention and erasure

Section titled “Amendment 2026-10-31: decisions, inquiry attachments, retention and erasure”

GuestCraft now decides inquiries, reservation requests and alteration requests through Channex’s simulator-backed POST /live_feed/{event_id}/resolve adapter. A request is claimed as resolving under the property’s Messages admission barrier, checked again immediately before the call, and committed as resolved with an audit entry after Channex confirms it. The state is the idempotency guard: a resolved or already-resolving request cannot be sent a second time. A stale ETag, retired or stopped property, mismatched kind, and passed deadline are stable refusals. An extranet decision discovered by the regular feed refresh wins; the local request is then resolved and a later local decision is refused. Inquiry decisions are only preapproval or special_offer with a total price in minor units and the booking currency; an unanswered inquiry lapses. A stop returns 503 messages.replies_in_flight while a decision remains in flight; the operator retries after it settles. Refusal-triggered stops are retried by the drain. An uncertain resolve is re-read by event ID with bounded retries before it is reopened or settled.

Before sending, GuestCraft reads the event and records every known prior final outcome as channel-decided, including the same choice. Special-offer outcomes retain their returned total and currency; a different amount or currency is a channel decision, returns 409, and records requested and returned totals in ISO minor units in the audit. A missing or malformed final offer amount cannot confirm success. If a channel actor records exactly the same action, currency and amount between that read and POST /resolve, Channex returns no origin marker; GuestCraft cannot distinguish that race. The audit records requested and recorded outcomes without claiming upstream provenance beyond what the read proves.

The thread attachment form for an inquiry without a booking is verified against the simulator only. Channex documents attachment IDs on the booking message form; live support for the inquiry thread form remains a certification gap, recorded in the live checklist. The existing encrypted outbox uploads the file and then sends it through the thread-specific endpoint; the same stop barrier, uncertain-send handling and no-duplicate guarantee apply as for booking attachments. The simulator implements both calls, so no real Channex account is used.

Retention is a workspace resource with default, inquiry, reservation and attachment day limits. The scheduled retention job redacts at most 100 expired rows per run, keeps redaction metadata, and never blocks synchronization. The operation is audited. A member holding the workspace-only messages.erase grant can erase content linked to a guest; PostgreSQL retains only identifiers and an erasure record, and no upstream request is made. Channex’s and the OTA’s copies, booking revision archives and audit entries remain and must be erased with those providers. The lookup takes the guest-merge lock and locks the merged_into chain to the final survivor, erases every thread reachable through its reservations, and records both requested and survivor IDs. Absorbed IDs explicitly resolve to that survivor. A later merge does not mark the newly reachable conversations erased. No email or SMS path is involved.

Inquiry actions are Pre-approve, Send special offer in the inquiry booking currency, and Let it lapse (which makes no upstream call). The localized offer field uses ISO 4217 minor units in GuestCraft and the adapter converts to the upstream integer major-unit total_price. The Channex schema accepts no fractional major units, so the UI and domain reject them explicitly. There is no inquiry Decline. Every transmitted decision has an accessible confirmation; reservation declines expose the reason and optional distinct guest and Airbnb messages. Retention purpose overrides can be cleared to NULL. Erasure is available on the selected guest profile and in settings, with typed ERASE confirmation.

Erasure is destructive browser administration: messages.erase is excluded from API key grants, consistent with ADR 0041. It uses the workspace can check and rejects property-restricted members. Retention updates likewise require unrestricted messages.configure. Local erasure cannot remove Channex/OTA copies, booking revision archives or audit entries; operators must ask Channex and the OTA. Inquiry-only threads have no trusted GuestCraft guest association and cannot be selected by matching names.

channex.messages_retention redacts a total of at most 100 content rows per transaction, across message envelopes, titles, request details and queued payloads. A full batch enqueues its continuation in the same transaction. The worker safety schedule and request-woken drain discover content in stopped and disconnected workspaces without requiring a Channex key. The independent sync job never executes or waits for a purge query. Partial indexes support selection by the content timestamp; request expiry uses receipt time, independent of feed refresh.

Bodies and attachments share one encrypted message envelope. A message containing attachments expires at the earlier of its conversation-purpose policy and the attachment policy. Existing pre-migration envelopes have no unencrypted attachment metadata: the migration conservatively classifies them as attachment-bearing, so an old text-only message may also expire at that shorter limit. New ingestion stores the exact attachment flag. Redaction writes NULL ciphertext and an explicit timestamp/reason, allowing key rotation to skip removed content. Erasure records a per-thread history point: stored content and existing redacted message IDs remain erased, while new message IDs arriving later are stored and shown normally. It preserves sending and uncertain delivery bookkeeping and audits each queued reply it cancels. Repeated erasure counts only non-NULL content and preserves prior content redaction timestamps. Unknown guest IDs return 404.

The documented inquiry payload currently has no authoritative deadline. GuestCraft does not invent a provider TTL. Let it lapse sends nothing; a lapse outcome requires an authoritative known deadline or channel outcome. The lapse-after-deadline UI regression uses an explicit deadline fixture and does not certify a deadline supplied by live Channex. This is a remaining product limit.

Amendment 2026-10-31: terminal decision-read recovery

Section titled “Amendment 2026-10-31: terminal decision-read recovery”

Decision reconciliation persists a failed-read counter on the request (migration 0043), reset only when a fresh operator decision claims an open request. A retryable or outcome-unknown read failure schedules another read three minutes later, up to three failed reads in total. A non-retryable response (including 403 and 404), an invalid event identity, or an exhausted budget reopens the request and records channex.message_request_reconciliation_failed with outcome: unknown, the safe failure reason and attempt count. It does not send a decision. This releases the decision stop barrier: an administrator can retry deactivation or unlink, and a refusal-triggered stop can finish in the drain. An operator can retry an open request after restoring access; that retry first reads Channex’s authoritative event, whose prior final outcome is preserved. Reopening records uncertainty rather than claiming the first decision was not applied. The counter and version compare-and-set prevent restarted or concurrent workers from extending the retry budget or overwriting a completed decision.

Retention update audits use the string default for a cleared purpose override, alongside numeric values for explicit day counts. Zero is never an audit substitute for an absent override. Request views expose the persisted redaction reason and separately report whether present details ciphertext was decryptable, so missing upstream facts, erased/retained content and encryption-key failures have different operator messages.

Integrated guest identity, truthful claims and bounded scheduling

Section titled “Integrated guest identity, truthful claims and bounded scheduling”

The Messages migrations use the current schema, migration sequence and API revision. Guest-content erasure serializes with guest merges, resolves and locks the complete survivor chain, and returns both identifiers. It covers reservation-linked conversations only; inquiry-only threads and threadless requests have no trusted guest link. Guest merge provenance snapshots, booking revision archives, audit entries and upstream copies remain outside this operation. The browser confirmation states the scope.

A decision attempt is audited in its durable claim transaction with the operator, requested action, reason and Money. Read-only reconciliation compares action, amount and currency against that persisted claim. An exact match is recorded as the operator’s decision confirmed by reconciliation; a different result or a result without a pending claim is channel-decided. This is the provenance rule despite the upstream origin-marker limitation. Exhausted recovery keeps a visible unknown-outcome notice and actor metadata when reopening. Definite upstream 409/422 refusals return 409 messages.decision_refused and are audited; they are not transient 503 failures. Reconciliation infrastructure failures do not prevent outbox or ingest work.

Request-woken drains schedule retention only, using a five-minute persisted per-workspace claim in the enqueue transaction. They do not fan out synchronization. Webhooks, operator writes and the safety schedule retain their existing Messages drain behavior. The retention query first uses an indexed bound from the minimum purpose duration, then checks each candidate’s thread purpose. Pending/failed replies dismissed by retention receive individual cancellation audits.

Retention discovery reads retained property and settings rows instead of scanning message content. Request-woken discovery filters due workspaces before its bound; safety discovery continues to include all owned Messages workspaces. The enqueue transaction still claims the five-minute window, so concurrent discoveries cannot enqueue duplicate windows.

On an absorbed guest profile the browser names the survivor and links to that profile for erasure confirmation; it offers no destructive action on the absorbed profile. Erasure status reports the requested and surviving profiles. Unknown-decision notices resolve the operator through the existing organization-scoped actor-name lookup, with a team-member fallback when a display name is unavailable. Claim releases close their attempted-decision audits; reconciliation failure is a system event that retains the attempted operator in its data.

The simulator follows the pinned int32 offer range without inventing positivity, uses 400 for unparsable /resolve JSON, and applies documented text precedence over attachment_id for both booking and thread message forms. The Messages Collection Message Threads attachment subsection links to the booking form; live inquiry support still requires certification.

Erasure covers the content stored when the operation runs. The thread records erased_at for that history; a new message arriving afterward is stored normally even if Channex gives it an older or second-rounded timestamp. Re-ingesting an existing redacted message never refills its content.

Workspace discovery requires the database’s sole registered instance (enforced by migration 0004) and excludes connections with a different recorded owner, such as a restored connection. Disconnected content with no surviving connection owner belongs to that registered installation and remains discoverable without a Channex key. The worker regression switches the singleton registration between two owners and proves each sees only its own connections plus unowned content; it never weakens the database’s one-instance invariant.

A conversation’s history is read back past its latest 1,000 messages. The thread detail keeps those 1,000 (oldest first) for earlier clients and adds messages_next_cursor; GET /message_threads/{thread_id}/messages is the collection read, a list after ADR 0016 (limit 1–100, 20 by default; { data, next_cursor, has_more }), whose first page, without a cursor, is the latest messages. The browser loads 100 older messages at a time.

  • Keyset, never offset. The order is the conversation’s: Channex time, then ingestion sequence. The cursor’s position is the ingestion sequence of the oldest message a page shows; the next page holds the messages ordered strictly before that message’s own (inserted_at, seq), compared on its stored row. The position carries no timestamp: instants are handed out at millisecond precision, while Channex times can be finer, so a timestamp position could skip messages of the same millisecond. Arrivals between two pages, including one at the position’s own time, never shift a page; a message Channex dates before the position and GuestCraft ingests late appears on the page its time puts it on.
  • Bound to the thread. The cursor is the shared envelope bound to the caller and to ["message_threads", thread_id, "messages"]: on another thread or with a forged position it is cursor.invalid (400). A position the thread does not hold reads as an empty page. Row-level security and the thread’s property grant apply to every page.
  • Redaction stays. Pages carry the stored redaction state (readable: false, no body, redaction_reason), never ciphertext, and paging never writes: retention, erasure, read markers and their audit are unchanged.
  • Index. channex_messages_thread (workspace_id, thread_id, inserted_at, message_id) range-scans to the position; ties of one instant are sorted by sequence. No migration.
  • Browser. “Load older messages” sits above the oldest message shown. The pages continue from the detail’s cursor of the first request, and the view keeps every message it showed, so messages that new arrivals push out of the detail’s latest 1,000 stay between the detail and the older pages. The message being read stays in place, and a failure offers to try again on the same button. The thread’s polite live region says how many older messages loaded and when the conversation’s start is reached. New messages are counted by ID, since the detail’s length stops growing at 1,000.