ADR-0005: Separate client and system changesets for activities
| Status | Accepted |
| Date | 2026-10-08 |
| Issue | #5 |
| Pull request | #73 |
| Related | ADR-0003, ADR-0006, ADR-0015, ADR-0017 |
Activities are created from untrusted input on four paths: the tenant REST API, the Converger client API, inbound provider webhooks and the legacy WebSocket channel. This ADR records how Converger separates the fields a client may set from the fields only the server may set.
Context and problem statement
Converger.Activities.Activity.changeset/2 cast :inserted_at along with everything else, and both ConvergerWeb.ActivityController.create/2 and ConversationChannel.handle_in/3 forwarded the raw request params into create_activity/1, only overriding tenant_id and conversation_id. A client could therefore:
- backdate or postdate messages by sending
inserted_at. Ordering and watermark replay were based on(inserted_at, id), so other clients could receive the message out of order or never receive it on resume; - set
idempotency_keyin the body, bypassing the header-based idempotency contract; - impersonate any sender over WebSocket, for example
"bot", becausesendercame from the payload; - submit any
typestring, unboundedmetadataand arbitraryattachments, which then fanned out to every channel and subscriber.
This is classic mass assignment: the trust boundary was implicit in each caller instead of explicit in the schema.
Decision drivers
- The server timestamp and server-assigned identity must not be forgeable.
- The rule must be enforced in one place, not remembered by every controller and channel.
- Size limits protect the database, the PubSub fan-out and downstream providers.
- Validation errors must be field-level and machine-readable (422 on REST).
- Server-internal callers (echo replies, lifecycle events, fixtures) still need to create activities with system fields.
Considered options
- Two changesets plus a client entry point -
client_changeset/2casts an allowlist,system_changeset/2casts server fields, andcreate_client_activity(client_params, system_attrs)filters untrusted input before merging trusted attributes. - Strip forbidden keys in each controller and channel - keep one changeset; every caller deletes
inserted_at,senderand so on before callingcreate_activity/1. - Embedded schema per transport - a separate input schema (REST, WS, inbound) that validates and maps to the activity.
- Keep one changeset but stop casting
inserted_at- minimal fix for the worst symptom.
Pros and cons of the options
Option 1: Client and system changesets
- Good: the allowlist lives next to the schema (
Activity.client_fields/0) and every untrusted path goes through one function. - Good: server fields come only from a separate argument, so a client key cannot override them even if a caller forgets.
- Good: trusted callers keep the full
changeset/2(client plus system). - Bad: two entry points (
create_activity/2andcreate_client_activity/2); a new transport that calls the trusted one with raw input reintroduces the bug.
Option 2: Per-caller stripping
- Good: no schema change.
- Bad: a denylist in every caller; the next field added to the schema is assignable by default.
Option 3: Per-transport input schemas
- Good: precise per-protocol validation.
- Bad: more code, and the size and type rules would be duplicated or still need a shared core.
Option 4: Only drop inserted_at
- Good: one-line change.
- Bad: leaves sender spoofing, body idempotency keys and unbounded payloads open.
Decision
Chosen option: "Two changesets plus a client entry point" (option 1), because it makes the trust boundary a property of the schema rather than of each caller, and it uses an allowlist, so new fields are server-only until someone deliberately makes them client-settable.
The rules:
Activity.client_changeset/2casts onlytype,text,attachmentsandmetadata, and validates:typeagainstActivity.types/0:message,event,typing,conversationUpdate,endOfConversation;- limits from
config :converger, :activity_limits(defaults below).
Activity.system_changeset/2caststenant_id,conversation_id,senderandidempotency_key, requiressender,tenant_idandconversation_id, and carries the foreign-key and(conversation_id, idempotency_key)unique constraints.inserted_atis never cast, by either changeset. The server timestamp always wins.seqis not cast either; it is allocated under a lock (see ADR-0006).Activities.create_client_activity(client_params, system_attrs)takes onlyclient_fields/0keys fromclient_params, mergessystem_attrson top, and callscreate_activity/2. Each transport decides where the server fields come from:
| Path | sender | idempotency_key |
|---|---|---|
/api/v1 tenant REST (ActivityController) | request body, because this is a server-to-server API authenticated by the tenant API key | x-idempotency-key header only |
Converger client API (ConvergerAPI.ActivityController) | from.id in the body, per the protocol | x-idempotency-key header only |
Inbound webhooks (InboundController) | the sender parsed by the channel adapter | the provider message id parsed by the adapter |
Legacy WebSocket (ConversationChannel) | token sub claim | not settable |
| Limit | Default | Measured as |
|---|---|---|
max_text_bytes | 65536 | bytes of text |
max_attachments | 10 | list length |
max_attachment_bytes | 4096 | JSON size of each attachment map |
max_metadata_bytes | 16384 | JSON size of metadata |
Errors: REST returns 422 with field-level errors through the fallback controller. The legacy WebSocket replies {:error, %{reason: "invalid_activity", errors: %{field => [message]}}}.
Consequences
Positive
- Clients cannot backdate messages, choose their own idempotency key in the body, or impersonate another sender over WebSocket.
- Every activity type is from a known list, and payload sizes are bounded before they reach the pipeline.
- New schema fields are server-only by default.
Negative and trade-offs
- Behaviour change on the legacy WebSocket
ConversationChannel:senderis now the token subject (falling back to"user"when the token has none). Clients that relied on sending their ownsendersee a different value. - The
/api/v1tenant API and the Converger client API still let the caller name the sender. That is acceptable for the server-to-server tenant API; for the client API it follows the protocol'sfrom.idand relies on the conversation-scoped token for authorization. - The upload controller builds its activity params explicitly and calls the trusted
create_activity/2; it takestype,text,metadataandfrom.idfrom the multipartactivityJSON. It never passesinserted_atoridempotency_key, but it is a second place to keep correct (see ADR-0007). - Test fixtures can no longer cast
inserted_at;activity_fixturebackdates with a direct row update.
Follow-ups
- The rich activity model will extend the type list and validate attachments with a schema: #28.
- Client-stamped message ids with server acks over WebSocket: #24.
- Scoped tokens and stronger client identity: #52.
Implementation
Converger.Activities.Activity:@client_fields,@system_fields,@default_limits,types/0,limits/0,client_changeset/2,system_changeset/2,changeset/2.Converger.Activities.create_client_activity/2.- Callers:
ConvergerWeb.ActivityController,ConvergerWeb.ConvergerAPI.ActivityController,ConvergerWeb.InboundController,ConvergerWeb.ConversationChannel.
Tests: test/converger_web/controllers/activity_mass_assignment_test.exs checks that a body inserted_at is ignored and that each limit (text, attachment count, attachment size, metadata, unknown type, configured limits) returns 422. test/converger_web/channels/conversation_channel_test.exs checks that a WebSocket payload cannot set sender, inserted_at or idempotency_key, and that invalid payloads get field-level error replies.
Links
- Issue #5, pull request #73
- Security overview