ADR-0026: One client socket stack; the legacy socket and token family are deprecated
| Status | Accepted |
| Date | 2026-10-09 |
| Issue | #23 |
| Pull request | #115 |
| Related | ADR-0003, ADR-0006, ADR-0020, ADR-0024 |
Context and problem statement
Converger had two client WebSocket stacks and two token families:
| Legacy | Converger API | |
|---|---|---|
| Socket | /socket, ConvergerWeb.UserSocket | /socket/converger, ConvergerWeb.ConvergerSocket |
| Topic / channel | conversation:<id>, ConversationChannel | converger:conversation:<id>, ConvergerChannel |
| Token | Converger.Auth.Token (x-channel-token, POST /api/v1/tokens) | Converger.Auth.ConvergerToken (Bearer, /api/v1/converger/tokens/*) |
| Frames | new_activity | activitySet with a watermark |
| Resume | last_activity_id | watermark |
| Sending | new_activity push with a reply | REST only |
They duplicated authorization, drifted in payload shape, and every document and SDK had to explain both. Protocol v1 (#21, ADR-0024) is built on the Converger API stack (#22), so the legacy stack has no future. The only capability the Converger API socket lacked was sending with a reply.
While moving the tests, we found that the legacy verifier
(Converger.Auth.Token.verify_token/1) only checked the signature. All tokens
share one signer, so an end user's conversation token or Converger API token
passed x-channel-token authentication: it acted as the whole tenant on the
tenant API (routing rules, any conversation's activities) and could call
POST /api/v1/tokens to mint tokens for other conversations of the channel
under any user id.
Decision drivers
- One implementation of the client protocol, so #22 to #28 are built once.
- Existing integrations keep working until a removal date that is announced in advance (protocol v1, section 13.3).
- Operators can see who still uses a deprecated surface before it is removed.
- An end user's token must never act as the tenant.
Considered options
- Deprecate the legacy stack, add sending to the Converger API socket, log every use - the legacy code stays as it is until removal.
- Make the legacy socket a thin alias of the Converger API socket -
translate
new_activity/last_activity_idinto the new stack. - Remove the legacy stack now - one breaking release.
Pros and cons of the options
Option 1
- Good, because no existing client breaks before the announced removal.
- Good, because the new code (sending) lives only in the stack that stays.
- Bad, because the legacy code stays in the tree for at least two minor releases.
Option 2
- Good, because only one channel module would run.
- Bad, because the translation layer is new code with its own bugs, written
for a surface that is removed anyway, and its frames (
new_activitywith the canonical map) still differ fromactivitySet.
Option 3
- Good, because it removes the most code.
- Bad, because it breaks every legacy client without a migration window, against the deprecation policy of protocol v1.
Decision
Chosen option: "Deprecate, add sending, log every use".
ConvergerChannelhandles thepostActivityevent: a Direct Line-style activity is stored throughActivities.create_client_activity/2(the pipeline is the only delivery path, ADR-0003), and the reply carriesid,seqandwatermark. The sender is the token's verifieduser_id, elsefrom.id, else"user"(protocol v1, section 3.2). An optionalclientId(protocol v1 syntax) is stored asws:<sender>:<clientId>, like the legacyidempotency_key. Sends share the tenant'sactivity_createrate-limit bucket with REST.- Deprecated, removed no earlier than two minor releases and 6 months after
this change:
/socket(UserSocket,ConversationChannel),POST /api/v1/tokens, and everyx-channel-tokenuse (channel tokens onPOST /api/v1/conversationsand on the tenant API). The tenant API withx-api-keyis not deprecated: it is the server-to-server API and has no replacement. ConvergerWeb.Deprecationlogs a warning per legacy socket connection and per HTTP request, emits[:converger, :deprecated, :use], and adds the RFC 9745DeprecationandLink: <guide>; rel="deprecation"headers to HTTP responses.- Legacy verifiers check the token's shape:
verify_channel_token/1(haschannel_id, noconversation_id) forx-channel-token,verify_conversation_token/1for the legacy socket, and both reject Converger API tokens (type: "converger").
postActivity is a pre-v1 event on the pre-v1 (activitySet) shape of the
topic. Protocol v1 frames (#22) replace it with text and the other message
frames plus ack (#24); postActivity follows the deprecation of the
activitySet shape.
Consequences
Positive
- New client work (#22 to #28) targets one socket.
- All WebSocket tests run against the Converger API socket; the legacy tests only pin the deprecation warning and what still ships.
- Deprecated-surface usage is visible in logs and telemetry.
- End-user tokens can no longer act as the tenant.
Negative and trade-offs
- Integrations that sent a conversation token as
x-channel-tokento the tenant API break immediately (security fix, not deprecation). - REST
POST .../activitiesstill takesfrom.idas the sender even when the token names auser_id; only the socket enforces the verified id. - Per-request warnings on the tenant API can be noisy for an integration that still uses a channel token; that is intended pressure to migrate.
Follow-ups
- #22: protocol v1 frames on
the same socket;
postActivityandactivitySetbecome the deprecated pre-v1 shape. - #24:
ackframes forclientId. - Remove the legacy stack after the deprecation window (protocol v1, section 13.3).
Implementation
ConvergerWeb.ConvergerChannel(postActivity).ConvergerWeb.Deprecation, called fromUserSocket.connect/3,TenantAuth,TokenController.create/2andConversationController.create/2.Converger.Auth.Token(verify_channel_token/1,verify_conversation_token/1).- Tests:
test/converger_web/channels/converger_channel_test.exs,conversation_channel_test.exs(deprecation),test/converger_web/controllers/legacy_deprecation_test.exs,auth_test.exs(end-user tokens refused), and the integration tests. The chaos harness drives the Converger API socket.