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>
196 lines
12 KiB
Markdown
196 lines
12 KiB
Markdown
# 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](./websocket-envelopes.md).
|
|
|
|
## 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](./attachments.md).
|
|
- RTC negotiation that brings the data channel up → [voice-signaling](./voice-signaling.md).
|
|
- Permission to send a message (`writeMessages`, `manageMessages`, channel overrides, slowmode hint) → [access-control](./access-control.md).
|
|
- The wire shape of every envelope used here → [websocket-envelopes](./websocket-envelopes.md).
|
|
|
|
## 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 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_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](./voice-signaling.md)).
|
|
- `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](./ipc-bridge.md). 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` / `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 **monotonic** — `advanceDirectMessageStatus` 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 **additive** — `mergeIncomingMessage` / `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](./ipc-bridge.md)); `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.
|
|
|
|
---
|
|
|
|
## Related features
|
|
|
|
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of every envelope here.
|
|
- **[attachments](./attachments.md)** — file payloads ride alongside chat events on the data channel.
|
|
- **[voice-signaling](./voice-signaling.md)** — establishes the data channel DMs ride on.
|
|
- **[ipc-bridge](./ipc-bridge.md)** — exposes the CQRS persistence DMs and server chat use.
|
|
- **[access-control](./access-control.md)** — gates write permissions and slowmode.
|
|
|
|
## Changelog
|
|
|
|
| Date | Change |
|
|
|------|--------|
|
|
| 2026-05-25 | Initial documentation |
|