# Slack-Parity Collaboration Platform — Architecture & Delivery Plan

**Version** 3.0 · **Date** 2026-07-27 · **Owner** Yasir · **Status** Draft for approval

> **Changes from v2.0.** Backend returns to TypeScript (NestJS + Fastify + Drizzle) — v2.0's Elixir/Phoenix plan is withdrawn because the team cannot operate it, and an architecture nobody can debug in production has negative value. Socket.IO with the Redis adapter replaces Phoenix Channels; pg-boss replaces Oban but **preserves transactional job enqueue**, so the outbox pattern stays eliminated. Redis returns for presence, ephemeral signals, and socket fanout. Next.js returns as the client via static export — one build serves web and desktop. Desktop narrows to **Windows + macOS via Tauri v2, with Linux served by the web app as an installable PWA**; this removes the WebKitGTK media problem entirely and with it the need for any Rust. Two defects found in v2.0 are fixed: invariant **I5** wrongly counted thread replies toward channel unread, and per-thread read state was missing from the data model. `DECISIONS.md` records the supersession chain.
>
> **The stack now requires no Elixir and no Rust.** Every component is TypeScript, Postgres, or off-the-shelf infrastructure the team can run.

---

## 1. Scope & Governing Decisions

| Dimension | Decision |
|---|---|
| Product | Multi-tenant team-messaging platform at Slack feature parity |
| Backend | **TypeScript** — NestJS on Fastify, self-hosted. No BaaS. |
| Realtime | **Socket.IO** + Redis adapter |
| Web | **Next.js** (static export, client-rendered) — one build, also loaded by desktop |
| Desktop | **Tauri v2 — Windows + macOS** |
| Linux | **Web app, installable as a PWA** |
| Mobile | Deferred past GA |
| Tenancy | Multi-tenant SaaS, mid scale — 5k concurrent sockets/region, 500 workspaces |
| Calls / SMS | Architected now, built in Phase 6 |
| Optimisation target | **Correctness of the realtime layer**, subject to the team being able to operate every component |

### 1.1 The premise

This product is **80% a realtime state-synchronisation problem and 20% CRUD.** Most Slack clones are built as 80% CRUD with realtime bolted on, which is exactly why they exhibit unread-count drift, out-of-order rendering, duplicated sends, and broken reconnects. Every decision below follows from taking the 80% seriously.

The second premise, learned the hard way across v1→v3: **operability is a correctness property.** A design the team cannot run, profile, or debug under load will fail in production regardless of its theoretical merit. Where those two premises conflict, operability wins.

### 1.2 Non-negotiable principles

1. **The server owns order.** Every message carries a server-assigned per-channel monotonic `seq`. No client ever sorts by wall-clock time.
2. **Idempotency at every write.** Every client mutation carries a client-generated UUID. Retries and offline replays are safe by construction.
3. **The sync engine is a named subsystem** with an explicit invariant set and its own test suite (§5, §6) — not an emergent property of component choices.
4. **Tenant isolation lives in the database**, enforced by row-level security.
5. **One shared language.** Sync logic runs on both sides of the wire; it is written once in TypeScript and shared.
6. **One product build.** Web, desktop, and the Linux PWA are the same bundle behind thin shells.
7. **Every component must be operable by this team.** No exceptions for elegance.

---

## 2. Feature Inventory

Legend — **M** = MVP (Phases 1–3), **1** = v1.0 (Phases 4–5), **2** = post-v1 (Phases 6–7).

### 2.1 Identity & Access

| # | Feature | Tier |
|---|---|---|
| 1.1 | Email + password auth, verification, password reset | M |
| 1.2 | Magic-link / email OTP sign-in | M |
| 1.3 | Session management, refresh-token rotation, device list, remote sign-out | M |
| 1.4 | OAuth social login (Google, Microsoft, Apple) | 1 |
| 1.5 | SAML 2.0 / OIDC enterprise SSO (via Keycloak or Ory) | 2 |
| 1.6 | SCIM 2.0 provisioning & deprovisioning | 2 |
| 1.7 | MFA (TOTP + recovery codes) | 1 |
| 1.8 | Roles: Owner, Admin, Member, Multi-channel Guest, Single-channel Guest, Bot | M |
| 1.9 | Granular per-workspace permission policies | 1 |

### 2.2 Workspaces & Organisations

| # | Feature | Tier |
|---|---|---|
| 2.1 | Create workspace — slug/subdomain, icon, description | M |
| 2.2 | Invitations: email invite, share link with expiry and usage cap | M |
| 2.3 | Domain-based auto-join | 1 |
| 2.4 | Join requests with admin approval queue | 1 |
| 2.5 | Member directory, profiles, custom profile fields | M |
| 2.6 | Member management: deactivate, reactivate, change role, transfer ownership | M |
| 2.7 | Multi-workspace membership + fast switcher | M |
| 2.8 | Workspace settings: retention, who-can-invite, who-can-create-channels | 1 |
| 2.9 | Enterprise Grid equivalent | 2 |
| 2.10 | Compliance export of all messages and files | 2 |
| 2.11 | Workspace deletion with grace period | 1 |

### 2.3 Channels & Conversations

| # | Feature | Tier |
|---|---|---|
| 3.1 | Public channels — create, join, leave, browse directory | M |
| 3.2 | Private channels — invite-only | M |
| 3.3 | Direct messages (1:1) | M |
| 3.4 | Group DMs (up to 9 participants) | M |
| 3.5 | Rename, topic, purpose, description | M |
| 3.6 | Archive / unarchive | M |
| 3.7 | Convert public ↔ private | 1 |
| 3.8 | Sidebar sections, custom ordering | 1 |
| 3.9 | Star, mute, hide conversation | M |
| 3.10 | Channel-level notification override | 1 |
| 3.11 | Canvases — collaborative channel documents | 2 |
| 3.12 | Cross-org shared channels | 2 |
| 3.13 | Default channels for new members, posting restrictions | 1 |

### 2.4 Messaging

| # | Feature | Tier |
|---|---|---|
| 4.1 | Send / receive text in realtime | M |
| 4.2 | Rich composer: bold, italic, strike, code, code block, quote, links, lists | M |
| 4.3 | Threaded replies, thread following, "also send to channel" | M |
| 4.4 | Emoji reactions with counts and reactor lists | M |
| 4.5 | Custom emoji upload and aliases | 1 |
| 4.6 | Edit (with indicator) and delete | M |
| 4.7 | Mentions: `@user`, `@here`, `@channel`, `@everyone`, `@usergroup` | M |
| 4.8 | User groups with membership management | 1 |
| 4.9 | Typing indicators, presence, custom status + emoji | M |
| 4.10 | Read state: channel unread, **per-thread unread**, mention badges, new-message divider | M |
| 4.11 | Mark unread, mark all read, jump to last read | 1 |
| 4.12 | Message permalinks and deep links | M |
| 4.13 | Pinned messages | M |
| 4.14 | Saved items / bookmarks | 1 |
| 4.15 | Forward / share to another conversation | 1 |
| 4.16 | Scheduled send | 1 |
| 4.17 | Drafts, synced across devices | 1 |
| 4.18 | Link unfurling (OpenGraph, oEmbed) | 1 |
| 4.19 | Code snippets and long-form posts | 1 |
| 4.20 | Reminders (`/remind`), snoozed messages | 1 |
| 4.21 | Retention and auto-delete policies | 1 |
| 4.22 | Emoji picker — search, skin tone, frecency | M |
| 4.23 | Optimistic send with pending/failed/retry states, offline outbox | M |
| 4.24 | Workflow Builder equivalent | 2 |
| 4.25 | Translation, AI recaps | 2 |

### 2.5 Files & Media

| # | Feature | Tier |
|---|---|---|
| 5.1 | Upload via drag-drop, paste, picker — presigned direct-to-S3 | M |
| 5.2 | Image/video thumbnails and inline previews | M |
| 5.3 | PDF and document preview | 1 |
| 5.4 | File browser per channel and workspace, with filters | 1 |
| 5.5 | Voice clips | 2 |
| 5.6 | Video clips (screen + camera capture) | 2 |
| 5.7 | Virus scanning, type allowlist, size limits | 1 |
| 5.8 | Per-workspace storage quota and usage reporting | 1 |
| 5.9 | External file links (Drive, Dropbox) | 2 |

### 2.6 Search & Navigation

| # | Feature | Tier |
|---|---|---|
| 6.1 | Message search with `from:` `in:` `has:` `before:` `after:` `is:` modifiers | M |
| 6.2 | File search | 1 |
| 6.3 | People and channel search | M |
| 6.4 | Command palette / quick switcher | M |
| 6.5 | Full keyboard navigation and shortcut map | 1 |
| 6.6 | Activity feed — all mentions and reactions | 1 |
| 6.7 | Later / to-do list | 2 |

### 2.7 Notifications

| # | Feature | Tier |
|---|---|---|
| 7.1 | In-app notifications and badge counts | M |
| 7.2 | Desktop OS notifications with click-to-deep-link (Tauri) | M |
| 7.3 | Web push (Service Worker + VAPID) — **also serves Linux PWA** | M |
| 7.4 | Email notifications and missed-activity digests | 1 |
| 7.5 | **SMS notifications** (Twilio — opt-in, critical alerts, verification codes) | 1 |
| 7.6 | Preferences: global, per-workspace, per-channel, keyword highlights | 1 |
| 7.7 | DND schedule, snooze, timezone awareness | 1 |
| 7.8 | Mobile push (APNs / FCM) | 2 |
| 7.9 | Cross-device deduplication | 1 |

### 2.8 Voice, Video & Telephony *(Phase 6)*

| # | Feature | Tier |
|---|---|---|
| 8.1 | Connect — lightweight persistent audio rooms per channel | 2 |
| 8.2 | 1:1 and group video calls | 2 |
| 8.3 | Screen sharing, multi-screen | 2 |
| 8.4 | Call controls: mute, camera, device picker, background blur | 2 |
| 8.5 | In-call chat and reactions | 2 |
| 8.6 | Recording, transcription, summary | 2 |
| 8.7 | Ring, decline, missed-call message in channel | 2 |
| 8.8 | PSTN dial-in and outbound calling | 2 |
| 8.9 | Call-quality telemetry and stats overlay | 2 |

### 2.9 Platform & Extensibility

| # | Feature | Tier |
|---|---|---|
| 9.1 | Bot users and bot tokens | 1 |
| 9.2 | Incoming webhooks | 1 |
| 9.3 | Events API — signed outbound payloads with retry and DLQ | 1 |
| 9.4 | Slash commands — built-in plus per-workspace custom registry | 1 |
| 9.5 | Public REST API with OAuth 2.0 install flow and scopes | 2 |
| 9.6 | Interactive components — buttons, selects, modals | 2 |
| 9.7 | App directory | 2 |
| 9.8 | Rate limiting per token and per tier | 1 |

### 2.10 Admin, Compliance & Operations

| # | Feature | Tier |
|---|---|---|
| 10.1 | Admin console — members, channels, invites, settings | M |
| 10.2 | Audit log | 1 |
| 10.3 | Analytics — DAU, messages sent, channel activity | 1 |
| 10.4 | Retention policy and legal hold | 2 |
| 10.5 | eDiscovery / DLP export API | 2 |
| 10.6 | GDPR — data export, erasure, DPA surface | 1 |
| 10.7 | Billing, plans, seat counting (Stripe) | 1 |
| 10.8 | Status page, incident banner, maintenance mode | 1 |

### 2.11 Desktop (Tauri v2 — Windows + macOS)

| # | Feature | Tier |
|---|---|---|
| 11.1 | Native window chrome, custom titlebar, window-state persistence | M |
| 11.2 | System tray with unread badge; taskbar/dock badge | M |
| 11.3 | Global shortcuts (quick switcher, push-to-talk, mute) | 1 |
| 11.4 | Deep links (`slacknew://`) routed to channel/message | M |
| 11.5 | Signed auto-update | M |
| 11.6 | Single-instance enforcement, second-launch focus | M |
| 11.7 | Launch on login, minimise/close to tray | 1 |
| 11.8 | Multi-window / per-workspace windows | 1 |
| 11.9 | Token storage in OS keychain | M |
| 11.10 | Local SQLite cache — read-only operation when offline | 1 |
| 11.11 | Native file drag-out, reveal in folder | 2 |
| 11.12 | Native spellcheck and emoji IME | 1 |

### 2.12 Web & PWA (all platforms, primary path on Linux)

| # | Feature | Tier |
|---|---|---|
| 12.1 | Installable PWA — manifest, icons, standalone window | M |
| 12.2 | Service Worker: app-shell caching, offline read-only | 1 |
| 12.3 | **Badging API** for unread counts on dock/taskbar | M |
| 12.4 | Web push notifications (VAPID) | M |
| 12.5 | IndexedDB local cache and outbox | M |
| 12.6 | Web share target, file handling | 2 |
| 12.7 | Keyboard shortcut parity with desktop | 1 |

---

## 3. Technology Stack

| Layer | Choice | Rationale |
|---|---|---|
| Language | **TypeScript** everywhere | Sync logic runs on both sides of the wire; write it once |
| API framework | **NestJS** on **Fastify** | Module boundaries, DI, guards; integrates the socket layer natively |
| Realtime | **Socket.IO** + `@socket.io/redis-adapter` | Rooms map to channels; reconnection and cross-node fanout provided |
| Database | **PostgreSQL** (latest stable) | RLS, JSONB, FTS — all used |
| ORM | **Drizzle** | Explicit SQL; permits `SET LOCAL` for RLS, which Prisma obstructs |
| Jobs | **pg-boss** (Postgres-backed) | **Enqueue is transactional with the write** — keeps the outbox eliminated |
| Cache / ephemeral | **Redis** | Socket.IO adapter, presence, typing, rate limits, WS tickets |
| Search | **Typesense** | Typing-latency filtered full-text; low ops burden |
| Object storage | **Amazon S3** | Presigned direct upload (MinIO only as a local stand-in) |
| Contracts | **Zod** + generated OpenAPI | Single source of truth, server and client |
| Client | **Next.js** (`output: 'export'`), React, Tailwind | One build for web, PWA, and desktop |
| State | **Zustand** + custom sync engine | Entity-normalised, not request-cached (§5) |
| Server data (non-realtime) | **TanStack Query** | Admin, settings, search only — never message data |
| Editor | **ProseMirror + Yjs** | One foundation for composer and Phase-7 canvases (see OD-2) |
| Desktop | **Tauri v2** (Windows, macOS) | Small binaries; no Rust required for this scope |
| Linux | **PWA** | Full functionality including calls via Chromium WebRTC |
| Marketing / docs | **Next.js** (separate app, SSR/SSG) | Where SSR is actually useful |
| Calls (P6) | **LiveKit** self-hosted + LiveKit JS SDK | Browser and webview WebRTC; no native sidecar |
| SMS / PSTN | **Twilio** | — |
| Email | **Postmark / SES** + React Email | — |
| Auth | In-house (Argon2id, rotating refresh tokens); **Keycloak/Ory** for SSO in Phase 7 | Self-hosted need not mean built from scratch |
| Observability | OpenTelemetry → Grafana, Sentry | — |
| Infra | Docker Compose → managed VMs, Terraform | Kubernetes unnecessary at this scale |
| CI/CD | GitHub Actions; Tauri matrix on Windows + macOS runners | — |

### 3.1 What Elixir gave, and how much is recovered

Honest accounting, since v2.0 promised these and v3.0 must replace them.

| v2.0 property | v3.0 status |
|---|---|
| Transactional job enqueue (Oban) | **Fully recovered** via pg-boss on Postgres. Outbox still unnecessary. |
| Cluster-wide pub/sub (Phoenix.PubSub) | **Recovered** via Socket.IO Redis adapter. Redis is an added dependency, but a well-understood one. |
| Distributed CRDT presence (Phoenix.Presence) | **Not recovered.** Presence is hand-built on Redis (§4.4). This is the single largest piece of net-new work. |
| Process-per-socket isolation | **Not recovered.** Node is single-threaded per process; a bad handler can stall the event loop. Mitigated by keeping socket handlers trivial (§4.3) and running the gateway as its own process. |
| Shared server/client sync logic | **Newly gained.** Elixir could not do this. Zod contracts and one sync implementation are a real advantage v2.0 lacked. |

Net: one significant loss (presence), one significant gain (shared contracts), and a stack the team can actually run.

---

## 4. System Architecture

### 4.1 Topology

```
   Next.js static bundle
     ├── app.domain.com          (web + installable PWA — all platforms, primary on Linux)
     └── Tauri v2 shell          (Windows, macOS — same bundle)
   Next.js marketing app (SSR)   (separate deployment)
             │
             │  HTTPS (JSON)              WSS (Socket.IO, websocket transport only)
             ▼                                   ▼
   ┌──────────────────────────────────────────────────────────┐
   │           L7 load balancer / TLS termination              │
   │   (no sticky sessions needed — polling transport off)     │
   └──────────────────────────────────────────────────────────┘
             │                                   │
             ▼                                   ▼
   ┌────────────────────┐             ┌──────────────────────────┐
   │  api (NestJS)      │             │  gateway (NestJS)        │
   │  N nodes, stateless│             │  M nodes, holds sockets  │
   │  HTTP only         │             │  Socket.IO server        │
   └────────────────────┘             └──────────────────────────┘
             │                                   │
             └──────────────┬────────────────────┘
                            ▼
   ┌──────────────────────────────────────────────────────────┐
   │  Redis  — Socket.IO adapter · presence · typing ·         │
   │           rate limits · WS tickets                        │
   ├──────────────────────────────────────────────────────────┤
   │  PostgreSQL primary + read replicas                       │
   │    (also hosts the pg-boss job queue)                     │
   ├──────────────────────────────────────────────────────────┤
   │  S3 / MinIO          ·          Typesense                 │
   └──────────────────────────────────────────────────────────┘
                            │
                            ▼
   ┌──────────────────────────────────────────────────────────┐
   │  worker (NestJS) — pg-boss queues:                        │
   │  notifications · email · sms · unfurl · search_index ·     │
   │  media · webhooks · retention · exports · analytics       │
   └──────────────────────────────────────────────────────────┘
                            │
                            ▼
   ┌──────────────────────────────────────────────────────────┐
   │  LiveKit SFU cluster (Phase 6)                            │
   └──────────────────────────────────────────────────────────┘
```

**One monorepo, three deployable NestJS apps** (`api`, `gateway`, `worker`) sharing `core` and `data` libraries. Run all three in one process in development; deploy separately in production. The gateway is a separate deployment from day one — retrofitting that split later is the expensive mistake in this product class.

**No sticky sessions.** Socket.IO is configured `transports: ['websocket']`, disabling the HTTP long-polling fallback. This removes the sticky-session requirement at the load balancer, which is a common and painful source of production confusion. The cost is that clients on networks that block WebSockets cannot connect — acceptable, and detectable.

### 4.2 Repo layout

```
apps/
  api/            NestJS HTTP — auth, workspaces, channels, messages, admin, search
  gateway/        NestJS + Socket.IO — sockets, presence, typing, fanout
  worker/         NestJS + pg-boss consumers
  web/            Next.js (output: 'export') — THE product. Web, PWA, and desktop bundle.
  desktop/        Tauri v2 shell (Windows, macOS)
  marketing/      Next.js SSR — public site
libs/
  contracts/      Zod schemas → OpenAPI + TS types. Single source of truth.
  core/           Domain logic, policy engine, mention parsing, blocks serialisation
  data/           Drizzle schema, migrations, queries, RLS helpers
  sync/           The sync engine (§5) — stores, outbox, cache, gap detection
  realtime/       Socket.IO client wrapper, cursors, reconnect
  ui/             Design system and feature components
  editor/         ProseMirror + Yjs schema, blocks serialisation
```

`libs/core` and `libs/contracts` are imported by both server and client. Mention parsing, `blocks` serialisation, and cursor arithmetic exist exactly once — this is the concrete payoff of one language, and the reason TypeScript beat Laravel on merit.

### 4.3 Realtime design

**Connection lifecycle**

1. Client authenticates over HTTPS and receives a single-use 60-second WS ticket (stored in Redis).
2. Client connects: `io('wss://rt.domain.com', { transports: ['websocket'], auth: { ticket } })`.
3. Gateway middleware validates and burns the ticket, loads workspace membership, and attaches `userId` / `workspaceId` to the socket. **Tenant scope is fixed here and never read from client input again.**
4. Client joins rooms: `ws:{workspaceId}`, `ch:{channelId}` per open conversation, `u:{userId}` for personal events.
5. Presence registration (§4.4) and heartbeat begin.

**Socket handlers stay trivial.** Node is single-threaded per process; any CPU-bound or blocking work inside a handler stalls every socket on that node. Handlers therefore do three things only: validate, write to Redis, or enqueue. All real work happens in `api` or `worker`.

**Write path**

```ts
await db.transaction(async (tx) => {
  const seq = await tx.nextChannelSeq(channelId);        // UPDATE ... RETURNING
  const msg = await tx.insertMessage({ ...input, seq }); // UNIQUE(channel_id, client_msg_id)
  await tx.insertMentions(msg);
  await tx.touchChannel(channelId);
  await boss.send('notifications', { messageId: msg.id }, { db: tx }); // same transaction
  return msg;
});
// after commit — best effort, deliberately not transactional
io.to(`ch:${channelId}`).emit('message:created', payload);
```

pg-boss enqueues inside the transaction, so a committed message can never fail to produce its notification job, and a rolled-back message can never produce one. **The transactional outbox pattern remains unnecessary.** The post-commit broadcast is best-effort because gap detection (§5.3) repairs any dropped emit.

**Fanout.** `io.to('ch:123').emit(...)` is propagated cross-node by the Redis adapter. No application-level fanout code.

**Ephemeral signals.** Typing, presence, and cursors are Redis-only and never persisted. Typing is throttled server-side to one event per user per channel per 3 seconds.

### 4.4 Presence — the one piece Elixir gave for free

Hand-built, and specified here because it is the largest net-new work item relative to v2.0.

```
Redis keys
  presence:{workspaceId}          HASH   userId → JSON {status, lastSeen, deviceCount}
  presence:sock:{socketId}        STRING userId, TTL 60s   (liveness token)
  presence:user:{userId}          SET    socketIds
```

- On connect: add `socketId` to the user's set, increment `deviceCount`, set status `active`, publish `presence:changed` to `ws:{workspaceId}`.
- Heartbeat every 25 s refreshes `presence:sock:{socketId}` TTL.
- On disconnect: remove the socket, decrement. **Only publish `offline` when `deviceCount` reaches zero** — otherwise closing one tab marks a user offline while they are still active elsewhere. This is the defect this design exists to prevent.
- A sweeper job every 30 s reconciles sets against surviving TTL tokens, covering ungraceful gateway death.
- `away` is client-reported on idle; `status_text`/`status_emoji` persist in Postgres, not Redis.

Presence is deliberately **best-effort and self-healing** rather than strongly consistent. It is repaired by the sweeper and by client resubscription, and it is never used for authorisation.

### 4.5 Multi-tenancy

- Shared schema. Every tenant-scoped table carries `workspace_id` as the leading column of every composite index.
- **Postgres RLS** with `SET LOCAL app.current_workspace_id` and `app.current_user_id` per transaction, issued by a Drizzle wrapper. Application bugs cannot leak across tenants; RLS fails closed where a forgotten `WHERE` clause fails open.
- Because `SET LOCAL` is transaction-scoped, **every tenant-scoped request runs inside a transaction.** This is a deliberate constraint and the main reason Drizzle was chosen over Prisma.
- Authorisation is centralised in one module — `core/policy.can(actor, action, resource)`. Controllers and socket handlers call it; they never re-implement checks.

### 4.6 Calls architecture (Phase 6)

| | Web / Linux PWA | Desktop (Windows, macOS) |
|---|---|---|
| Media | Browser WebRTC, LiveKit JS SDK | Webview WebRTC (WebView2 / WKWebView), same SDK |
| Signalling | Socket.IO (room tokens, ring, decline) | Same |
| Capture | `getUserMedia` / `getDisplayMedia` | Same |

No native sidecar and no Rust. This is viable because Linux desktop is served by the browser rather than by a WebKitGTK binary — Chrome and Firefox on Linux have full Chromium/Gecko WebRTC, so Linux users get complete call functionality including screen sharing. WebView2 on Windows is Chromium-based. That leaves **WKWebView on macOS as the only uncertain surface** — see OD-3; it is spiked in Phase 1, not Phase 6.

Server-side LiveKit integration is JWT room-token minting plus webhook ingestion.

### 4.7 Data model

Primary keys integer (identity). All timestamps `timestamptz` UTC.

```sql
organizations      (id, name, plan, created_at)
workspaces         (id, org_id, slug, name, icon_url, settings jsonb, retention_days)
users              (id, email, email_verified_at, password_hash, name, avatar_url,
                    tz, mfa_secret, created_at)
workspace_members  (workspace_id, user_id, role, display_name, title, status_text,
                    status_emoji, status_expires_at, joined_at, deactivated_at)
                   PRIMARY KEY (workspace_id, user_id)
user_groups        (id, workspace_id, handle, name)
user_group_members (group_id, user_id)

channels           (id, workspace_id, type, name, topic, purpose, created_by,
                    is_archived, last_message_at, member_count)
                   -- type: public | private | dm | group_dm
channel_members    (channel_id, user_id, role, joined_at,
                    last_read_seq bigint, last_read_at, mention_count int,
                    notif_pref, is_muted, is_starred, section_id)
channel_seq        (channel_id, last_seq bigint)     -- per-channel counter

messages           (id, workspace_id, channel_id, seq bigint, client_msg_id uuid,
                    author_id, type, text, blocks jsonb, revision int,
                    parent_id, is_broadcast boolean NOT NULL DEFAULT false,
                    thread_reply_count, thread_last_reply_at,
                    edited_at, deleted_at, created_at)
                   UNIQUE (channel_id, seq)
                   UNIQUE (channel_id, client_msg_id)
                   -- parent_id IS NULL        → top-level channel message
                   -- parent_id IS NOT NULL    → thread reply
                   -- is_broadcast = true      → thread reply ALSO shown in channel

-- NEW in v3.0 — per-thread read state. Absent in v2.0; see DECISIONS ADR-013.
thread_subscriptions (user_id, root_message_id, channel_id, workspace_id,
                      last_read_reply_seq bigint, reason, is_muted, subscribed_at)
                     PRIMARY KEY (user_id, root_message_id)
                     -- reason: authored | replied | mentioned | manual

reactions          (message_id, user_id, emoji)  PRIMARY KEY (message_id, user_id, emoji)
message_mentions   (message_id, target_type, target_id)   -- user|group|channel|here
pins               (channel_id, message_id, pinned_by, pinned_at)
saved_items        (user_id, message_id, saved_at, completed_at)
drafts             (user_id, channel_id, parent_id, body jsonb, updated_at)
scheduled_messages (id, workspace_id, channel_id, author_id, body jsonb, send_at, status)

files              (id, workspace_id, uploader_id, name, mime, size, storage_key,
                    checksum, scan_status, thumb_key, created_at)
message_files      (message_id, file_id)
unfurls            (message_id, url_hash, payload jsonb, fetched_at)

notification_prefs (user_id, workspace_id, scope, channel_id, settings jsonb)
devices            (id, user_id, platform, push_token, app_version, last_seen_at)
phone_numbers      (user_id, e164, verified_at, sms_opt_in)

invites            (id, workspace_id, email, token_hash, role, expires_at,
                    used_at, max_uses, use_count)
apps               (id, workspace_id, name, kind, scopes, secret_hash)
slash_commands     (id, workspace_id, app_id, command, url, description)
webhook_deliveries (id, app_id, event_id, status, attempts, next_retry_at)
audit_log          (id, workspace_id, actor_id, action, target_type, target_id,
                    ip, user_agent, meta jsonb, inserted_at)
```

**Thread replies share the channel `seq` space.** A reply is a `messages` row in the same channel and consumes a channel sequence number, which preserves one total order per channel. It is simply excluded from the channel view unless `is_broadcast`. `thread_subscriptions.last_read_reply_seq` therefore holds a channel `seq` value — one cursor space, two views.

**No partitioning at MVP.** v1.0 specified `PARTITION BY RANGE (created_at)` alongside `UNIQUE (channel_id, seq)`, which Postgres rejects — unique constraints on a partitioned table must include every partition key column. Correct indexing carries this workload to tens of millions of rows. If partitioning later becomes necessary, `PARTITION BY HASH (channel_id)` keeps both constraints legal. Do not reach for Cassandra or ScyllaDB.

**Load-bearing indexes**

```sql
messages             (channel_id, seq DESC) WHERE parent_id IS NULL   -- channel scrollback
messages             (channel_id, parent_id, seq)                     -- thread view
messages             (workspace_id, author_id, created_at DESC)        -- from: filter
channel_members      (user_id, workspace_id)                           -- sidebar hydration
channel_members      (channel_id) WHERE NOT is_muted                   -- notification fanout
thread_subscriptions (user_id, workspace_id) WHERE NOT is_muted        -- Threads view
messages             USING GIN (to_tsvector('simple', text))           -- fallback, eDiscovery
```

### 4.8 Search permission filtering

Specified because an unspecified version is a data-leak class of bug.

Typesense documents carry `workspace_id` and `channel_id`. Queries **filter at query time** against the requesting user's live channel membership:

```
filter_by: workspace_id:=<ws> && channel_id:[<user's channel ids>]
```

ACLs are deliberately **not** denormalised into the index. Denormalised ACLs go stale on every membership change, and a stale ACL in a search index means a user who left a private channel keeps finding its messages. Query-time filtering cannot go stale.

Membership is cached in Redis per user with short TTL and invalidated on join/leave. For users in very many channels the filter clause grows large; the mitigation is to filter by workspace and post-filter the top-N result page against membership, accepting a slightly shorter effective result set. Threshold to be measured in Phase 2.

### 4.9 Client architecture

**One Next.js build, three delivery targets.** `apps/web` is configured `output: 'export'` and is fully client-rendered. The identical bundle is served at `app.domain.com` (web and installable PWA) and embedded in the Tauri shell.

What static export costs, accepted knowingly: no SSR, no API routes, no middleware, no server actions, no Next image optimisation. None of these serve an authenticated websocket-fed application. Routing, layouts, and code splitting — the parts actually used — all work. SSR-worthy pages live in `apps/marketing`.

**Platform adapter** — one interface, three implementations:

```ts
interface PlatformAdapter {
  secureStorage: SecureStore  // IndexedDB     | IndexedDB      | OS keychain (Stronghold)
  notifications: Notifier     // Notification  | Web Push       | tauri-plugin-notification
  badge:         BadgeSetter  // favicon/title | Badging API    | tray + dock badge
  deepLink:      DeepLinks    // History API   | History API    | tauri-plugin-deep-link
  cache:         LocalCache   // IndexedDB     | IndexedDB      | SQLite (tauri-plugin-sql)
  updater?:      Updater      // n/a           | n/a            | tauri-plugin-updater
}                             //   web             PWA              Tauri
```

**PWA specifics.** Manifest with `display: standalone`, maskable icons, a Service Worker caching the app shell (never API responses containing message data — the sync engine owns that), Badging API for unread counts, and Web Push via VAPID. On Linux this yields a standalone window, a dock icon with a live unread badge, and native-looking notifications — the meaningful gaps versus Tauri are system tray, global shortcuts, and launch-on-login.

**Tauri v2 notes.** Capability/ACL permissions are declarative per window and stricter than v1 — budget real time. Plugins: `single-instance`, `window-state`, `deep-link`, `notification`, `updater`, `global-shortcut`, `sql`, `stronghold`, `log`. All are configured from JavaScript; **no Rust is required for this feature scope.** Apple Developer and Authenticode certificates are procurement lead-time items — start immediately.

---

## 5. The Sync Engine

The most important subsystem, and the one most clones get wrong by never naming it.

### 5.1 Why hand-rolled

Slack's sync shape is narrow — an append-mostly log per channel, two read cursors, and a small mutable set of reactions and edits. Generic engines (Replicache, Zero, ElectricSQL) charge complexity for generality that goes unused, and full local replication of message history is the wrong model; a bounded window is correct. What is built here is smaller than the cost of integrating and working around a general engine.

Naming it matters as much as building it. When sync is emergent rather than specified, nobody owns its correctness and defects arrive as unreproducible reports about wrong badges.

### 5.2 Invariants

The contract. Every one is machine-checked (§6).

| ID | Invariant |
|---|---|
| **I1** | *Total order.* All caught-up clients render a channel in identical order, determined solely by `seq`. |
| **I2** | *Durability.* Once a send is acknowledged, the message is retrievable by every member with read access until explicitly deleted. |
| **I3** | *Idempotency.* A given `(channel_id, client_msg_id)` yields at most one message, regardless of retries or offline replay. |
| **I4** | *Cursor monotonicity.* `last_read_seq` and `last_read_reply_seq` never decrease except by explicit user action. |
| **I5** | *Channel unread convergence — corrected in v3.0.* At quiescence, displayed channel unread equals `count(seq > last_read_seq AND author_id ≠ self AND deleted_at IS NULL AND (parent_id IS NULL OR is_broadcast))`. Thread replies do **not** contribute to channel unread. |
| **I5b** | *Thread unread convergence — new in v3.0.* For each subscribed thread, displayed unread replies equals `count(parent_id = root AND seq > last_read_reply_seq AND author_id ≠ self AND deleted_at IS NULL)`. |
| **I6** | *Mention fidelity.* Mention badges derive from persisted `message_mentions`, never from client-side text parsing. |
| **I7** | *Catch-up completeness.* After reconnecting at cursor `S`, the client renders every message with `seq > S` exactly once, in order, with no live message rendered ahead of a backfilled one. |
| **I8** | *Mutation convergence.* Edits, deletes, and reactions converge under arbitrary delivery order, resolved by `revision`. |
| **I9** | *Presence liveness.* A user with at least one live socket is never shown offline; a user with zero live sockets is shown offline within 60 s. |

> **On I5.** v2.0 defined channel unread over all messages with `seq > last_read_seq`, silently including thread replies. Since replies consume channel `seq` but are not rendered in the channel, that produces a badge that can never be cleared by reading the channel. This is precisely the unread-drift failure the verification strategy exists to prevent, and it was found by re-reading the spec rather than by testing — which is the argument for having a spec.

### 5.3 Mechanisms

**Gap detection.** The client tracks `expectedSeq` per channel. Receiving `seq > expectedSeq` is a gap: the client does **not** render, it requests `GET /channels/:id/messages?afterSeq=&beforeSeq=`, then merges. Silently rendering a gap makes order corruption permanent.

**The catch-up/live boundary (I7).** On join the client sends its cursor and enters `catchingUp`. Live events arriving in that window are **buffered client-side, not rendered.** When backfill completes the buffer is merged by `seq`, deduplicated, and flushed; only then does the channel enter `live`. Buffering client-side keeps gateway nodes stateless with respect to catch-up and survives a gateway crash mid-backfill.

Socket.IO's built-in connection-state recovery replays a short packet buffer. **It is not sufficient** and is not relied upon — treat Socket.IO as transport plus rooms only.

**Outbox.** Pending mutations persist to IndexedDB (web, PWA) or SQLite (desktop) with a client-generated UUID *before* any network attempt. On reconnect they replay in order; `UNIQUE (channel_id, client_msg_id)` makes replay safe. Failed sends surface as retryable UI state and never silently vanish.

**Read state.** Client acks `{channelId, seq}` or `{rootMessageId, seq}`; the server persists debounced at 500 ms. Server-authoritative. On every reconnect the client **discards its local unread computation and rehydrates from the server.** This is the specific defence against unread drift.

**Local cache.** Bounded window per channel (most recent N, plus any open thread), LRU evicted. Channel switch renders from cache immediately, then reconciles.

---

## 6. Verification Strategy

This section, not the stack, is what determines whether the product feels correct. It survived the move from Elixir to TypeScript intact.

**Property-based testing** — `fast-check`. Generators produce arbitrary interleavings of sends, edits, deletes, reactions, thread replies, broadcasts, joins, disconnects, and acks. Assertions are I1–I9 directly. Shrinking yields minimal counterexamples.

**Seeded fault-injection harness.** A simulation layer over the transport and clock seams, injecting partitions, reordering, duplicate delivery, delayed acks, dropped broadcasts, and mid-transaction crashes — every run replayable from a seed. Deterministic simulation in the FoundationDB/TigerBeetle tradition, scoped to the seams the sync protocol traverses. In Node this means injecting fake timers and a scriptable transport rather than the real Socket.IO client.

**Formal model.** A TLA+ (or Alloy) specification of the read-state and catch-up protocol only — including both cursors, since I5/I5b interact. Deliberately narrow: unread correctness is the invariant clones most reliably violate, and it is small enough to model exhaustively. Separate artefact.

**Integration tests** against real Postgres, Redis, and Typesense via Testcontainers. No mocked data layer — RLS behaviour cannot be mocked.

**Cross-tenant isolation suite.** Every endpoint and every socket event attempted with a foreign `workspace_id`. Release-blocking.

**End-to-end** — Playwright against the web build, the installed PWA, and the packaged Tauri build via WebDriver.

**Load.** Gateway soak at 3× target concurrency with mass-reconnect storms — drop 100% of sockets and measure catch-up correctness, not merely throughput.

### 6.1 Performance budgets — enforced in CI

| Metric | Budget |
|---|---|
| Send → visible on second client (p95, in-region) | < 250 ms |
| Channel switch, warm cache | < 100 ms |
| Scrollback page of 50 (p95 server time) | < 120 ms |
| Cold app load to interactive (p95) | < 2.5 s |
| Sockets per gateway node (2 vCPU / 4 GB, Node + Socket.IO) | ≥ 8 000 |
| Event-loop lag under full load (p99) | < 50 ms |
| Reconnect storm: 5 000 clients fully caught up | < 30 s, zero I1–I9 violations |

The socket-density budget is deliberately lower than v2.0's 20 000. Node with Socket.IO carries more per-connection overhead than the BEAM, and 8 000 is an honest figure rather than an aspirational one. At 5 000 concurrent per region, one node plus one for redundancy suffices. Event-loop lag is a new budget and the key health signal for a single-threaded gateway.

---

## 7. Cross-Cutting Concerns

**Security.** Argon2id hashing. Refresh-token rotation with reuse detection; 15-minute access tokens. Tenant isolation by RLS. Central policy module. Strict CSP with no `unsafe-inline`; user content served from a separate origin to contain XSS. Rate limiting at edge (IP) and application (user/token/workspace). Signed 5-minute file URLs. HMAC-signed webhooks with a 5-minute replay window. Encryption at rest, TLS 1.3 in transit. Penetration test before GA.

**End-to-end encryption is out of scope**, being incompatible with server-side search, unfurling, and compliance export — all required features. Deliberate and documented, not an oversight.

**Observability.** Trace IDs propagate client → api → pg-boss → worker → gateway emit. Realtime-specific signals: connected sockets per node, **event-loop lag**, Redis adapter latency, catch-up gap-size distribution, reconnect rate, outbox depth, presence sweeper corrections, and unread-reconciliation corrections per hour. That last metric should trend to zero; a non-zero rate is a defect signal, not noise.

**Node-specific operational care.** The gateway is single-threaded per process. Run one process per core behind the load balancer, keep handlers trivial (§4.3), alarm on event-loop lag, and never do JSON work over large payloads inside a socket handler. This is the discipline that replaces what the BEAM would have enforced structurally.

---

## 8. Phased Delivery

Durations are indicative. **Gates are quality-based, not date-based.**

### Phase 0 — Foundation *(2–3 weeks)*
Monorepo, three NestJS apps, CI/CD, environments, Terraform baseline, Postgres + Redis + Typesense + MinIO, Zod→OpenAPI contract pipeline, design-system skeleton, Next.js static export verified inside Tauri on Windows and macOS, PWA manifest and Service Worker skeleton. Begin certificate procurement.
**Exit:** a message round-trips web → api → Redis adapter → gateway → desktop across two gateway nodes in staging; CI produces desktop artefacts for Windows and macOS and a deployable web bundle.

### Phase 1 — Identity, Workspaces & Spikes *(3–4 weeks)*
Features 1.1–1.3, 1.8, 2.1–2.2, 2.5–2.7, 2.11, 10.1 (basic). RLS live, transaction-scoped. Policy module. Sessions, devices, invites, workspace switcher.
**Run the three spikes here, not later:** WKWebView `getDisplayMedia` on macOS (OD-3), ProseMirror composer against the performance budget (OD-2), Socket.IO + Redis adapter at 8 000 sockets across two nodes (OD-6).
**Exit:** cross-tenant isolation suite green across every endpoint and socket event; all three spikes resolved with a documented decision.

### Phase 2 — Messaging Core & Sync Engine *(7–9 weeks)* — the phase that decides the product
Features 3.1–3.6, 3.9, 4.1–4.4, 4.6–4.7, 4.9–4.10, 4.12–4.13, 4.22–4.23, 5.1–5.2, 6.3–6.4, 12.1, 12.3–12.5. The full sync engine per §5 including both cursors, the property suite, the fault harness, and the TLA+ model. Presence per §4.4. Threads with per-thread read state. Reactions, typing, unreads, optimistic send, offline outbox, file upload.
**Exit:** I1–I9 hold under the fault harness across 10 000 seeded runs; a 5 000-client reconnect storm converges with zero violations; 3× target concurrency sustained one hour with no message loss, no duplicate renders, and p99 event-loop lag under budget.

### Phase 3 — Client Polish, Desktop & PWA *(4–5 weeks)*
Features 6.1, 7.1–7.3, 11.1–11.6, 11.9, 12.2, 12.7. Signed auto-update. Deep links, tray, badges, keychain. PWA installable on Linux with working badge and push. Accessibility pass. Performance budgets enforced in CI.
**Exit:** **internal alpha — the team runs its own daily communication on the product**, including at least one member on Linux via the PWA. Auto-update ships a build to all alpha users.

> Keep this gate aggressive. Feedback quality is the one input a longer calendar cannot buy.

### Phase 4 — Notifications & Collaboration Depth *(5–6 weeks)*
Features 3.7–3.8, 3.10, 3.13, 4.5, 4.8, 4.11, 4.14–4.21, 5.3–5.4, 5.7–5.8, 6.2, 6.5–6.6, 7.4–7.7, **7.5 SMS**, 7.9, 11.7–11.8, 11.10, 11.12. Notification decision engine with cross-device dedup and DND. Email digests. Twilio. Typesense indexing pipeline with §4.8 filtering.
**Exit:** the notification matrix (device × preference × DND × mention type × thread vs channel) passes automated tests; zero duplicate notifications with desktop and PWA simultaneously active.

### Phase 5 — Platform, Admin & Launch Readiness *(5–6 weeks)*
Features 1.4, 1.7, 1.9, 2.3–2.4, 2.8, 9.1–9.4, 9.8, 10.2–10.3, 10.6–10.8. Bots, webhooks, slash commands, Events API. Audit log, analytics, billing. Penetration test, load test to 3×, DR drill, runbooks, status page.
**Exit:** **v1.0 GA.** Pen-test findings remediated; RTO/RPO demonstrated by live restore; on-call staffed.

### Phase 6 — Voice, Video & Telephony *(7–9 weeks)*
Features 5.5–5.6, 8.1–8.9. LiveKit cluster, TURN/STUN, Connect, video, screenshare, recording, transcription, PSTN bridge. No native media work — browser and webview WebRTC only.
**Exit:** 12-participant room stable 60 minutes on Windows and macOS desktop, and on Chrome/Firefox/Safari including Linux; MOS above threshold; graceful degradation at 5% packet loss.

### Phase 7 — Enterprise & Ecosystem *(ongoing)*
Features 1.5–1.6 (Keycloak/Ory), 2.9–2.10, 3.11–3.12 (canvases on the Phase-2 Yjs foundation), 4.24–4.25, 5.9, 6.7, 9.5–9.7, 10.4–10.5. Native mobile if warranted.

### Sequence summary

| Phase | Indicative | Cumulative | Gate |
|---|---|---|---|
| 0 Foundation | 2–3 w | ~3 w | Two-node round-trip |
| 1 Identity + spikes | 3–4 w | ~7 w | Isolation proven, spikes resolved |
| 2 Messaging & sync | 7–9 w | ~16 w | **I1–I9 verified** |
| 3 Polish, desktop, PWA | 4–5 w | ~21 w | **Internal alpha** |
| 4 Notifications | 5–6 w | ~27 w | Private beta |
| 5 Platform & admin | 5–6 w | ~33 w | **v1.0 GA** |
| 6 Calls | 7–9 w | ~42 w | Connect & video |
| 7 Enterprise | ongoing | — | SSO, Grid, public API |

Roughly **7–8 months to GA**, **10 months** including calls. Phase 2 is two weeks longer than v2.0 because presence is hand-built rather than inherited; Phase 6 is shorter because there is no native media pipeline. Note that v2.0's estimate silently assumed Elixir competence the team does not have — **v3.0 is the first honest timeline in this document's history.**

### 8.1 Specifications required before Phase 2 opens

Not optional. Each is a document, not a code comment.

| Spec | Why it blocks Phase 2 |
|---|---|
| **Realtime event catalogue** | Every room, event name, payload schema, and the protocol version negotiation. Desktop clients run stale versions; without versioning from day one, every future event change is a breaking change. |
| **`blocks` JSON schema** | The ProseMirror serialisation format is a wire contract you live with permanently. Needs a version field and a migration story. |
| **Permission matrix** | Roles × actions × resources, all six role types. Single-channel guests are where this gets subtle. |
| **Notification decision table** | Channel pref × global pref × DND × keyword × mention type × thread vs channel × device activity × dedup window. Too complex to write correctly straight into code. |
| **Capacity and cost model** | Node sizing, Redis sizing, storage growth, Typesense sizing, monthly cost at 100 / 1 000 / 10 000 users. Absent from every revision so far. |

---

## 9. Risk Register

| Risk | Impact | Mitigation |
|---|---|---|
| Sync-engine defects (loss, duplication, misordering, unread drift) | High | §5 invariants + §6 property, fault-injection, and formal verification. Release-blocking. |
| Unread drift across channel *and* thread cursors | High | I5/I5b both modelled in TLA+; server-authoritative rehydration on every reconnect; corrections tracked as a production metric. |
| Hand-built presence is wrong (ghost-online, false-offline) | Medium-High | §4.4 device-count design, sweeper job, I9 in the property suite. The largest net-new work item in v3.0. |
| Node event-loop stalls under load | Medium | Trivial socket handlers, one process per core, event-loop lag budget and alarm. |
| Socket.IO density ceiling below projection | Medium | Measured in the Phase 1 spike (OD-6) before Phase 2 commits. Fallback is `ws` with a hand-built room layer. |
| macOS `getDisplayMedia` unusable in WKWebView | Medium | Phase 1 spike (OD-3). Fallbacks: Tauri capture APIs, or macOS screen-share via browser. Chat is unaffected either way. |
| Next.js static export friction | Low | Verified in Phase 0 before anything is built on it. |
| Linux users on web only | Low | Product decision, not a defect. PWA recovers standalone window, badge, and notifications. Revisit if demand appears. |
| Underestimating Phase 2 | High | Cut Phase-4 scope first, never sync foundations. |
| `channel_seq` write contention | Medium | Per-channel row counters, never global. Monitor lock waits from Phase 2. |
| Code signing / notarisation delay | Medium | Procurement starts now. |
| Search ACL staleness | Medium | Query-time filtering only (§4.8); ACLs never denormalised into the index. |
| Scope creep to full parity before GA | High | Nothing tiered **2** enters the GA branch. |

---

## 10. Immediate Next Steps

1. Approve this document and `DECISIONS.md`.
2. Stand up Phase 0 — including the Next.js-static-export-inside-Tauri check, which gates everything client-side.
3. Start Apple Developer and Authenticode certificate procurement today.
4. Schedule the three Phase 1 spikes (OD-2, OD-3, OD-6) as explicit deliverables with owners.
5. Write the five specifications in §8.1 during Phases 0–1, so Phase 2 opens against written contracts.
6. Lock the design language; build the design-system skeleton alongside Phase 0.

---

## Appendix A — Deliberately excluded

**Rejected as wrong, not merely expensive:** event sourcing or CQRS (the `messages` table already *is* an append-only log with a sequence); microservices; Cassandra or ScyllaDB; end-to-end encryption; a bespoke SFU; Kubernetes at this scale; partitioning `messages` before tens of millions of rows; denormalised search ACLs.

**Excluded because the team cannot operate them:** Elixir/Phoenix (v2.0's plan, withdrawn); any Rust in the application path.

**Deferred, not rejected:** native mobile apps; native Linux desktop binary; AI features; CRM integrations; multi-region active-active writes.
