ADR-0036: Rich activity model with a closed type vocabulary, validated attachments and append-only edits
| Status | Accepted |
| Date | 2026-10-09 |
| Issue | #28 |
| Pull request | #125 |
| Related | ADR-0004, ADR-0005, ADR-0006, ADR-0007, ADR-0024, ADR-0032, ADR-0033, ADR-0034 |
Context and problem statement
Every modern channel emits more than plain messages: WhatsApp sends reactions and replies with a
context.id, Slack and Telegram edit and delete messages, and media arrive as provider-specific
objects. Before #28, Converger had five activity
types, a WhatsApp reaction arrived as an event that pointed at a provider message id nobody could
resolve, and attachments was an unvalidated list of maps. Adapters put whatever they had into it
(providerMediaId at the top level, no contentType at all from some clients), so a WebSocket
client could not rely on any attachment field, and there was no way to say "this message edits,
deletes, reacts to or answers that one".
The model has to stay compatible with what is already stored (activities are immutable and ordered
by seq, ADR-0006), with the client and system
changesets (ADR-0005) and with the single
canonical serializer (ADR-0004).
Decision drivers
- A WebSocket or REST client must be able to render any activity from documented fields only.
- No silent loss: an inbound provider event Converger cannot map is kept, not dropped.
- The activity log stays append-only and gap-free; an edit must not rewrite history behind a client's watermark.
- Existing rows (attachments without
contentType, provider keys at the top level) must keep loading unchanged; the migration must be online-safe. - Channels that cannot render a type (WhatsApp cannot show "message edited") must not receive empty or broken messages.
Considered options
- Closed vocabulary, embedded-schema validation on write, edits and deletes as new activities referencing the original, adapter capabilities with downgrade (chosen)
- Open, renderer-named types stored opaquely (as sketched in the draft rich message vocabulary, messages.md), validated only when the type is known
- Edit and delete in place:
PATCH/DELETEendpoints that rewrite or remove the original row embeds_manyattachments instead of validating a list of maps
Pros and cons of the options
Option 1
- Good, because every consumer can switch exhaustively over nine types, and an unknown type is a
client error (
422) instead of something a channel silently fails to deliver. - Good, because the log stays append-only: a
messageUpdategets its ownseqand reaches every client that is behind, through replay, like any other activity. - Good, because validation only runs on new writes, so stored rows are untouched.
- Bad, because clients must fold updates and deletes into the original themselves.
Option 2
- Good, because it matches mekik/1's open renderer-named frames.
- Bad, because adapters, downgrade and storage cannot reason about types they do not know, and the issue's acceptance criterion requires unknown types to be rejected.
Option 3
- Good, because a single read shows the current state.
- Bad, because it rewrites rows behind clients' watermarks: a client that already received the original never learns about the change, and replay is no longer a faithful log.
Option 4
- Good, because Ecto validates and types every field on load.
- Bad, because loading drops unknown keys and so loses data in rows written before the schema; it also changes the column semantics for every existing reader.
Decision
Chosen option: "Option 1".
- Vocabulary.
Activity.types/0ismessage,event,typing,messageReaction,messageUpdate,messageDelete,conversationUpdate,endOfConversationand the internaldeliveryReceipt. Internal types are accepted only withcreate_activity(attrs, internal: true); the client changeset rejects them. Any other type is a422. - Attachments.
Converger.Activities.ActivityAttachmentis an embedded schema used to validate and normalise each attachment on write:contentType(a MIME type) is required,contentUrlandthumbnailUrlmust behttp(s)URLs or server paths,sizeis a non-negative integer,application/vnd.converger.card.*requires an objectcontent, provider fields go inchannelData, other keys are dropped. The column stays a list of maps, so old rows load as they are. - References. A new nullable
activities.reply_to_idnames the activity an activity refers to, in the same conversation. It has no foreign key:activitiesis partitioned by month (ADR-0034), its primary key is(id, inserted_at)soreply_to_idalone cannot reference it, and retention drops whole months. The same-conversation rule is enforced bycreate_activity/2under the conversation row lock, and readers must tolerate a danglingreply_to_id(the original was removed by retention or a purge). The reference is optional formessage(a threaded reply), required formessageReaction,messageUpdateandmessageDelete, whose target must be amessage. Updates and deletes must come from the original's sender and cannot target a deleted message. - Edits and deletes are new activities. Accepting a
messageUpdateormessageDeletestampsedited_atordeleted_aton the original in the same transaction, under the conversation row lock that allocates theseq, so concurrent deletes are serialised. The original's content is not rewritten; SDKs apply the change. - Inbound. Adapters name a referenced provider message as
"reply_to_provider_id". The inbound controller resolves it to an activity of the same conversation (an inbound message by idempotency key, an outbound one by its delivery's provider message id). An unresolvable reaction, edit or delete is stored as aneventwith its metadata, never dropped. - Capabilities and downgrade. Adapters may add an
activity_types: [...]entry to thecapabilities/0list that #22 introduced for:outbound(ADR-0033); without it every client type is delivered as before. For types the adapter does not list, the channel config keyunsupported_activitiesdecides:downgrade(default) delivers amessagewith a text rendering ("user reacted with 👍"), or skips types with nothing to say (typing, a removed reaction);skipdoes not deliver.deliveryReceiptis never delivered to channels. WebSocket clients always receive every type.
Consequences
Positive
- A WhatsApp reaction is a
messageReactionwhosereplyToIdis the reacted-to activity, whether the user reacted to their own message or to a bot reply. - Clients get one documented attachment shape, published as a JSON Schema
(
priv/protocol/v1/activity.schema.json) and tested against the server's own validation. - WhatsApp channels no longer receive empty messages for
typingor text-lesseventactivities.
Negative and trade-offs
- The original row of an edited or deleted message keeps its content until retention drops its month (ADR-0034); redacting a single deleted message earlier needs a separate mechanism.
- Without a foreign key,
reply_to_idcan dangle: retention drops the month holding an original while a later reply, reaction or edit referring to it lives on in a newer month. Clients render such a reference as "original unavailable"; a new activity cannot reference an activity that is gone (it gets422, "does not exist in this conversation"). - A REST reader that only fetches one page sees
editedAton the original but has to page on to find themessageUpdateitself. - Attachments written before the schema are returned as stored and may lack
contentType; the JSON Schema therefore types returned attachments loosely and the write shape strictly. - The draft rich message vocabulary (messages.md, #68) describes open
renderer-named types; at the storage and REST level those become a closed type plus typed
payloads (cards as
application/vnd.converger.card.*attachments), and the frame-level mapping is left to #22 and #68.
Follow-ups
- #36: adapter behaviour v2 extends
capabilities/0. - #37: native outbound WhatsApp reactions, media and interactive messages, which move those types from "downgrade" to "native".
- #68: rich message vocabulary and frame mapping.
deliveryReceiptis reserved vocabulary: #25 made receipts and typing transient signals (ADR-0032), so nothing emits it today. A future server-side use creates it withinternal: true. Likewise thetypingactivity type stays only for clients that still store typing over REST; it is never delivered to messaging adapters, which get typing throughsend_typing/2.
Implementation
Converger.Activities.Activity(types,reply_to_id,edited_at,deleted_at, per-type rules),Converger.Activities.ActivityAttachment,Converger.Activities.Downgrade.Converger.Activities.create_activity/2checks references and stamps the original;get_activity_by_provider_message_id/2resolves provider ids.Converger.Channels.Adapter.activity_types/1and anactivity_types:entry in thecapabilities/0of the WhatsApp and echo adapters;Converger.Pipelinefilters skipped channels and applies the downgrade at delivery time.- Migration
20261010400000_add_reply_and_edit_columns_to_activities(nullable columns on the partitioned table, no foreign key; the partial index onreply_to_idis createdON ONLYthe parent, builtCONCURRENTLYon every partition and attached; later partitions get it on attach). Stampingedited_at/deleted_atupdates the original by(id, inserted_at), so only its partition is touched. - Tests:
test/converger/activities/rich_activity_test.exs,test/converger_web/controllers/rich_activity_api_test.exs.