ADR-0062: Typed message payloads stored losslessly on message activities
| Status | Accepted |
| Date | 2026-10-14 |
| Issue | #68, #63 |
| Pull request | #166 |
| Related | ADR-0024, ADR-0036, ADR-0034 |
Context and problem statement
Converger Protocol v1 (ADR-0024) carries rich
messages as typed frames: chativa's vocabulary (buttons, card, carousel, image, ...) and
mekik/1's agent-runtime frames (tool_call, genui, interrupt, interrupt_resolved, skill), plus
open renderer-named types. The activity model of ADR-0036 only had
text, attachments and metadata. A client that sent a location frame got invalid_message, and
nothing could persist what a mekik bot emits. #63 also promises that mekik's golden fixture frames
round-trip through Converger's storage byte-identically (canonical JSON), except seq.
Decision drivers
- Lossless: a stored frame must re-serialise to what was received, including fields Converger does not know (open types, future mekik fields).
activities.typestays the closed vocabulary of ADR-0036. A new message type must not be a new activity type, because every adapter, routing condition and downgrade rule keys on it.- Channels that cannot render a type still get something sensible, with no change per adapter today.
- No rewrite of the partitioned
activitiestable (ADR-0034).
Considered options
- Two nullable columns on
activities:message_typeandpayload(JSONB), onmessageactivities. - One activity type per message type (
card,tool_call, ... inactivities.type). - Encode payloads as attachments (
application/vnd.converger.card+json), as the interim card mapping did.
Pros and cons of the options
Option 1
- Good, because the activity vocabulary, routing and every adapter keep working unchanged, and a
typed message without a renderer falls back to
text. - Good, because the payload is the frame body verbatim, so unknown fields survive.
- Bad, because there is one more place to look for content (
payloadnext totextandattachments).
Option 2
- Good, because the type is visible in one column.
- Bad, because the closed vocabulary becomes open (renderer names are client-chosen), and every
typecheck in adapters, routing and downgrade has to learn each new type.
Option 3
- Good, because there is no migration.
- Bad, because attachments are limited to 4 KiB each and 10 per activity, and they are validated
and normalised (lossy). Frame-level fields (
actions, a mekikid) have nowhere to go.
Decision
Chosen option: "Two nullable columns", because it keeps the activity model closed and stores the frame losslessly.
- A persistent frame other than a plain text turn is a
messageactivity withmessage_type(the frame type) andpayload. For mekik-native frames the payload is every top-level field excepttypeandseq. For message frames it is everything excepttype,seqand the envelope the server owns (from,timestamp,sender,metadata,clientId,replyTo,meta). A message frame'sidis kept only when a bot names its messages; a client'sidis its clientId. - A
textframe with onlydata.text(anddata.attachments) keeps the plain columns (message_typenil), as before. - Known types are validated against
priv/protocol/v1on write (jsvbecomes a runtime dependency). Failures areinvalid_messagewith per-field details, for examplepayload: ["data/buttons/0: property 'label' is required"]. Open renderer-named types only need an objectdata; they are counted inconverger.activities.unknown_type.count. textis filled from the payload when the sender gave none (data.text, caption, title, labels,payload.titleof an interrupt), for search and channel fallbacks. A frame is at most 128 KiB.- Adapters declare natively rendered types with a
message_types:capability (a list or:all). Adapters that listactivity_types(end-user messaging) default to none. All other adapters (WebSocket, webhook, brokers) default to:all. A channel without a native renderer gets a plainmessagewith the text fallback, or nothing when there is none.tool_callreaches end-user channels only withshow_tool_traces: true, as aname: statusline.
Consequences
Positive
- Clients can send every type of the vocabulary; bots and agents can store mekik frames. The mekik fixture round trip of #63 is tested.
- No existing activity changes shape. REST adds
messageTypeandpayload, both null for plain messages.
Negative and trade-offs
- Until adapters render types natively (the per-channel matrix of messages.md section 5), WhatsApp, Telegram, Slack, email and SMS get text only, so buttons arrive without buttons.
- Stored payloads are not re-validated when the schemas change. A schema tightening applies to new writes only.
tool_callupdates are stored as separate activities (one per status). Clients upsert them bydata.id, as in mekik/1; storage does not merge them.
Follow-ups
- #68: native rendering per adapter, answer
normalisation (
metadata.action), interrupt answers asresume, portal rendering. - #64: the mekik relay and the channel-scoped socket write
bot frames through this storage with
keep_id: true.
Implementation
- Migration
20261014100000_add_message_type_and_payload_to_activities.exs: two nullable columns, metadata-only on the partitioned table. Converger.Activities.MessagePayload: frame split, validation, text fallback.Converger.ProtocolSchemasmoved fromtest/supporttolib/with cached roots and per-field details.Converger.Activities.Activity:message_type,payload,max_payload_bytes.ConvergerWeb.Protocol.Send/Frames: client sends of rich types on the native endpoint and the Phoenix binding, and typed frames rendered back.Converger.Activities.Downgrade,Converger.Channels.Adapter.message_types/1, channel configshow_tool_traces.- Tests:
test/converger/activities/typed_message_test.exs,test/protocol/mekik_storage_round_trip_test.exs, the native socket, Phoenix binding and REST tests.
Links
- Rich message vocabulary, sections 4 and 5
- Converger Protocol v1, sections 5 and 14
- Activities