ADR-0032: Transient conversation signals: receipts, typing and presence
| Status | Accepted |
| Date | 2026-10-09 |
| Issue | #25 |
| Pull request | to be added on merge |
| Related | ADR-0020, ADR-0024, ADR-0006 |
Context and problem statement
Converger.Deliveries already broadcast a delivery_status event for every delivery status
change, including WhatsApp "delivered" and "read" receipts, but ConvergerChannel dropped it.
End users never saw delivered/read for their WhatsApp-bridged messages, and agent consoles could
not show typing or online state. Protocol v1 (ADR-0024)
specifies the frames (deliveryStatus, typing, presence, client typing and read, section 8)
but not how the server produces them. We had to decide what is stored, how the signals fan out
across nodes, who counts as a participant, and how external channels take part.
Decision drivers
- Receipts and typing must reach the other connections of a conversation quickly (typing from one client visible to another within 100 ms locally) and across nodes.
- "Persist first, then push" applies to activities. Typing and presence are ephemeral and must not
grow the activity log or consume
seqnumbers. - A read position must survive reconnects and never move backwards, even with several tabs and out-of-order frames.
- External channels (WhatsApp) should show typing and blue ticks where the provider supports it, without blocking the WebSocket process on a provider call or making every adapter implement it.
- Anonymous widget users must not be announced to others by default.
- The legacy socket is being retired (#23) and must not receive new events by accident.
Considered options
- Transient frames over dedicated PubSub topics, stored read watermarks, optional adapter callbacks (chosen).
- Typing, read receipts and presence as activities (as typing is today): stored, numbered and replayed like messages.
- Everything on the existing
conversation:<id>topic. - A participants table per conversation (
conversation_participants.read_seq), as sketched in the issue.
Pros and cons of the options
Option 1
- Good, because ephemeral signals cost no writes and no
seq, and only the read watermark, which must survive, is stored. - Good, because separate topics (
conversation:<id>:signals,conversation:<id>:presence) reach onlyConvergerChannelprocesses: the legacy channel and the admin LiveView, which are joined toconversation:<id>, are unaffected. - Good, because PubSub and Phoenix Presence are already cluster-wide.
- Bad, because a client that was disconnected misses typing and presence changes (acceptable: they describe the present, and presence sends a snapshot on join).
Option 2
- Good, because replay would include them.
- Bad, because every keystroke burst would be a database write and a
seq, and replayed typing is meaningless; Protocol v1 already moved typing to a transient frame.
Option 3
- Good, because no new subscriptions.
- Bad, because the legacy channel forwards every event on that topic to its clients unfiltered, so they would receive frames they do not understand, and the admin LiveView would have to ignore them.
Option 4
- Good, because it would model membership explicitly.
- Bad, because there is no such table:
participantsare external parties per channel, and WebSocket users are identified only by token claims. Aconversation_readstable keyed by(conversation_id, reader_id)stores exactly what is needed without inventing membership.
Decision
Chosen option: "Option 1".
- Participants. A connection's participant is derived from its conversation token: id = the
token's
user_id, or"anonymous"; roleuser. Only conversation tokens can join since #114; theagentrole arrives with channel-scoped sockets (#64). A connection never receives signals of its own participant. - deliveryStatus. The existing
delivery_statusbroadcast gainsseq,sender,attempts,last_errorandupdated_at; eachConvergerChannelturns it into adeliveryStatusframe. Identified end users only receive it for activities they sent. - read.
Converger.Receipts.mark_read/3upsertsconversation_readswithON CONFLICT ... DO UPDATE ... WHERE read_seq < new, so the position is monotonic without a lock, and caps it atconversations.last_seq. Only a moving position is broadcast as a read receipt. - typing. Relayed with
broadcast_fromon the signals topic, rate-limited in the channel process (same state within 2 s dropped), cleared on disconnect, never stored. - presence. A second Phoenix Presence,
ConvergerWeb.ConversationPresence, keyed by participant per conversation, separate from the per-channelSocketPresenceof ADR-0020. Per-channel config keypresence:"identified"(default),"all"or"off". - External channels. Optional adapter callbacks
send_typing/2andsend_read_receipt/2, called byConverger.Channels.Signalsfor the channels an activity would be delivered to, in a supervised task, best effort (logged, never retried). WhatsApp Cloud API implements both against the participant's latest inboundwamid.
Consequences
Positive
- The issue's acceptance criteria hold: a WhatsApp "read" DLR reaches the WebSocket client as
deliveryStatus, and typing crosses connections within 100 ms locally (tested). - No new infrastructure; one small table.
- Adapters opt in; channels without support are skipped silently.
Negative and trade-offs
- Signals are at-most-once (PubSub across nodes, no replay). Clients expire typing after 6 s and get a presence snapshot on join, which bounds the damage.
- Anonymous end users share one participant id per conversation, so their tabs cannot be told apart.
- A WhatsApp typing indicator also marks the participant's latest message as read (Cloud API behaviour).
- Typing activities posted over REST are still stored, for existing clients.
Follow-ups
- #22: emit the same frames on the raw v1 socket.
- #64 / #67:
agentrole for channel-scoped sockets. - Read receipts and typing for WhatsApp via Infobip, and
awaypresence, are not implemented.
Implementation
ConvergerWeb.ConvergerChannel(clienttyping/read, pushes),ConvergerWeb.ConvergerFrames(frame builders),ConvergerWeb.ConversationPresence.Converger.ReceiptsandConverger.Receipts.ReadPosition, migration20261010100000_create_conversation_reads.Converger.Channels.Signals,Converger.Channels.Adapter(send_typing/2,send_read_receipt/2),Converger.Channels.Adapters.WhatsAppMeta.Converger.TaskSupervisor;config :converger, :channel_signals_async(falsein tests).- Tests:
test/converger_web/channels/converger_channel_signals_test.exs(every frame validated against its JSON Schema),test/converger/receipts_test.exs,test/converger/channels/signals_test.exs.
Links
- WebSocket
- Real-time
- Protocol v1, section 8
- Writing an adapter