# Realtime event catalogue

This is the §8.1 specification for rooms, event names, payload shapes, and
protocol version negotiation. The executable contract is
`libs/contracts/src/events.ts`. This document is the human-readable form that
document required before Phase 2 opened.

## Protocol version

`PROTOCOL_VERSION` is `"1.0"`.

Clients send `auth.protocolVersion` on the Socket.IO handshake (`ConnectAuth`).
The gateway must reject a connection whose **major** version it does not
support. Within a major version, payloads may only change by adding optional
fields. Removing or renaming a field, or changing a required type, requires a
major bump.

Desktop and PWA clients run stale bundles. Versioning from day one is what
stops every future event change from being an accidental breaking change.

## Rooms

| Room | Who joins | What it carries |
|---|---|---|
| `ws:{workspaceId}` | Members after `workspace:join` succeeds | Presence, membership, workspace-wide fanout |
| `ch:{channelId}` | Members after `channel:join` succeeds | Messages, typing, pins, reactions, read-state for that channel |
| `u:{userId}` | Joined automatically at connect from the authenticated socket | Personal fanout: DMs opened elsewhere, notifications, call signals, drafts |

Tenant scope is fixed at connect from the access JWT (`sub`). Handlers never
re-read identity from client input.

## Client → server

| Event | Payload | Isolation gate |
|---|---|---|
| `workspace:join` | `{ workspaceId }` | `checkWorkspaceMembership` |
| `channel:join` | `{ workspaceId, channelId, cursorSeq? }` | `checkChannelMembership` |
| `channel:leave` | `{ channelId }` | Local room leave only (no tenant data) |
| `message:send` | `{ workspaceId, channelId, clientMsgId, text, blocks?, parentId?, isBroadcast? }` | `sendMessage` → `withTenant` + channel membership |
| `read:ack` | `{ workspaceId, channelId, seq }` | `markChannelRead` |
| `thread:ack` | `{ workspaceId, channelId, rootMessageId, seq }` | `markThreadRead` |
| `typing:start` | `{ workspaceId, channelId }` | `checkChannelMembership` (required; a missing workspace id is rejected) |
| `presence:heartbeat` | `{ active? }` | Only workspaces already joined on this socket |
| `call:signal` | `{ workspaceId, callId, toUserId, signal, data }` | Both ends must be `isCallParticipant`; `fromUserId` is stamped from the socket |

`clientMsgId` is a UUID. Idempotent send (I3) is `UNIQUE (channel_id, client_msg_id)`.

## Server → client

Payload schemas live as Zod objects next to the event names. Summary:

| Event | Payload | Notes |
|---|---|---|
| `message:created` | `{ message, workspaceId?, channelId? }` | Personal-room fanout includes workspace/channel so the client can route without joining `ch:` |
| `message:edited` | `{ message }` | Also used for thread-root summary updates after a reply |
| `message:deleted` | `{ channelId, messageId, seq }` | Content is scrubbed at every read path |
| `reaction:changed` | `{ reaction, op }` | `op`: add \| remove |
| `pin:changed` | `{ channelId, messageId, op }` | |
| `channel:updated` | `{ channel }` | Archive, rename, topic, member count |
| `read:updated` | `{ channelMember }` | |
| `typing:changed` | `{ channelId, userId, isTyping }` | Server-throttled 3s; client auto-clears |
| `presence:changed` | `{ workspaceId, userId, status }` | `active` \| `away` \| `offline`; only on 0↔1 device-count transitions |
| `member:status_changed` | `{ workspaceId, userId }` | Custom status / DND |
| `saved:updated` | `{ workspaceId, messageId, saved }` | |
| `draft:updated` | draft fields + `deleted` | |
| `notification-preferences:updated` | `{ preferences }` | |
| `activity:read-updated` | `{ workspaceId, messageIds, read }` | |
| `thread:subscription-updated` | `{ status }` | |
| `task:created` / `task:updated` | `{ task }` | Full current row |
| `task:deleted` | `{ workspaceId, taskId, title, createdBy, deletedByUserId }` | |
| `event:created` / `event:updated` | `{ event }` | Series row, not an expanded occurrence |
| `event:deleted` | `{ workspaceId, eventId }` | |
| `call:started` / `call:updated` / `call:ended` | `{ call }` | Full row |
| `call:signal` | `{ callId, fromUserId, signal, data }` | Point-to-point; `fromUserId` is never taken from the sender payload |

Broadcast of mutations is best-effort via Redis (`messaging:events`). Catch-up
(§5.3) repairs a dropped emit. Writes do not fail when Redis is down.

## HTTP vs socket

`message:send` on the socket and `POST .../messages` on HTTP call the same
`sendMessage` function and therefore publish the same Redis event. Clients
must not assume one path is more authoritative than the other.
