# Architecture Decision Records

**Project** Slack-parity collaboration platform · **Doc version** 3.0 · **Last updated** 2026-07-27

Companion to `ARCHITECTURE.md`. Each record states the decision, why, what was rejected, and what would trigger a revisit.

Several records supersede earlier ones. The chain is preserved deliberately — a reversed decision with its reasoning intact is a useful artefact; a silently rewritten one invites the team to relitigate it in six months.

**Supersession summary**

| ADR | v1.0 | v2.0 | v3.0 (current) |
|---|---|---|---|
| 002 Backend | NestJS/Node | Elixir/Phoenix | **NestJS/Node** |
| 005 Client | Next.js + separate SPA | One SPA, no Next.js | **Next.js static export, one build** |
| 008 Editor | Lexical | ProseMirror + Yjs | **ProseMirror + Yjs** (OD-2 open) |
| 009 Desktop | Tauri, 3 OSes | Tauri + Rust sidecar | **Tauri Win/macOS + PWA on Linux** |

---

## ADR-001 — Server-assigned per-channel sequence as the ordering primitive

**Status** Accepted, unchanged since v1.0 · **Load-bearing**

**Decision.** Every message receives a monotonic `seq` from a per-channel counter row, assigned inside the write transaction. Clients order exclusively by `seq`, never by timestamp. Thread replies share the channel's `seq` space.

**Why.** Wall-clock ordering fails under clock skew, concurrent sends, and retries — and it fails *silently*, corrupting history permanently. A per-channel counter gives total order within the only scope that needs it, and hands clients a cursor for gap detection and catch-up at no extra cost. Sharing the sequence space with thread replies preserves one total order per channel while still allowing two views over it.

**Rejected.** Timestamp ordering (skew). Global sequence (contention across unrelated channels). Lamport or vector clocks (no benefit without concurrent writers per channel; users need order presented as absolute). Snowflake IDs (embed time, inherit skew). Separate sequence space per thread (two counters to keep consistent, no benefit).

**Revisit if** a single channel's counter row becomes a measurable write hot spot — the answer then is batched allocation, not a different primitive.

---

## ADR-002 — TypeScript backend: NestJS on Fastify

**Status** Accepted · **Supersedes the v2.0 Elixir/Phoenix decision** · **Load-bearing**

**Decision.** NestJS on Fastify, Drizzle for data access, deployed as three apps (`api`, `gateway`, `worker`) from one monorepo.

**Why.** Two reasons, in order of weight.

**1. Operability.** The team cannot run, profile, or debug Elixir in production. An architecture nobody can operate under load has negative value however good its properties are — it converts every incident into an outage of unknown duration. Operability is a correctness property, not a convenience.

**2. Sync logic lives on both sides of the wire.** This is the merit argument, and it holds independently of skills. Gap detection, cursor arithmetic, outbox replay, mention parsing, and `blocks` serialisation must agree *exactly* between client and server. In TypeScript that is one shared package (`libs/core`) with Zod schemas as the single source of truth. In any other backend language it is the same logic implemented twice, in two languages, drifting apart under maintenance. For a product that is 80% sync, this outweighs every framework-batteries advantage on offer.

**What Elixir gave that this does not.** Accounted honestly rather than glossed:

- *Transactional job enqueue* — **recovered** via pg-boss (ADR-006).
- *Cluster-wide pub/sub* — **recovered** via the Socket.IO Redis adapter (ADR-013).
- *Process-per-socket isolation* — **not recovered.** Node is single-threaded per process. Replaced by discipline: trivial socket handlers, one process per core, an event-loop lag budget with an alarm.
- *Distributed CRDT presence* — **not recovered.** Hand-built on Redis (`ARCHITECTURE.md` §4.4). This is the largest net-new work item in v3.0 and is tracked as its own risk.

**Rejected.**

- *Elixir/Phoenix (the v2.0 decision).* Technically the best fit for this workload — Channels, Presence, PubSub, and Oban collapse four components into one runtime, and Discord and WhatsApp are the precedents. Withdrawn solely on operability. Recorded in full so that a future team with Elixir capability can reopen it on merit rather than rediscover the case from scratch.
- *Laravel + Reverb.* Genuinely strong batteries — notification channels, Horizon, Scout, Passport, Cashier, Spatie's permission and audit packages would hand over roughly 3–4 weeks of Phase 4–5 work. Rejected because it forfeits shared sync logic (reason 2 above), and because Reverb's Pusher protocol has no cursor, ack, or resume semantics, so ADR-004's catch-up must be built over HTTP regardless. **Laravel optimises the 20% of this product that is CRUD; the 80% that is sync spans both client and server.** Remains the recommended fallback if TypeScript is ever abandoned.
- *Fastify or Hono without Nest.* Lighter and less ceremonious, but a multi-month multi-person codebase benefits from enforced module boundaries, DI, and guards more than it suffers from decorators.
- *Python/FastAPI.* Weakest realtime ecosystem of the candidates; same shared-logic loss as Laravel.

**Revisit if** the team acquires genuine Elixir operational capability *and* Phase 2's measured socket density proves inadequate. Both conditions, not either.

---

## ADR-003 — Shared-schema multi-tenancy with Postgres row-level security

**Status** Accepted, unchanged · **Load-bearing**

**Decision.** Every tenant-scoped table carries `workspace_id` as the leading column of every composite index. RLS policies enforced via `SET LOCAL app.current_workspace_id` and `app.current_user_id`, issued by a Drizzle wrapper. Tenant scope is fixed at authentication and never read from client input thereafter.

**Why.** Isolation belongs in the database. Application-level `where workspace_id = ?` conditionals **fail open** — one forgotten clause is a cross-tenant leak, and code review does not reliably prevent that across a multi-year codebase. RLS **fails closed**.

**Consequence, accepted.** `SET LOCAL` is transaction-scoped, so every tenant-scoped request runs inside a transaction. This is the primary reason Drizzle was chosen over Prisma — Prisma's connection handling makes reliable per-request session variables awkward.

**Rejected.** Schema-per-tenant (migration cost across 500+ schemas, pool fragmentation). Database-per-tenant (untenable at this count). Application-level filtering only (fails open). Prisma with RLS (fights the tool).

**Revisit if** one tenant's volume justifies isolation — the column-first design makes schema-per-tenant, then Citus sharding on `workspace_id`, a mechanical migration.

---

## ADR-004 — Hand-rolled sync engine with an explicit invariant set

**Status** Accepted, unchanged · **Load-bearing**

**Decision.** Build the sync engine as a named subsystem (`libs/sync`) against the invariants in `ARCHITECTURE.md` §5.2 (I1–I9). Client-side gap detection, client-side buffering across the catch-up/live boundary, a persistent idempotent outbox, and server-authoritative read state for both cursors.

**Why.** 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 charge complexity for generality that goes unused, and full local replication of message history is the wrong model (a bounded window is correct). What we build is smaller than the cost of integrating and then working around a general engine.

Naming it matters as much as building it. When sync is an emergent property of component choices, nobody owns its correctness, and the defects arrive as unreproducible reports about wrong unread badges. The two defects found in v2.0 (ADR-014) were caught by re-reading a written spec — which is the argument for having one.

**Rejected.** Replicache (mature and proven, commercially licensed, shaped for full-dataset sync). Zero (promising, too young for a load-bearing role). ElectricSQL (Postgres-logical-replication-to-client is the wrong model for large message history). **TanStack Query as the primary store** — it is a *request* cache and message data needs an *entity* cache; using it here guarantees hand-rolled normalisation later anyway. TanStack Query is retained strictly for admin, settings, and search. Socket.IO's connection-state recovery as a substitute for catch-up (short buffer only; insufficient).

**Revisit if** Zero reaches production maturity before Phase 2 begins.

---

## ADR-005 — One Next.js build for web, PWA, and desktop

**Status** Accepted · **Supersedes both the v1.0 and v2.0 client decisions**

**Decision.** `apps/web` is a Next.js app configured `output: 'export'`, fully client-rendered. The identical bundle is served at `app.domain.com` (web and installable PWA) and embedded in the Tauri shell. A separate `apps/marketing` Next.js app keeps SSR where SSR is useful.

**Why.** Three constraints intersect. Next.js cannot run its server inside Tauri, so any desktop build must be static. The team knows Next.js, and imposing unfamiliar tooling is the same category of error as imposing Elixir. And one build for all three targets collapses the divergence surface to a single `PlatformAdapter` interface.

Static export costs SSR, API routes, middleware, server actions, and Next image optimisation. **None of these serve an authenticated websocket-fed application** — there is no SEO value behind a login and no useful way to server-render an inbox fed by live sockets. Routing, layouts, and code splitting all work. So the loss is nominal and the familiarity gain is real.

**Rejected.** *v2.0's "no Next.js, use Vite + Astro."* Technically marginally cleaner — Next.js used purely as an SPA framework is mildly unorthodox. Withdrawn because the marginal gain does not justify unfamiliar tooling on a project where operability already proved to be the binding constraint. *v1.0's "Next.js for web plus a separate Vite SPA for desktop"* — two builds of the same product, strictly worse. *Next.js with SSR for the app* (impossible in Tauri). *Remix or TanStack Start* (same static-export mismatch, less familiar).

**Verify in Phase 0**, before anything is built on it: the exported bundle must load and run correctly inside Tauri on both Windows and macOS.

---

## ADR-006 — Postgres for persistence and jobs; Redis for ephemeral state

**Status** Accepted · **Amends the v2.0 "no Redis" decision**

**Decision.** PostgreSQL for all durable state, including the job queue via **pg-boss**. Redis for the Socket.IO adapter, presence, typing, rate limits, and WS tickets. `messages` is not partitioned at MVP.

**Why.** pg-boss stores jobs as Postgres rows, so `insertMessage` and `send('notifications', …)` commit in **one transaction.** A committed message can never fail to produce its notification job, and a rolled-back one can never produce a stray. **This keeps the transactional outbox pattern eliminated** — the single most valuable property inherited from v2.0's Oban, and the reason pg-boss beats the more popular alternative.

Redis returns because v3.0 genuinely needs it: the Socket.IO adapter requires it for cross-node fanout, and presence has no Phoenix.Presence to inherit. It is now doing four useful jobs rather than being kept "just in case."

On partitioning: v1.0 specified `PARTITION BY RANGE (created_at)` together with `UNIQUE (channel_id, seq)`. **That combination is invalid** — Postgres requires unique constraints on partitioned tables to include every partition key column. Correct indexing carries this workload to tens of millions of rows. If partitioning becomes necessary, `PARTITION BY HASH (channel_id)` keeps both constraints legal.

**Rejected.** **BullMQ** — more mature with better dashboards (Bull Board), but Redis-backed, which forces the transactional outbox pattern back into the design. The transactional guarantee is worth more than the dashboard. *Graphile Worker* — a close second to pg-boss and a fine substitute; pg-boss chosen for simpler operational surface. *Cassandra or ScyllaDB for messages* — Discord migrated at *billions* of messages; copying that here forfeits transactions, RLS, and joins for no gain.

**Revisit if** queue throughput outgrows Postgres — at which point BullMQ plus a proper outbox becomes the correct trade, made deliberately rather than by default.

---

## ADR-007 — Typesense for search, with query-time ACL filtering

**Status** Accepted, extended in v3.0

**Decision.** Typesense, with a Postgres GIN full-text index retained as fallback and for eDiscovery scans. Permission filtering happens **at query time** against the user's live channel membership; ACLs are never denormalised into the index.

**Why.** The primary requirement is filtered full-text search at typing latency with modifier support (`from:`, `in:`, `has:`, date ranges). Typesense is built for that and carries a fraction of OpenSearch's operational weight.

The ACL half was unspecified before v3.0, which was a latent data-leak bug. Denormalised ACLs go stale on every membership change — a user who leaves a private channel keeps finding its messages until reindexing catches up. Query-time filtering cannot go stale. The cost is a large filter clause for users in very many channels; mitigation is workspace-level filtering plus post-filtering of the top-N page, with the threshold measured in Phase 2.

**Rejected.** OpenSearch/Elasticsearch (more expressive DSL, better aggregation, materially heavier to run; honestly a close call decided on operational weight). Postgres FTS alone (relevance inadequate at scale). Meilisearch (comparable; Typesense chosen for filtering ergonomics). Denormalised per-document ACLs (stale-permission leaks).

**Revisit at** Phase 7 if eDiscovery and DLP need Elasticsearch aggregations — the search interface makes this a swap, not a rewrite.

---

## ADR-008 — ProseMirror + Yjs for rich text

**Status** Accepted, pending OD-2 · **Supersedes the v1.0 Lexical decision**

**Decision.** ProseMirror as the editor foundation, Yjs for collaborative editing. One architecture for the message composer and Phase-7 canvases.

**Why.** Lexical is the faster path to a good chat composer. ProseMirror is the better *foundation*: a rigorous document model and, via Yjs, a mature CRDT path to real-time collaborative editing. Canvases require collaborative documents, so choosing Lexical means adopting a second editor architecture later. One foundation beats two.

**Caveat carried into v3.0.** This decision was originally made when schedule was believed to be unconstrained. It survives because ProseMirror is a library rather than an operational burden — the constraint that killed Elixir does not apply. But it is the more demanding choice, and **Lexical remains an explicitly documented fallback** if the Phase 1 spike shows the composer missing its performance budget or costing disproportionate effort. Choosing the fallback would be a reasonable outcome, not a failure.

**Rejected.** Lexical as primary (second editor needed later for canvases). TipTap (a ProseMirror wrapper — reasonable, but the abstraction obscures the control needed for custom `blocks` serialisation). Slate (weaker collaborative story). Plain `contenteditable` (unserious at this scope).

**Decide by** the Phase 1 spike (OD-2).

---

## ADR-009 — Desktop: Tauri v2 on Windows and macOS; Linux served by the PWA

**Status** Accepted · **Supersedes the v2.0 Rust-sidecar decision**

**Decision.** Tauri v2 ships to Windows and macOS. Linux users run the web app, installable as a PWA. No native Linux binary, no Electron, no Rust in the application path.

**Why.** This resolves a problem that two previous revisions attacked from the wrong direction. Tauri borrows the OS webview, and on Linux that is WebKitGTK, whose media stack is the weakest of the three. v2.0's answer was the LiveKit Rust SDK in a sidecar — good engineering, but it presumes Rust capability the team does not have.

The observation that dissolves it: **the web app is completely independent of the desktop shell.** Linux users in Chrome or Firefox get full Chromium/Gecko WebRTC — better media support than any Tauri Linux build would have provided. So not shipping a Linux binary is not a compromise; on the media path it is an upgrade. And with WebKitGTK out of scope, the sidecar has no purpose, which removes Rust from the project entirely.

A PWA recovers most of what a native binary offers: standalone window, dock/taskbar icon, unread count via the Badging API, Web Push notifications, and offline read-only via Service Worker. The genuine gaps are system tray, global shortcuts, and launch-on-login.

**Rejected.** *Tauri on all three OSes with webview WebRTC* (Linux calls unreliable — the failure v1.0 walked into). *Tauri plus Rust sidecar (v2.0)* (best media architecture, requires unavailable skills). *Electron* (one bundled Chromium solves WebRTC in TypeScript, but costs ~100 MB binaries and materially higher memory — unnecessary once Linux is served by the browser). *Native Linux binary via a different toolkit* (cost far exceeds the audience).

**Open item.** WKWebView on macOS is now the only uncertain media surface — `getDisplayMedia` has historically been its rough edge. Spiked in **Phase 1** (OD-3), not Phase 6. Fallbacks: Tauri's capture APIs, or macOS users screen-sharing from a browser. Chat is unaffected in every scenario.

**Revisit if** Linux desktop demand materialises after GA — Electron for Linux only, or Tauri accepting chat-only there.

---

## ADR-010 — Single write region

**Status** Accepted, simplified from v2.0

**Decision.** One Postgres write region with per-region read replicas. Gateway nodes may be deployed per region.

**Why.** Active-active Postgres for a chat workload is a write-conflict problem with no proportionate payoff. Message writes are small and far less frequent than reads. The latency users actually perceive — presence, typing, fanout, scrollback — is served locally by regional gateways and replicas.

v2.0 could geo-distribute the gateway tier almost free via BEAM clustering. With Socket.IO plus a Redis adapter, cross-region gateway clustering means cross-region Redis, which is materially less pleasant. **Recommendation for v3.0: single region for GA**, adding regions only when a concrete user population justifies the Redis topology work.

**Rejected.** Full active-active multi-master (conflict complexity, no proportionate gain). Multi-region at GA (premature given the Redis coupling).

**Revisit when** a distinct regional user base justifies regional Redis and replica topology.

---

## ADR-011 — Verification standard: property-based, fault-injected, formally modelled

**Status** Accepted, unchanged · **Load-bearing**

**Decision.** The sync invariants (§5.2, I1–I9) are verified by `fast-check` property tests, a seeded fault-injection harness with fake timers and a scriptable transport, and a narrow TLA+ model of the read-state and catch-up protocol covering **both** cursors.

**Why.** This is the section that actually determines whether the product feels correct, and it survived the move from Elixir to TypeScript intact — which is worth stating plainly, because it means the stack downgrade did not cost the quality strategy.

Realtime sync bugs are combinatorial in delivery order, timing, and failure point. They are not reachable by example-based tests and they surface in production as unreproducible reports. Property-based testing explores the interleaving space and shrinks to minimal counterexamples; seeded fault injection makes any failure replayable; a formal model exhaustively checks the invariant clones most reliably violate. Modelling both cursors together matters because I5 and I5b interact — which is exactly where v2.0's defect lived.

**Rejected.** Example-based integration tests alone (cannot reach the defect class). Full deterministic simulation of the entire runtime (diminishing returns; scope determinism to the seams the protocol traverses). Formally modelling the whole system (cost outruns benefit outside read state and catch-up).

**Revisit** never downward. This standard is the reason to believe the product will feel correct.

---

## ADR-012 — No end-to-end encryption

**Status** Accepted, unchanged

**Decision.** Encryption at rest and TLS 1.3 in transit. No end-to-end encryption.

**Why.** E2E encryption is incompatible with server-side message search, link unfurling, and compliance export — all required features (6.1, 4.18, 10.5). A product trade-off, not a technical shortcut, recorded so it is never mistaken for an oversight.

**Revisit if** a target market makes E2E a purchase requirement — applying it to specific channel types only, with search and unfurling explicitly disabled there.

---

## ADR-013 — Socket.IO with the Redis adapter, websocket transport only

**Status** Accepted · **New in v3.0**

**Decision.** Socket.IO server inside the `gateway` NestJS app, `@socket.io/redis-adapter` for cross-node fanout, `transports: ['websocket']` with HTTP long-polling disabled. Rooms: `ws:{workspaceId}`, `ch:{channelId}`, `u:{userId}`.

**Why.** Rooms map one-to-one onto channels, reconnection with backoff is built in, and the Redis adapter provides cross-node fanout without hand-written routing. Decisively for this team: the documentation and community are the largest of any option, which matters most at 2 a.m. during an incident.

Disabling the polling transport removes the **sticky-session requirement** at the load balancer — a common and painful source of production confusion. The cost is that clients on networks blocking WebSockets cannot connect; acceptable and detectable.

**Explicit limitation.** Socket.IO's connection-state recovery replays only a short packet buffer. It is **not** a substitute for seq-based catch-up (ADR-004) and is not relied upon. Socket.IO is transport plus rooms; nothing more is delegated to it.

**Rejected.** *Raw `ws`* (better per-socket efficiency, but rooms, reconnection, heartbeats, and backpressure all become hand-built — worse for a team that must debug its own stack). *µWebSockets.js* (fastest, least forgiving). *Managed Pusher/Ably* — Ably is genuinely tempting because its protocol has message serial and resume semantics, which is precisely the hard part of ADR-004; rejected on per-message cost and vendor dependency for a product whose entire function is messaging, but it is the right answer if catch-up correctness proves beyond reach. *Soketi* (self-hosted Pusher-compatible; no advantage over Socket.IO here).

**Measure in Phase 1** (OD-6): 8 000 sockets across two nodes with the Redis adapter, under a reconnect storm. Fallback is raw `ws` with a hand-built room layer.

---

## ADR-014 — Thread read state as a first-class cursor

**Status** Accepted · **New in v3.0 — fixes two v2.0 defects**

**Decision.** Add `thread_subscriptions (user_id, root_message_id, …, last_read_reply_seq, reason, is_muted)`. Add `messages.is_broadcast`. Correct invariant I5 to exclude thread replies from channel unread, and add I5b for per-thread unread.

**Why.** v2.0 had exactly one cursor, `channel_members.last_read_seq`, and defined channel unread as `count(seq > last_read_seq AND …)`. Because thread replies are `messages` rows in the channel and consume channel `seq`, that definition **silently counted replies that are never rendered in the channel view** — producing a badge that reading the channel could not clear. That is the unread-drift failure the entire verification strategy exists to prevent, sitting in the specification itself.

Threads also need their own unread state to work at all: you follow a thread (on authoring, replying, being mentioned, or manually), accumulate unread replies per thread, and see them aggregated in a Threads view. One cursor cannot express that.

`is_broadcast` distinguishes a "also send to channel" reply, which appears in both views and therefore *does* count toward channel unread.

**Why it was missed.** v2.0 listed threading as a feature (4.3) and wrote the invariant over the message table without checking it against the thread data model. Feature lists and invariants must be cross-checked against each other; that is now part of the §8.1 specification work.

**Rejected.** A separate sequence space per thread (two counters to keep consistent, no benefit — ADR-001). Deriving thread unread client-side from cached messages (violates the server-authoritative principle and breaks for threads outside the cache window). Ignoring per-thread unread (Slack parity requires it, and it is much harder to add after read state ships).

---

## Open decisions

| # | Question | Resolve by | Method |
|---|---|---|---|
| OD-1 | Hosting: managed VMs, Fly.io, or bare metal? | Phase 0 | Cost model (§8.1) plus operational preference |
| OD-2 | Does ProseMirror meet the composer performance budget? | Phase 1 | Spike; fallback Lexical (ADR-008) |
| OD-3 | Does `getDisplayMedia` work in WKWebView on macOS? | Phase 1 | Spike; fallbacks in ADR-009 |
| OD-4 | Keycloak or Ory for SAML/OIDC/SCIM? | Phase 6 | Evaluation — no Phase 1–5 dependency |
| OD-5 | Typesense filter-clause ceiling for users in many channels | Phase 2 | Measurement (ADR-007) |
| OD-6 | Socket.IO density: 8 000 sockets/node across two nodes? | Phase 1 | Load spike; fallback raw `ws` (ADR-013) |
