ADR-0033: The websocket channel is a delivering adapter with pending receipts
| Status | Accepted |
| Date | 2026-10-10 |
| Issue | #22 |
| Pull request | #119 |
| Related | ADR-0001, ADR-0003, ADR-0006, ADR-0008, ADR-0020, ADR-0024, ADR-0026, ADR-0032 |
Context and problem statement
Before this decision the websocket adapter was a stub: supported_modes/0 returned only outbound, deliver_activity/2 did nothing, and the pipeline left websocket out of its hardcoded list of delivered types (@delivery_types ~w(echo webhook whatsapp_meta whatsapp_infobip), ADR-0003). WebSocket clients only saw the after-commit PubSub broadcast on conversation:<id>. As a result:
- a
websocketchannel could not be the target of a routing rule, so the most common hub use case, WhatsApp to an agent console, was impossible; - nothing recorded whether any connected client received an activity;
- the Converger client socket could not send at all (clients posted over REST), and nothing applied the channel's mode to messages coming from sockets.
The question is how sockets fit the delivery model that every other channel uses: a delivery row per activity and target channel, created by the pipeline, retried by Oban, with the channel's middleware applied.
Decision drivers
- One delivery path for every channel type: routing rules, middleware and delivery tracking must work for
websockettargets exactly as for webhooks (ADR-0003). - No data loss while a client is offline, without a second message store: activities are already persisted with a gap-free
seq(ADR-0006). - An offline client is not a failure: it must not burn retries or end up dead-lettered.
- Inbound messages from sockets and from webhooks take the same path and obey the channel's mode.
- One socket must be able to follow a whole channel (agent console) or one conversation (end-user widget).
- Existing clients of
/socket/convergerkeep working without changes.
Considered options
- Broadcast-only (status quo) - keep the PubSub broadcast as the only real-time path; let routing rules target
websocketchannels without delivery rows. - Adapter delivery that fails while no client is connected -
deliver_activity/2returns{:error, :no_clients}and Oban retries until a client connects. - Adapter delivery with a pending result -
deliver_activity/2broadcasts on channel topics and returns{:pending, meta}when no client is connected (or acks are required); the delivery stayspendingwithout a retry and is markedsentwhen a client replays or acknowledges it. - Per-socket outbox - store a queue of undelivered activities per socket or per channel and drain it on reconnect.
Pros and cons of the options
Option 1: broadcast-only
- Good, because nothing changes.
- Bad, because there is still no delivery record and no middleware for socket targets; routing rules would have two semantics.
Option 2: fail and retry
- Good, because it reuses the existing retry machinery without a new result.
- Bad, because an agent console that is closed overnight exhausts
max_attemptsand dead-letters every message, and the retries rebroadcast to nobody.
Option 3: pending result
- Good, because the delivery row answers "did a client get this?", offline buffering is the existing replay from the watermark, and no new storage is needed.
- Good, because retries stay reserved for real failures.
- Bad, because the adapter behaviour gains a result type and a delivery can stay
pendingindefinitely when no client ever comes back.
Option 4: per-socket outbox
- Good, because it could deliver to a specific socket.
- Bad, because it duplicates the activity log, needs its own retention and ordering rules, and sockets are anonymous and short-lived.
Decision
Chosen option: "Adapter delivery with a pending result", because it gives websocket channels the same delivery path, middleware and tracking as every other type, while relying on the persisted, seq-ordered log for offline catch-up.
- Adapter.
Converger.Channels.Adapters.WebSocketsupportsinbound,outboundandduplex.deliver_activity/2broadcasts the canonical activity, after the channel's middleware, onchannel:<channel id>andchannel:<channel id>:conversation:<conversation id>, and counts the connected clients withConvergerWeb.Sockets.count_connections/2(Presence entries of the channel that follow the conversation or the whole channel). It returns{:ok, %{connected_clients: n}}whenn > 0, and{:pending, %{connected_clients: n}}whenn == 0or the channel's config hasrequire_ack: true. - Pending result.
{:pending, meta}is a new result ofc:Converger.Channels.Adapter.deliver_activity/2. The pipeline records it withDeliveries.mark_handed_off/2: the delivery stayspending,attemptsis incremented, the job succeeds and nothing is retried.Deliveries.acknowledge(channel_id, conversation_id, seq)marks the channel's handed-off (attempts > 0) pending deliveries of the conversation up toseqassent. It runs when a client sendsack {watermark}, and after a replay on join when the channel does not require acks. Deliveries that have not been handed off yet are left alone, so the pipeline still broadcasts them to the channel's other sockets. - Capabilities, not type lists. Adapters gain an optional
capabilities/0callback (default[:inbound, :outbound]). The pipeline delivers to channels whose adapter has:outbound; the@delivery_typeslist is removed. This is the first step of adapter behaviour v2 (#36). - Pipeline rules for websocket targets. Lifecycle events (
conversationUpdate) are delivered towebsocketchannels as well as webhooks, since they are frames of the protocol (ADR-0024). The participant echo rule does not apply to awebsocketchannel: it is a hub of many sockets (the participant's other tabs, an agent console on the same channel), and the sending socket drops its own frame byseq. - Inbound. The per-message logic of
ConvergerWeb.InboundControllermoves intoConverger.Inbound.receive_message/3, which checks the channel's mode. Webhooks call it, and so does the Converger socket'spostActivityevent (ADR-0026) when the token's channel is awebsocketchannel: the socket is then that channel's own transport. A token of another channel type is a client of the conversation, like the REST API, and keeps writing directly. - Topics and authorization.
converger:conversation:<id>accepts a token restricted to the conversation and, withscope: "channel"(roleagent), the conversation's own channel (owned: the after-commitconversation:<id>broadcast, as before) and awebsocketchannel that an enabled routing rule targets from the conversation's channel (routed: the channel's deliveries, after its middleware). An unscoped channel-level token still cannot join, as required by the security fix of #114.converger:channel:<id>follows every delivery to awebsocketchannel and requires a token withscope: "channel"(ADR-0024 decision 12 asks for an explicit claim rather than "a token without a conversation"). - Ordering per conversation topic. The channel process tracks the last
seqit pushed, starting from the conversation head read before subscribing. Frames at or below it are dropped; a frame abovelast + 1first pushes the missing range from the database (with the channel's middleware for routed sockets). This covers lost cross-node broadcasts, concurrent delivery jobs and middleware-halted activities. - Existing channels. Migration
20261010200000_make_websocket_channels_duplexchanges existingwebsocketchannels fromoutbound, the only mode they could have, toduplex.
Consequences
Positive
- WhatsApp to agent console bridging works end to end: inbound WhatsApp messages reach the console in real time with a tracked delivery, and agent replies go back to WhatsApp through the pipeline with the WhatsApp channel's middleware.
- "Was this received by a client?" has an answer in the
deliveriestable, includingconnected_clientsin its metadata. - An offline client costs one delivery row and no retries; it catches up by replay.
- Messages from sockets obey the channel's mode like webhooks do.
Negative and trade-offs
- Every activity of a conversation on a
websocketchannel in modeoutboundorduplexnow creates a delivery row and an Oban job. This is the price of tracking. - A delivery whose client never returns stays
pendingindefinitely. Dashboards that countpendingdeliveries include them; retention of deliveries is #30. connected_clientsis a Presence count: it is eventually consistent across nodes and counts joined channel processes, not end users.- Owned and routed sockets see different payloads for the same conversation: owned sockets get the activity as committed, routed sockets get it after their channel's middleware.
- Gap detection is per conversation topic. The channel topic pushes frames as they arrive, without de-duplication.
Follow-ups
- Client-generated ids and server acks for sends: #24.
- The native endpoint (#26, ADR-0030) is one conversation per connection: it counts as a connected client and its sends go through
Converger.Inbound, but channel-scoped (agent console) sessions over it are not built yet (#64, #67). - Adapter behaviour v2 (
config_schema/0, registry, the remaining type lists in health checks and the dashboard): #36. - Watermarks per conversation for channel-scoped sockets (
hello.watermarks): #64, #67.
Implementation
- Adapter:
lib/converger/channels/adapters/websocket.ex. - Behaviour (
{:pending, meta},capabilities/0,capability?/2):lib/converger/channels/adapter.ex. - Pipeline (capability filter, lifecycle and echo rules, pending result):
lib/converger/pipeline.ex. - Delivery tracking (
mark_handed_off/2,acknowledge/3):lib/converger/deliveries.ex. - Inbound context:
lib/converger/inbound.ex, used byConvergerWeb.InboundController. - Socket (topics, authorization,
postActivitythroughConverger.Inbound,ack,seqtracking):lib/converger_web/channels/converger_channel.ex; connection counting and channel-scoped socket ids:lib/converger_web/sockets.ex;scopeclaim:lib/converger/auth/converger_token.ex. - Routing authorization:
Converger.RoutingRules.routes_to?/3inlib/converger/routing_rules.ex. - Migration:
priv/repo/migrations/20261010200000_make_websocket_channels_duplex.exs.
Tests: test/converger_web/integration/websocket_channel_adapter_test.exs drives a signed WhatsApp webhook into a routed agent console and back to a stubbed Graph API, and covers routed topics with middleware, gap fill and de-duplication, pending deliveries marked sent by replay, require_ack with ack, and authorization of both topics.