The contract between client and server: REST for request/response, WebSocket/STOMP for real-time.
Contract rules (the spine): every send carries a client-generated clientMsgId; the
server is idempotent on (conversationId, clientMsgId) and returns the canonical message with
its server-assigned seq. Clients dedup on clientMsgId and order on seq — never
on wall-clock. Receipts and typing/presence are best-effort and never block the send path.
Legend: ✅ shipped · 🔜 next day · ⬜ later phase.
POST /v1/auth/register → 201 {accessToken, userId, username, displayName, deviceId} ✅
POST /v1/auth/login → 200 {accessToken, ...} ✅
GET /v1/users/me → 200 {id, username, displayName, status, createdAt} ✅
GET /v1/conversations → my conversations (participants) ✅
POST /v1/conversations → create direct/group ✅
GET /v1/conversations/{id} → one conversation (member-gated) ✅
POST /v1/conversations/{id}/members → add members (group) ✅
GET /v1/conversations/{id}/messages → history/sync, cursor-paginated (?before|after=seq&limit) ✅
GET /v1/users → other users (member picker) ✅
POST /v1/conversations/{id}/read → advance lastReadSeq (204) ✅
GET /v1/users/{id}/presence → {userId, online, lastSeenAt} ✅
POST /v1/media/presign → presigned upload URL (returns blobKey) ⬜ Day 10
Auth: all routes except /v1/auth/** require Authorization: Bearer <jwt>.
Errors: uniform ApiError envelope {timestamp, status, error, message, fieldErrors}.
400validation (withfieldErrors) ·401bad/missing credentials (generic, no user enumeration) ·403authenticated-but-forbidden ·404not found ·409conflict.
→ CONNECT (auth token in header → principal + deviceId)
→ SUBSCRIBE /user/queue/messages (my inbound message stream)
→ SUBSCRIBE /user/queue/receipts (delivery/read updates for my sent messages)
→ SUBSCRIBE /user/queue/presence (presence/typing for my conversations)
→ SEND /app/conversations/{id}/send { clientMsgId, type, body } ← idempotent
← MESSAGE /user/queue/messages { id, seq, senderId, clientMsgId, type, body, createdAt }
← client dedups on clientMsgId, inserts by seq
→ SEND /app/conversations/{id}/typing { state: start|stop } ← ephemeral, debounced ✅
← MESSAGE /user/queue/typing { conversationId, userId, typing } ✅
← MESSAGE /user/queue/presence { userId, online, lastSeenAt } ✅
← MESSAGE /user/queue/receipts { conversationId, userId, lastReadSeq } ← read cursor ✅
(read is advanced via POST /v1/conversations/{id}/read, not a STOMP frame)
→ SEND /app/calls/{id}/signal { sdp|ice } ← WebRTC signaling ⬜ Day 13
Ack semantics: the server acks a send only after durable persist + seq assignment,
returning the canonical message. The client renders optimistically (pending state) and reconciles
when the canonical message (with seq) arrives. (ADR-0004)
Reconnect/sync: on reconnect the client sends its last-known seq per conversation; the server
streams everything after it. (ARCHITECTURE §5) ⬜ Day 6.
| Rule | Server | Client |
|---|---|---|
| Idempotent send | unique (conversationId, clientMsgId); retry returns the same canonical message |
reuse the same clientMsgId on retry |
| Ordering | assigns monotonic seq per conversation atomically |
sort/insert by seq, render in seq order |
| Dedup | — | drop a message whose clientMsgId is already present |
| No wall-clock trust | createdAt is metadata only |
never order by createdAt |
4. Frontend stream surface (RxJS) — (ADR-0009)
The client models the socket as observable streams (SocketService); features subscribe:
connection$ : 'disconnected' | 'connecting' | 'connected' | 'reconnecting' (BehaviorSubject)
messages$ : InboundMessage (deduped on clientMsgId, scanned into seq-ordered state)
receipts$ : ReceiptUpdate
presence$ : PresenceUpdate
typing$ : TypingUpdate
Day-1 ships the stub (connection$, messages$) so features code against a stable surface; Day 3
fills in the real STOMP transport without changing it.