Architecture decision records
An Architecture Decision Record (ADR) captures one architecturally significant
decision: the problem and the forces at play, the options that were on the
table, what was chosen and why, and what it costs. Converger keeps its ADRs in
docs/adr/, next to
the code, in the MADR format.
ADRs are immutable once accepted. When a decision changes, a new ADR supersedes the old one and both are linked. Read How we write ADRs before adding one, and start from the template.
Status values
| Status | Meaning |
|---|---|
| Proposed | Under discussion in an issue or pull request. |
| Accepted | Decided and, unless noted, implemented on main. |
| Deprecated | No longer relevant (the feature was removed). |
| Superseded by ADR-NNNN | Replaced by a later decision. |
Index
Delivery and data integrity
| ADR | Decision | Status | Issue / PR |
|---|---|---|---|
| 0001 | Transactional outbox: Oban delivery jobs are inserted in the activity transaction | Accepted | #1 / #69 |
| 0002 | Broadway for throughput, Oban for retries and dead letters | Accepted | #2 / #70 |
| 0003 | The pipeline is the only delivery path | Accepted | #3 / #71 |
| 0004 | One canonical activity serializer for REST, sockets and broadcasts | Accepted | #4 / #72 |
| 0005 | Separate client and system changesets for activities | Accepted | #5 / #73 |
| 0006 | Per-conversation seq under a row lock and opaque seq watermarks | Accepted (opaque watermarks superseded by ADR-0068) | #6 / #78 |
| 0015 | Per-message idempotent processing of inbound batches | Accepted | #15 / #82 |
| 0016 | Participant-based conversation resolution for inbound messages | Accepted | #16 / #88 |
| 0017 | Conversation lifecycle enforced under the seq lock | Accepted | #17 / #84 |
| 0018 | Keyset pagination for every list query | Accepted | #18 / #89 |
| 0019 | Per-channel retry policy, DeliveryError and Oban Lifeline | Accepted | #19 / #85 |
| 0028 | Dead-letter replay resets the delivery in place and always goes through Oban | Accepted | #32 / #121 |
| 0031 | Per-channel circuit breaker on the channel row, priority-based parking, Hammer rate limits and tier queues | Accepted | #31 / #120 |
| 0076 | When a channel's breaker opens or it is paused, its whole waiting backlog is parked in one statement and new deliveries are inserted parked; at most max_in_flight deliveries of one channel run at once per node (default half of the queue's workers), and a channel at its cap has its waiting jobs demoted to priority 2 | Accepted | #191 / (this PR) |
| 0034 | Monthly partitioning of activities and deliveries, per-tenant retention and verified archives | Accepted | #30 / #124 |
| 0036 | Rich activity model: closed type vocabulary, validated attachments, append-only edits and deletes, adapter capabilities with downgrade | Accepted | #28 / #125 |
| 0062 | Typed message payloads: rich message and mekik-native frames stored losslessly as message activities with message_type and payload, validated against the Protocol v1 schemas, text fallback and message_types capability for channels | Accepted | #68 / #166 |
| 0064 | Per-channel rendering of typed messages: native buttons, lists, keyboards and Block Kit on WhatsApp, Telegram, Slack and email, numbered-list text elsewhere, and answers normalised to one user turn through opaque answer keys and numeric replies | Accepted (genui joining rule superseded by ADR-0072) | #68 / (this PR) |
| 0070 | Pluggable event backbone: platform events through Converger.Backbone with Native (PubSub + Oban, default), Kafka, NATS JetStream and RabbitMQ selected by one config value, idempotency receipts in the handler's transaction, broker outages parked in Postgres, dual publish for migrations; deliveries and the WebSocket fan-out stay | Accepted | #66 / (this PR) |
| 0071 | activity.created is published in the transaction that stores the activity (transactional outbox on every backbone: Native subscription jobs, a publish job for brokers), instead of after the commit, which lost events when a node died in between | Accepted | #66 |
| 0072 | A GenUI stream joined into one message for a channel without GenUI concatenates the text chunks that share a chunk id (a text run, as mekik streams and chativa renders them) instead of keeping the last one; runs are trimmed and joined by blank lines | Accepted | #67 / (this PR) |
Channels, middleware and real time
| ADR | Decision | Status | Issue / PR |
|---|---|---|---|
| 0007 | Attachment storage with hand-written Req signing, CDN layer and authenticated downloads | Accepted | #7 / #80 |
| 0008 | Middleware receives the channel; middleware crashes are contained | Accepted | #8 / #74 |
| 0020 | Per-subject socket ids and Presence | Accepted | #20 / #81 |
| 0024 | Converger Protocol v1 is a superset profile of mekik/1 | Accepted | #63 |
| 0026 | One client socket stack; the legacy socket and token family are deprecated | Accepted | #23 / #115 |
| 0027 | WebSocket limits in the socket transport, mailbox backpressure and readiness-gated draining | Accepted | #27 / #117 |
| 0029 | Protocol v1 sends on the Phoenix binding are acked in the channel process, with an in-flight window counted from its mailbox | Accepted | #24 / #127 |
| 0063 | Channel-scoped native v1 connections serve every routed conversation in {conversationId, frame} envelopes; a bot channel is a routing target with a connected agent; bot-only frames, transient bot frames and meta relayed over PubSub; interrupt answers become resume; hello.group shares conversations by rendezvous hashing over :pg | Accepted | #64 |
| 0069 | Agent group members own the user turns committed after their published eligibility time (join plus group_settle_ms), decided from the turn's inserted_at by every member alike; on a member's departure the new owner receives the unanswered turns that were passed to it | Accepted | #67 / (this PR) |
| 0066 | The mekik channel adapter (Mode A): one supervised, :global Mint WebSocket per conversation to a stock mekik/1 server, the mekik conversation and watermark recorded in mekik_sessions to resume, persistent frames stored exactly once by seq idempotency keys, a per-node relay for bot-only frames and turn meta, a :bot_relay adapter capability | Accepted | #64 |
| 0030 | Native v1 WebSocket on WebSock, MessagePack by subprotocol, SSE and long-poll fallbacks | Accepted | #26 / #122 |
| 0032 | Transient conversation signals: receipts, typing and presence over dedicated topics, stored read watermarks, optional adapter callbacks | Accepted | #25 |
| 0033 | The websocket channel is a delivering adapter with pending receipts | Accepted | #22 / #119 |
| 0035 | Native transports share the signals core, the client WebSocket limits and draining | Accepted | #26, #25, #27 / #123 |
| 0077 | Protocol v1 handshakes and sync replays wait for one of a bounded number of per-node slots (half the pool by default) and read through one pool checkout, so a drained node's clients queue on the survivors instead of failing in the pool; the TypeScript SDK caps its backoff at 10 s and the load harness honours retryAfterMs | Accepted | #198 / (this PR) |
| 0056 | Protocol v1 over a gRPC bidirectional stream on its own port; one transport-agnostic session for the WebSocket and gRPC, lossless JSON-protobuf frame mapping with a raw Struct fallback, a Close frame plus a matching gRPC status for endings | Accepted | #44 / #156 |
| 0038 | Adapter behaviour v2: declared capabilities and config schemas, a registry from built-ins plus config :converger, :adapters | Accepted | #36 / #130 |
| 0039 | WhatsApp rich outbound messages through a shared planner, synchronous inbound media download into Converger storage, cached template listing | Accepted | #37 / #134 |
| 0040 | Slack adapter: <channel>:<ts> message refs, conversation_scope thread / channel / user, an in-band inbound_challenge/2 callback | Accepted | #39 / #131 |
| 0041 | Telegram adapter: chat-scoped message ids, a configured secret token is always enforced, inbound files stored as uploads, register_webhook/2 behind an admin button | Accepted | #38 / #132 |
| 0042 | Email adapter: deterministic Message-ID per activity, thread-first conversation resolution (thread_refs), provider webhooks with per-provider authentication (SNS signature and topic, Mailgun HMAC, basic auth) | Accepted | #40 / #133 |
| 0045 | SMS adapters (Twilio, Vonage): a participant opted_out_at flag the pipeline checks before every delivery, Twilio signatures checked against candidate public URLs, an optional inbound_response/1 callback (TwiML) | Accepted | #41 / #136 |
| 0046 | Conditional routing rules (JSON conditions validated on save, priority with first-match stop, per-rule middleware), reply-back routing by the activity's source channel, conversations spanning channels, and a dry-run on the real planner | Accepted | #43 / #138 |
| 0047 | Broker channels: kafka and rabbitmq channel types, one Broadway consumer per channel in the cluster (:global plus a reconciling manager), broker-message-id idempotency, per-channel dead-letter topic / queue; the Broadway pipeline transport stays internal | Accepted | #42 / #141 |
| 0048 | Discord, Teams, Messenger, Instagram, Viber and HTTP pull: shared Meta Graph code, cached Bot Framework JWT validation with a service URL allowlist, users as participants with reply targets in metadata, inbound_response/2, a pull outbox acknowledged by served-only watermarks | Accepted | #44 / #139 |
| 0054 | NATS JetStream and Redis Streams broker channels on the ADR-0047 machinery: gnat and redix with small Broadway producers of our own (a durable pull consumer; a consumer group with XAUTOCLAIM recovery on start), stream sequence / entry id as the broker message id, per-channel dead-letter subject / stream | Accepted | #42 / #155 |
| 0053 | Platform event webhooks: tenant subscriptions to a fixed event catalog emitted from the existing broadcast hooks, one webhook_events row and Oban job per subscription, outbound signing and SSRF guard, shared retry policy, per-subscription dead letters and redelivery, 7-day log; alert_webhook_url becomes a managed subscription | Accepted | #49 / #143 |
| 0055 | MQTT channel on an in-house MQTT 3.1.1 client that acknowledges QoS 1 messages after the commit, persistent session per channel, idempotency key from the envelope or a SHA-256 of topic and payload; Discord Gateway inbound as an optional mode run by the broker consumer infrastructure, which gets a per-adapter child spec and a per-channel consume?/1 | Accepted | #44 / #157 |
Clients and SDKs
| ADR | Decision | Status | Issue / PR |
|---|---|---|---|
| 0065 | chativa's unmodified connector-mekik is the reference WebSocket client, kept working by a CI suite that runs its vendored code in Node against a real server; mekik/1 clients (no subprotocol, no hello.protocol) get a profile without Converger-only frames and heartbeats as pings; REST history pages backwards with a seq cursor (format=frames), channel-scoped tokens list conversations | Accepted | #65 / (this PR) |
| 0067 | Agent SDKs share one onTurn(turn, reply) API over the channel-scoped socket: reply.stream as genui text chunks, reply.ask as interrupt/resume, run frames around handlers, agent groups, watermark resume and acked sends | Accepted | #67 / (this PR) |
| 0068 | The watermark is the integer seq on the Converger REST API, the tenant API and every v1 surface; inputs accept the integer, its decimal string and the pre-v1 opaque forms for one release, reported as the deprecated opaque_watermark surface; only the legacy Phoenix activitySet keeps the string | Proposed | #63 / (this PR) |
| 0052 | Server-side SDKs (Python, Go, Elixir) in packages/ of this repository, a shared conformance suite (REST, errors, protocol resume state machine, webhook signature vectors generated from the server signer), generator-shaped hand-written models until #45, per-language tag releases | Accepted | #47 / #144 |
| 0051 | TypeScript SDK @converger/client: one session core over native / Phoenix / SSE transports, a persistent clientId outbox resent once after the replay, hand-written types in a generator-compatible shape until #45 | Accepted | #46 / #142 |
Security
| ADR | Decision | Status | Issue / PR |
|---|---|---|---|
| 0009 | Inbound signature scheme t=,v1= and per-channel require_signature | Accepted | #9 / #77 |
| 0011 | A custom trusted-proxies plug instead of the remote_ip library | Accepted | #11 / #75 |
| 0012 | Secrets at rest (Cloak AES-GCM), hashed tenant API keys, recursive audit redaction | Accepted | #12 / #79 |
| 0013 | Cluster-wide rate limiting with Hammer 7 ETS and PubSub sync | Accepted | #13 / #83 |
| 0014 | Webhook SSRF guard with DNS pinning and outbound signing | Accepted | #14 / #87 |
| 0058 | Admin and portal logins as hashed server-side session rows (absolute and idle expiry, revocable, LiveView disconnect), TOTP 2FA with nimble_totp (encrypted secret, single-use codes, required for super_admin by default), single-use hashed email reset tokens, one password policy (12 characters, 72 bytes, no email) | Accepted | #52 / #160 |
| 0059 | Client tokens: dedicated signing keys with kid, Postgres revocation list cached in ETS per node and fanned out over PubSub (rejected cluster-wide within 1 s), refresh rotation with a grace period and family-wide reuse detection, perms claim (read, write, typing) | Accepted | #52 / #158 |
| 0061 | Scoped tenant API keys (read, write, manage) in api_keys next to the full-access primary key, scope checks in TenantAuth that fail closed, /api/v1/manage for new configuration resources and manage on the existing ones, an ADMIN_API_TOKEN admin API, ETags with If-Match | Accepted | #51 / #164 |
| 0074 | Publishable keys for sites without a backend: a per-channel cvg_pk_ key usable only from allowed origins mints an anonymous user and a token bound to a new conversation (src: "publishable"), continued with a signed resume credential; own per-IP and per-channel limits; CORS on the client API follows the keys' origins; test tokens from the channel page | Accepted | #195 / #197 |
Platform, operations and process
| ADR | Decision | Status | Issue / PR |
|---|---|---|---|
| 0010 | CORS origins and OpenTelemetry export configured at runtime | Accepted | #10 / #76 |
| 0021 | CI quality gates and LF line endings | Accepted | #54 / #92 |
| 0022 | Deployment hardening: locked one-shot migrations, runtime ForceSSL/HSTS, fail-fast on leaked secrets | Accepted | #55 / #90 |
| 0023 | Platform and dependency baseline (OTP 28 / Elixir 1.19, Req 0.7, LoggerJSON, Oban 2.24) | Accepted | #56 / #91, #93, #104, #105 |
| 0025 | Docusaurus documentation site and documentation with every change | Accepted | #48 |
| 0037 | libcluster clustering (runtime strategy), /health/live and /health/ready, metrics on the main port behind a token, two-node :peer suite | Accepted | #29 / #129 |
| 0043 | Delivery SLO telemetry on the adapter call, polled database gauges, and alert rules as tested Prometheus rules | Accepted | #33 / #135 |
| 0044 | Oban on a second (background) pool of the same repo; retryable 503 / unavailable / replay_unavailable instead of crashes under database pressure; readiness degraded on pool wait | Accepted | #35 / #137 |
| 0049 | k6 load harness on the release image (1 or 3 nodes), seq-based lost-message checks, results in docs/benchmarks/, nightly 15 % p95 regression gate | Accepted | #34 / #140 |
| 0050 | OpenAPI 3.1 generated from open_api_spex annotations and committed (CI fails on drift), Swagger UI at /api/docs, one error envelope {code, message, details, request_id} with Protocol v1 codes, URL versioning with Deprecation / Sunset and a 12-month window | Accepted | #45 / #145 |
| 0057 | Password hashing behind Converger.Accounts.PasswordHasher: bcrypt by default, PBKDF2 (pure Elixir) when CONVERGER_PASSWORD_HASHER=pbkdf2 also drops the bcrypt NIF from the build, verification by the stored hash's prefix; .devcontainer/ with the pinned toolchain and Postgres | Accepted | #53 / #159 |
| 0060 | Admin panel and tenant portal stay LiveView, styled with Tailwind v4 + daisyUI 5 + heroicons (the Phoenix 1.8 stack) and built by the standalone tailwind/esbuild binaries: no Node.js, no CDN, one root layout with <Layouts.app>, light and dark themes | Accepted | #50 / #162 |
| 0073 | MIT license (inbound = outbound, no CLA) on the repository and every SDK package; SECURITY.md, Contributor Covenant code of conduct and issue forms | Accepted | #48 / #126 |