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>
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
localStorageon 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 viaadvanceDirectMessageStatus. - Peer-delivery service —
PeerDeliveryService(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_messageto 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_messageanddelete_messagefollow 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). PeerDeliveryServiceis the dispatcher. For each outgoing event it:- Tries every open data channel to a
recipients-listed peer. - If no data channel is available to a recipient, falls back to a signaling-server
forwardPeerMessageenvelope so the server forwards it to that peer's connection. - If neither path is open, enqueues the event in
OfflineMessageQueueServiceand replays onpeerConnected$/networkRestored$.
- Tries every open data channel to a
- 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 additive —
mergeIncomingMessage/upsertDirectMessageonly 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-messageevents carry the originalmessageId. On both transports, the receiver locates the existing row (by id) and applies the mutation viaapplyMutation.- 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 withdeletedAtso 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.
handleChatMessageonly broadcasts; there is no server-side message log. - The delivery state machine is monotonic —
advanceDirectMessageStatusnever 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 additive —
mergeIncomingMessage/upsertDirectMessagenever 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 viaforwardPeerMessage/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 atdomain/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);
DatabaseServicefor server-channel chat.
Electron
- DM persistence — TypeORM entity (likely
MessageEntityand a DM-specific row) + CQRS handlers. Backup / restore is part of the Electron data-management surface.
Testing
- TODO: chat domain has zero
*.spec.tsfiles at time of writing. - TODO: no dedicated server-side spec for
handleChatMessage,handleTyping, orforwardRtcMessage. - TODO: confirm specs for
PeerDeliveryServiceandOfflineMessageQueueService. - E2E:
e2e/tests/chat/chat-message-features.spec.tscovers 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_messagebroadcast 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.
applyMutationdoes not verify authorship on incomingedit-message/delete-messageevents. 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.
OfflineMessageQueueServiceretry policy is currently driven bypeerConnected$/networkRestored$events only — there is no scheduled retry; a stuck queue requires one of those events to fire. TODO: confirm behavior across reconnects.
Related features
- websocket-envelopes — owns the wire shape of every envelope here.
- attachments — file payloads ride alongside chat events on the data channel.
- voice-signaling — establishes the data channel DMs ride on.
- ipc-bridge — exposes the CQRS persistence DMs and server chat use.
- access-control — gates write permissions and slowmode.
Changelog
| Date | Change |
|---|---|
| 2026-05-25 | Initial documentation |