Files
Toju/agents-docs/features/messaging.md
brogeby 47beed01ca docs: add cross-context feature docs for auth, presence, access-control, messaging, attachments
Fills the five highest-value gaps under agents-docs/features/ so the index covers
the system's main cross-context contracts. Each doc follows the feature-template
structure and the AGENTS_FEATURES.md contract, with honest TODOs where coverage
or behavior couldn't be confirmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:33:41 +02:00

12 KiB

Messaging

Area: messaging Status: Active Last updated: 2026-05-25

Overview

Messaging in Toju covers two transports that share a single domain model. Server-channel chat is broadcast by the signaling server over WebSocket — fire-and-forget, no server-side persistence today. Direct messages (1:1 and group DMs) are peer-to-peer over the WebRTC chat data channel, with a signaling-server fallback when no data channel is open and an offline queue for when neither path is available. On both transports the client maintains a monotonic delivery state machine per message and a chunked inventory-sync protocol that lets two peers reconcile missing history without flooding the link.

This document is the cross-context contract: envelope names, sync protocol, delivery states, edit/delete rules, and storage decisions. The product-client domain READMEs at toju-app/src/app/domains/chat/README.md and toju-app/src/app/domains/direct-message/README.md cover internal NgRx state and effects; the wire shapes live in websocket-envelopes.

Responsibilities

  • Send, edit, and delete server-channel chat messages over WebSocket (chat_message, edit_message, delete_message).
  • Send, edit, and delete direct messages over the WebRTC data channel with signaling fallback.
  • Carry typing indicators on server-channel chat (user_typing).
  • Reconcile peer history via the inventory-sync protocol (chunked, capped backfill).
  • Drive a monotonic delivery state machine: QUEUED → SENT → DELIVERED → ACKNOWLEDGED.
  • Persist direct messages locally; the server keeps no message store.

This area does not own:

  • Attachment payloads or the chunked file-transfer protocol → attachments.
  • RTC negotiation that brings the data channel up → voice-signaling.
  • Permission to send a message (writeMessages, manageMessages, channel overrides, slowmode hint) → access-control.
  • The wire shape of every envelope used here → websocket-envelopes.

Key concepts

  • Server-channel message — broadcast through the signaling server to every connected peer of a server. No server-side persistence in the current build.
  • Direct message — point-to-point or group P2P message, persisted locally per-user via Electron CQRS (and via localStorage on browser fallback today — TODO confirm).
  • Conversation — 1:1 or group DM thread. Group DMs can be created by adding a third participant to a 1:1, which spawns a new conversation while preserving the original.
  • Inventory event — peer-to-peer announcement of "here is what I have for this conversation/channel"; the receiver replies with a request for missing pieces.
  • Sync batch — chunked response carrying up to 200 messages per envelope, capped at 1000 messages of backfill per inventory exchange.
  • Delivery state — monotonic enum on a direct message: QUEUED (0) → SENT (1) → DELIVERED (2) → ACKNOWLEDGED (3). Defined in the chat-events shared kernel; advanced via advanceDirectMessageStatus.
  • Peer-delivery servicePeerDeliveryService (toju-app/src/app/domains/direct-message/application/services/peer-delivery.service.ts) — the dispatcher that tries the data channel first, then the signaling forward, then the offline queue.

Transports

Server-channel chat (WebSocket)

  • Client sends chat_message to the signaling server; handleChatMessage (server/src/websocket/handler.ts:274) validates the user is in the target server and broadcasts to every other connection.
  • edit_message and delete_message follow the same fan-out path.
  • user_typing (handleTyping, handler.ts:309) is broadcast as a transient signal — no persistence, no sync, no delivery state.
  • The server does not persist these envelopes. Late joiners do not see chat history older than their join — they can request it from peers via the inventory protocol if any peer present has it stored locally.

Direct messages (WebRTC data channel)

  • DMs ride the chat-labelled data channel established alongside each voice peer connection (see voice-signaling).
  • PeerDeliveryService is the dispatcher. For each outgoing event it:
    1. Tries every open data channel to a recipients-listed peer.
    2. If no data channel is available to a recipient, falls back to a signaling-server forwardPeerMessage envelope so the server forwards it to that peer's connection.
    3. If neither path is open, enqueues the event in OfflineMessageQueueService and replays on peerConnected$ / networkRestored$.
  • The server forwards DM envelopes opaquely — no inspection, no persistence.

Storage

  • Direct messages persist via Electron CQRS — see ipc-bridge. Each user has their own local TypeORM database; messages are written via save-direct-message / equivalent commands.
  • Server-channel chat persists via DatabaseService (Electron CQRS) when running on desktop, or in IndexedDB when running purely in browser. TODO: confirm the IndexedDB code path.
  • The signaling server holds zero message bytes at rest today. Re-deploys lose nothing because there is nothing to lose.

Inventory / sync protocol

Both transports share the same inventory shape, defined in toju-app/src/app/shared-kernel/chat-events.ts:

  • ChatInventoryEvent — sender broadcasts "for conversation X I have messages with these ids and last-modified timestamps" (capped at 1000 entries).
  • ChatSyncBatchEvent — receiver replies with a chunked batch of full message payloads, up to 200 per envelope, repeated until the requested set is satisfied or 1000 messages have been returned.

Rules:

  • Inventory is additivemergeIncomingMessage / upsertDirectMessage only insert or update; a sparser peer never wipes a richer peer's history.
  • Reactions and attachment-link changes are reconciled by comparing per-message lastModifiedAt; the higher wins.
  • The 1000-message ceiling is per inventory exchange, not per conversation lifetime; an older history can be filled in piecewise across multiple inventory cycles.

The same protocol is reused for the chat domain (server channels) and the direct-message domain. The implementation lives in toju-app/src/app/domains/chat/domain/rules/message-sync.rules.ts and is invoked from the direct-message effects via DirectMessageService.requestSync().


Delivery state machine

For DMs, every outgoing message has a status:

Value Numeric Meaning
QUEUED 0 Composed locally; no transport attempt has succeeded yet.
SENT 1 At least one transport (data channel or signaling forward) has accepted the payload.
DELIVERED 2 At least one recipient has acknowledged receipt at the application layer.
ACKNOWLEDGED 3 The full recipient set has acknowledged (1:1: the one recipient; group: every participant).

Transitions are advanced via advanceDirectMessageStatus, which only advances — a higher value is never replaced by a lower one. A retried message that succeeds after a queue replay can therefore move QUEUED → SENT but never DELIVERED → SENT.

Server-channel messages do not carry an application-level delivery state today (the server broadcast is fire-and-forget); the UI treats them as SENT once the WebSocket accepts the frame.


Edit and delete

  • edit-message / delete-message events carry the original messageId. On both transports, the receiver locates the existing row (by id) and applies the mutation via applyMutation.
  • DMs use the same envelope types but ride the data channel / signaling-forward fabric.
  • Edits are last-writer-wins by editedAt. A delete removes the message body but keeps a tombstone with deletedAt so peers that haven't yet seen the delete can converge on the next inventory sync.

TODO: applyMutation does not currently verify the mutation originated from the original author. A non-cooperating client could send edit-message for someone else's message and a receiver would accept it. Confirm and either harden client-side or document the trust model.


Typing indicators

  • Server-channel only. DMs do not have a typing indicator today.
  • Sent as user_typing; broadcast to a server scope.
  • Transient: no persistence, no sync replay, no delivery state.

Business rules and invariants

  • The signaling server is not authoritative for messaging. handleChatMessage only broadcasts; there is no server-side message log.
  • The delivery state machine is monotonicadvanceDirectMessageStatus never moves status backwards.
  • DM envelopes are ignored unless the local user appears in participants / recipients (or has an existing local conversation matching the id).
  • Inventory merges are additivemergeIncomingMessage / upsertDirectMessage never delete or downgrade a richer local row.
  • A 1:1 → group upgrade preserves the original 1:1 history; the group is a new conversation.
  • Edits / deletes are reconciled by lastModifiedAt / editedAt / deletedAt.

Technical implementation

Server

  • WS handlers — server/src/websocket/handler.ts: handleChatMessage (line 274), handleTyping (309); DM forwarding via forwardPeerMessage / forwardRtcMessage.
  • No CQRS, no entities, no migrations: server messaging is broadcast-only.

Product client

  • Chat domain — toju-app/src/app/domains/chat/: services, effects, sync rules at domain/rules/message-sync.rules.ts.
  • Direct-message domain — toju-app/src/app/domains/direct-message/: DirectMessageService, PeerDeliveryService (application/services/peer-delivery.service.ts), offline queue.
  • Shared kernel — toju-app/src/app/shared-kernel/chat-events.ts (ChatInventoryEvent, ChatSyncBatchEvent, chat_message, edit-message, delete-message, direct-message-sync, direct-message-sync-request).
  • Persistence — Electron CQRS commands for DMs (see ipc-bridge); DatabaseService for server-channel chat.

Electron

  • DM persistence — TypeORM entity (likely MessageEntity and a DM-specific row) + CQRS handlers. Backup / restore is part of the Electron data-management surface.

Testing

  • TODO: chat domain has zero *.spec.ts files at time of writing.
  • TODO: no dedicated server-side spec for handleChatMessage, handleTyping, or forwardRtcMessage.
  • TODO: confirm specs for PeerDeliveryService and OfflineMessageQueueService.
  • E2E: e2e/tests/chat/chat-message-features.spec.ts covers happy-path chat and attachment sync between users.

Performance considerations

  • Inventory batch cap: 200 messages per envelope.
  • Inventory backfill cap: 1000 messages per inventory exchange.
  • chat_message broadcast is O(N) over connected peers of the server; no fan-out batching.
  • DMs incur O(recipients) data-channel writes (or signaling forwards) per send; large group DMs amplify per-message cost linearly.

Security considerations

  • No end-to-end encryption. P2P traffic over the data channel is DTLS-encrypted by WebRTC; signaling-forwarded fallback is plain WebSocket; either way the local TypeORM database stores plaintext.
  • applyMutation does not verify authorship on incoming edit-message / delete-message events. TODO above.
  • No server-side rate limiting on chat_message. A non-cooperating client can flood a server's broadcast.

Known issues and limitations

  • No server-side chat history. Late joiners depend on peers having local history to replay via inventory sync.
  • No spec coverage for the chat domain.
  • DM authorship is not verified by applyMutation.
  • No DM typing indicator.
  • OfflineMessageQueueService retry policy is currently driven by peerConnected$ / networkRestored$ events only — there is no scheduled retry; a stuck queue requires one of those events to fire. TODO: confirm behavior across reconnects.

Changelog

Date Change
2026-05-25 Initial documentation