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.
Observable acceptance criteria
Section titled “Observable acceptance criteria”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.
Contract and cost evidence
Section titled “Contract and cost evidence”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.
Decisions
Section titled “Decisions”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.
Unlink and relink
Section titled “Unlink and relink”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:
- Commit
stopping(g, reason). Admission for generation g closes in that transaction: every call is admitted by a committed check that g is stillactive, so none is admitted after it. Pending replies of g endfailedwith their reason, each audited with its prior error. - 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). - 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.
Amendment 2026-10-29: install ownership, unlink, reactivation and decisions
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 (409messages.install_unconfirmed), and the Messages card shows the installation ID, explains that Channex did not confirm it and offers two audited actions withmessages.configure(and, on a shared account,channex.manage_account_modeto confirm): confirm it as GuestCraft’s (…/install_attempt/confirm, the attempt becomesinstalled) 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 (503messages.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 itunconfirmed.
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.
The guarantee per endpoint
Section titled “The guarantee per endpoint”| 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.
Residual risk
Section titled “Residual risk”- 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
uncertainmay 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 asinstalled, 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 asunconfirmed. 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:
- Under the lock, commit a stop revision (
stopsadvances) 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. - 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 503messages.install_pending); aninstalledattempt is uninstalled by its exact ID and ended; anunconfirmedone stays for the operator (unlink 503, deactivation 409messages.install_unconfirmed). - 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
failedwithinternal; - a thread’s bug records the thread’s failure and backoff, and the property’s
failinghealth withinternal; - 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, withmax_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
Section titled “Platform dependencies”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.
Operator and permission decisions
Section titled “Operator and permission decisions”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.
Retention storage and scheduling
Section titled “Retention storage and scheduling”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.
Amendment 2026-11-01: History paging
Section titled “Amendment 2026-11-01: History paging”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 iscursor.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.