diff --git a/README.md b/README.md
index af83a1cb..61cf97f5 100644
--- a/README.md
+++ b/README.md
@@ -207,6 +207,8 @@ When rotating the server updater key, update [Server/updater/server_update_publi
- [docs/livekit-setup.md](docs/livekit-setup.md)
- [docs/port-forwarding.md](docs/port-forwarding.md)
- [docs/tailscale.md](docs/tailscale.md)
+- [docs/architecture/](docs/architecture/README.md) — system blueprints (diagrams + flows)
+- [docs/audit-2026-07-19.md](docs/audit-2026-07-19.md) — latest architecture & spec-conformance audit
- [docs/api.md](docs/api.md)
- [docs/protocol.md](docs/protocol.md)
- [docs/schema.md](docs/schema.md)
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
new file mode 100644
index 00000000..ecd6a51d
--- /dev/null
+++ b/docs/architecture/README.md
@@ -0,0 +1,40 @@
+# OwnCord Architecture Blueprints
+
+**Verified against:** commit `ddc49f0`, 2026-07-19
+**Companion audit:** [docs/audit-2026-07-19.md](../audit-2026-07-19.md)
+
+This directory is the curated architectural map of OwnCord — the "blueprints" for
+the whole system. Every diagram is a Mermaid fenced block (GitHub renders these
+natively) followed by a prose explanation and a **Source of truth** file list.
+
+## Index
+
+| Doc | Diagrams | Covers |
+|-----|----------|--------|
+| [system-overview.md](system-overview.md) | D1 System context, D8 Deployment topology | All processes, trust boundaries, ports, single-instance constraints |
+| [server.md](server.md) | D2 Server package map, D3 REST request lifecycle | Go package structure, DB-access styles, middleware chain |
+| [websocket.md](websocket.md) | D4 WS connect / replay / dispatch | Real-time engine: auth handshake, 3-tier reconnect replay, backpressure, V1/V2 dispatch |
+| [data-model.md](data-model.md) | D5 Entity-relationship overview | All 23 tables from migrations 001–015, grouped by domain |
+| [voice-e2ee.md](voice-e2ee.md) | D6 Voice + E2EE flow | LiveKit token flow, loopback TLS tunnel, ECDH key-holder relay |
+| [client.md](client.md) | D7 Client module map | Tauri client: bootstrap, dispatcher, stores, Rust sidecars |
+
+## Maintenance rule
+
+These documents are **curated, not generated**. The rule that keeps them honest:
+
+> If a PR changes the *structure* of anything listed in a diagram's
+> **Source of truth** list (new package, new table, new message type, changed
+> flow), that PR updates the corresponding diagram in the same change.
+
+Diagrams reference stable identifiers (package names, table names, message-type
+strings) rather than line numbers wherever possible. Line-number evidence lives
+in the dated audit reports, which are point-in-time snapshots by design.
+
+## Relationship to other docs
+
+- `docs/api.md`, `docs/protocol.md`, `docs/schema.md` are the *reference specs*
+ (request/response shapes, wire formats, DDL). These blueprints describe
+ *structure and flow*, not payload shapes. Known drift between the specs and
+ the code is catalogued in [audit-2026-07-19.md §2](../audit-2026-07-19.md).
+- `docs/client-architecture.md` predates the abandonment of the Solid.js
+ migration; [client.md](client.md) reflects the current state.
diff --git a/docs/architecture/client.md b/docs/architecture/client.md
new file mode 100644
index 00000000..d7ed0f5f
--- /dev/null
+++ b/docs/architecture/client.md
@@ -0,0 +1,112 @@
+# Client Architecture (Tauri)
+
+**Verified against:** commit `ddc49f0`, 2026-07-19
+
+Desktop client built on Tauri v2: a TypeScript webview (~31.7k LOC, vanilla TS —
+no UI framework) plus ~2.3k LOC of Rust commands. State lives in a hand-rolled
+reactive store (`src/lib/store.ts`: immutable updates, microtask-batched
+notifications, selector subscriptions). Components are factory functions
+returning `{ element, mount, destroy }` built with the `@lib/dom` helpers; a
+2-page state machine (`src/lib/router.ts`) switches between the Connect and
+Main pages.
+
+> `docs/client-architecture.md` still describes a SolidJS-based client. The
+> Solid migration was **abandoned** (per CHANGELOG); only a 154-LOC beachhead
+> remains under `src/components/solid/`. This document reflects the actual
+> state.
+
+## D7 — Module map
+
+```mermaid
+flowchart TB
+ subgraph boot ["Bootstrap"]
+ MAIN["main.ts
page orchestration, auth wiring,
appearance pre-render"]
+ end
+
+ subgraph rust ["Rust (src-tauri)"]
+ WSP["ws_proxy.rs
WSS + TOFU cert pinning"]
+ LKP["livekit_proxy.rs
loopback TLS tunnel"]
+ CRED["credentials.rs
OS keychain"]
+ PTT["ptt.rs
push-to-talk polling"]
+ UPDC["update_commands.rs
self-hosted updater"]
+ SET["commands.rs
settings store (key allowlist)"]
+ end
+
+ subgraph comm ["Communication layer (src/lib)"]
+ API["api.ts
REST client (tauri-plugin-http,
allowSelfSigned=true)"]
+ WSC["ws.ts
reconnect w/ backoff, seq replay,
generation counters"]
+ DISP["dispatcher.ts
~30 msg types → store mutators"]
+ LKS["livekitSession.ts (1.7k LOC)
voice state machine + E2EE"]
+ end
+
+ subgraph state ["Stores (8 singletons)"]
+ AUTH2["auth"]
+ CHAN["channels"]
+ MSG["messages"]
+ MEM["members"]
+ VOICE["voice"]
+ DM["dm"]
+ ROLES["roles"]
+ UIS["ui"]
+ end
+
+ subgraph ui ["UI (imperative DOM)"]
+ CP["ConnectPage
profiles + health polling"]
+ MP["MainPage
SidebarArea / ChatArea /
controllers"]
+ COMP["~30 component families
message-list, settings tabs,
voice widgets, overlays"]
+ SOLID["components/solid/
abandoned beachhead"]
+ end
+
+ MAIN --> CP
+ MAIN --> MP
+ MAIN --> API
+ MAIN --> WSC
+ WSC --> WSP
+ WSC --> DISP
+ DISP --> AUTH2 & CHAN & MSG & MEM & VOICE & DM & ROLES & UIS
+ state --> ui
+ LKS --> LKP
+ VOICE --> LKS
+ MP --> COMP
+ API -.->|"no cert pinning
(unlike WS path)"| SRV["Go server"]
+ WSP --> SRV
+ CRED -.-> MAIN
+ UPDC -.-> MAIN
+
+ %% cross-store coupling (audit finding)
+ AUTH2 -.->|clearAuth → leaveVoice| VOICE
+ VOICE -.-> MEM
+
+ classDef dead fill:none,stroke-dasharray: 5 5,opacity:0.6
+ class SOLID dead
+```
+
+**What this shows.** Data flows one way in the happy path: WS frame → Rust
+`ws_proxy` → `ws.ts` → `dispatcher.ts` → store mutators → subscribed components
+re-render. The dashed edges mark the audit findings: the HTTP path accepts any
+certificate (`allowSelfSigned` hardcoded, no TOFU pinning — unlike the WS and
+LiveKit paths, which pin fingerprints in Rust), the stores cross-import each
+other (auth→voice→members), and the Solid beachhead is dead weight.
+
+### Key mechanisms
+
+| Concern | Where | How |
+|---------|-------|-----|
+| Reconnect | `src/lib/ws.ts` | Exponential backoff (cap 30s), heartbeat 30s, `last_seq` replay + bounded dedup set, generation counter invalidates stale listeners |
+| Cert trust | `src-tauri/src/ws_proxy.rs` | TOFU: first fingerprint pinned per host (`certs.json`); mismatch → modal (`CertMismatchModal`) |
+| Credentials | `src-tauri/src/credentials.rs` | OS keychain per host; password field `serde(skip)` so it never crosses IPC back to JS |
+| Multi-server | `src/lib/profiles.ts` | Server profiles w/ 15s health polling and auto-connect; one active connection, quick-switch replaces WS + tunnels |
+| Updates | `src/lib/updater.ts` + `update_commands.rs` | Endpoint derived from the connected server URL, https-only, TLS pinned to TOFU fingerprint, minisign-verified |
+| Settings | `commands.rs` + `src/lib/preferences.ts` | Split persistence: Rust store (`settings.json`, key-allowlisted) *and* raw `localStorage` for UI prefs/themes |
+| Theming | `src/lib/themes.ts` + `styles/tokens.css` | CSS custom properties; 4 built-in themes + custom overrides |
+
+### Quality tooling
+
+157 test files (~63k LOC — about 2× the source): Vitest unit + integration,
+Playwright E2E (web and native Tauri suites), Stryker mutation testing, oxlint +
+type-checked ESLint, Prettier, Knip, strict `tsc`. The client unit suite is
+currently marked "KNOWN RED" and non-blocking in CI — an audit finding.
+
+**Source of truth:** `src/main.ts`, `src/lib/dispatcher.ts`, `src/lib/ws.ts`,
+`src/lib/api.ts`, `src/lib/store.ts`, `src/stores/*.store.ts`,
+`src-tauri/src/lib.rs`, `src-tauri/tauri.conf.json`.
diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md
new file mode 100644
index 00000000..4512c066
--- /dev/null
+++ b/docs/architecture/data-model.md
@@ -0,0 +1,126 @@
+# Data Model
+
+**Verified against:** commit `ddc49f0`, 2026-07-19
+
+The canonical schema is the ordered migration set `Server/migrations/001–015`
+(embedded via `go:embed`, applied by the custom runner in `Server/db/migrate.go`,
+tracked in the `schema_versions` table). SQLite is the only supported engine —
+`Server/main.go` rejects any other `database.type` at startup.
+
+## D5 — Entity-relationship overview
+
+All 23 application tables, grouped by domain. Junction/leaf detail columns are
+elided; the goal is the relationship graph, not full DDL (see `docs/schema.md`
+for DDL — note it is currently 6 migrations behind, see
+[audit-2026-07-19.md §2](../audit-2026-07-19.md)).
+
+```mermaid
+erDiagram
+ %% ── Identity & access ──
+ roles ||--o{ users : "role_id"
+ users ||--o{ sessions : "user_id"
+ roles ||--o{ channel_overrides : "role_id"
+ channels ||--o{ channel_overrides : "channel_id"
+ users ||--o{ user_blocks : "blocker_id / blocked_id"
+ users ||--o{ invites : "created_by / redeemed_by"
+
+ %% ── Messaging ──
+ channels ||--o{ messages : "channel_id"
+ users ||--o{ messages : "user_id"
+ messages ||--o{ messages : "reply_to"
+ messages ||--o{ attachments : "message_id"
+ messages ||--o{ reactions : "message_id"
+ users ||--o{ reactions : "user_id"
+ users ||--o{ read_states : "user_id"
+ channels ||--o{ read_states : "channel_id"
+
+ %% ── Direct messages (channels with type='dm') ──
+ channels ||--o{ dm_participants : "channel_id"
+ users ||--o{ dm_participants : "user_id"
+ channels ||--o{ dm_open_state : "channel_id"
+ users ||--o{ dm_open_state : "user_id"
+
+ %% ── Voice ──
+ users ||--o| voice_states : "user_id (PK)"
+ channels ||--o{ voice_states : "channel_id"
+
+ %% ── Plugins ──
+ plugins ||--o{ plugin_kv : "plugin_id"
+
+ %% ── Standalone (no FK edges) ──
+ events
+ settings
+ audit_log
+ audit_log_v6
+ login_attempts
+ rate_lockouts
+ emoji
+ sounds
+
+ users {
+ int id PK
+ int role_id FK
+ string username
+ string password_hash
+ bool banned
+ }
+ roles {
+ int id PK
+ string name
+ int permissions "31-bit bitfield"
+ int position
+ }
+ channels {
+ int id PK
+ string name
+ string type "text | voice | dm (trigger-enforced)"
+ }
+ messages {
+ int id PK
+ int channel_id FK
+ int user_id FK
+ int reply_to FK
+ string content
+ }
+ attachments {
+ string id PK "UUID"
+ int message_id FK
+ int uploader_id "added by 010"
+ }
+ events {
+ int seq PK "AUTOINCREMENT, hub seq seeded from MAX(seq)"
+ string type
+ string payload
+ }
+```
+
+### Domain notes
+
+| Domain | Tables | Notes |
+|--------|--------|-------|
+| Identity & access | `roles`, `users`, `sessions`, `channel_overrides`, `user_blocks`, `invites`, `login_attempts`, `rate_lockouts` | Sessions store only SHA-256 token hashes. Permissions are a bitfield on `roles.permissions`; channel overrides use Discord semantics `(role &^ deny) \| allow`. `rate_lockouts` (011) persists rate-limiter lockouts across restarts. |
+| Messaging | `channels`, `messages`, `attachments`, `reactions`, `read_states`, `emoji` | `channels.type` is constrained to `text \| voice \| dm` by INSERT/UPDATE triggers from migration 013 — the `announcement` type in the specs/admin UI is rejected at this layer. `attachments.uploader_id` (010) backs upload-ownership checks. |
+| Direct messages | `dm_participants`, `dm_open_state` | DMs are `channels` rows with `type='dm'`; these tables track membership and per-user open/closed UI state (009). |
+| Voice | `voice_states` | One row per user (`user_id` is the PK) — a user occupies at most one voice channel. |
+| Real-time replay | `events` | Cold tier of the 3-tier reconnect replay ([websocket.md](websocket.md)); written by the async `EventPersister`, pruned by retention. Hub seq counter is seeded from `MAX(events.seq)` at startup so seqs stay monotonic across restarts. |
+| Plugins | `plugins`, `plugin_kv` | 015. `plugin_kv` is per-plugin namespaced KV via composite PK `(plugin_id, key)`. |
+| Ops | `settings`, `audit_log`, `audit_log_v6`, `sounds` | `settings` is a generic KV read by admin and (directly, via inline SQL) by the WS hub. `audit_log` + `audit_log_v6` coexist after the 003 rebuild. `sounds` is **dead schema** — the soundboard feature was removed but the table remains. |
+
+### How the schema is accessed (three coexisting styles)
+
+1. **Raw SQL in `Server/db`** — hand-written queries (`*_queries.go`, ~178 call
+ sites). This is what actually runs, used directly by `Server/api` handlers,
+ `Server/admin`, and the `ws.Hub`.
+2. **`store.Store` interface** (`Server/store`) — a 14-domain composed interface
+ with `SQLiteStore` (delegates to `db.DB`) and `MemStore` (test double).
+ Consumed by the `Server/service` layer only.
+3. **sqlc-generated `Server/db/dbgen`** (~3.5k LOC, from `Server/db/queries/sqlite/`
+ per `sqlc.yaml`) — **imported by nothing**; dead code kept verified by the
+ `sqlc-verify` CI job.
+
+The coexistence of all three is the largest structural finding of the audit —
+see [audit-2026-07-19.md §3](../audit-2026-07-19.md).
+
+**Source of truth:** `Server/migrations/*.sql` (schema), `Server/db/migrate.go`
+(runner), `Server/db/*_queries.go` (live queries), `Server/store/store.go`
+(interface), `sqlc.yaml` + `Server/db/dbgen/` (dormant generated layer).
diff --git a/docs/architecture/server.md b/docs/architecture/server.md
new file mode 100644
index 00000000..d36d405f
--- /dev/null
+++ b/docs/architecture/server.md
@@ -0,0 +1,134 @@
+# Server Architecture
+
+**Verified against:** commit `ddc49f0`, 2026-07-19
+
+Single Go binary (`github.com/owncord/server`, Go 1.25). Pure-Go SQLite
+(`modernc.org/sqlite`, no CGO), chi router, `nhooyr.io/websocket`, LiveKit for
+voice, Wazero for plugins (build-tag gated), optional OpenTelemetry
+(`-tags otel`). Roughly 29k LOC of production code and 46k LOC of tests.
+
+## D2 — Package map
+
+```mermaid
+flowchart TB
+ subgraph entry ["Process entry"]
+ MAIN["main.go
config, TLS, DB open+migrate,
signal handling, maintenance loop"]
+ end
+
+ subgraph http ["HTTP layer"]
+ API["api
router, middleware, REST handlers,
WAF, LiveKit proxy, uploads"]
+ ADMIN["admin
embedded SPA + admin REST,
SSE log stream"]
+ end
+
+ subgraph realtime ["Real-time"]
+ WS["ws
Hub, pub/sub, replay,
V1+V2 dispatch, LiveKit, E2EE relay"]
+ end
+
+ subgraph domain ["Domain"]
+ SVC["service
Message/Channel/Permission/User/
DM/Invite/Block/Moderation/Voice"]
+ PERM["permissions
bitfield + Checker"]
+ PLUGIN["plugin
wazero runtime, registry,
manifest, host APIs"]
+ end
+
+ subgraph data ["Data"]
+ STORE["store
Store interface,
SQLiteStore, MemStore"]
+ DB[("db
raw SQL queries,
migration runner, models")]
+ DBGEN["db/dbgen
sqlc-generated"]
+ MIG["migrations
001–015 embedded SQL"]
+ end
+
+ subgraph support ["Support"]
+ AUTH["auth
bcrypt, tokens, TOTP,
rate limiting, TLS"]
+ CFG["config
koanf: defaults→YAML→env"]
+ STORAGE["storage
upload files on disk"]
+ TEL["telemetry
OTel (no-op default)"]
+ UPD["updater
minisign-verified self-update"]
+ end
+
+ MAIN --> CFG
+ MAIN --> API
+ MAIN --> DB
+ MAIN --> STORE
+ API --> ADMIN
+ API --> WS
+ API --> SVC
+ API --> AUTH
+ API --> STORAGE
+ API --> UPD
+ API --> PLUGIN
+ SVC --> STORE
+ SVC --> PERM
+ STORE --> DB
+ DB --> MIG
+ WS --> SVC
+ WS --> PLUGIN
+
+ %% Layering violations (dashed red): raw *db.DB used above the store seam
+ API -.->|"handlers take *db.DB directly"| DB
+ ADMIN -.->|"raw *db.DB"| DB
+ WS -.->|"inline SQL for settings"| DB
+ DBGEN -.-|"generated but imported by nothing"| DB
+
+ classDef dead fill:none,stroke-dasharray: 5 5,opacity:0.6
+ class DBGEN dead
+```
+
+**What this shows.** The intended layering is
+`api → service → store → db`, and the `service` layer does follow it. But three
+dashed edges mark where the seam is bypassed: nearly every REST handler receives
+both `svc` *and* a raw `*db.DB`; the `admin` package operates on `*db.DB`
+almost exclusively; and the `ws.Hub` runs inline SQL against the `settings`
+table. `db/dbgen` (sqlc output) is generated and CI-verified but referenced by
+no code. In effect the server has three coexisting data-access styles — see
+[data-model.md](data-model.md) and the audit for the consolidation
+recommendation.
+
+`api.NewRouter` (`Server/api/router.go`) is the composition root: it constructs
+the rate limiter, TOTP key, storage, `service.New`, the `ws.Hub`, the LiveKit
+client/subprocess, the updater, the admin handler, and the plugin admin handler;
+spawns background goroutines; and mounts all routes. `main.go` performs only
+process-level wiring (config, TLS, DB, event persistence, HTTP server,
+shutdown).
+
+**Source of truth:** `Server/main.go`, `Server/api/router.go`, package import
+graph (`go list -deps`), `Server/store/store.go`, `sqlc.yaml`.
+
+## D3 — REST request lifecycle
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant C as Client
+ participant MW as Global middleware
(api/router.go)
+ participant RT as Route mount
(Mount*Routes)
+ participant H as Handler
+ participant S as service.*
+ participant ST as store.Store
+ participant DB as SQLite
+
+ C->>MW: HTTPS request
+ Note over MW: RequestID → Recoverer → requestLogger
→ telemetry → SecurityHeadersWithTLS
→ MaxBodySizeUnless(uploads exempt)
→ optional Coraza WAF
+ MW->>RT: routed by chi
+ Note over RT: AuthMiddleware(database)
+ per-route rate limits
+ RequirePermission(...) where mounted
+ RT->>H: authenticated request
+ H->>S: domain call (svc.Messages, svc.Permissions, …)
+ S->>ST: Store interface method
+ ST->>DB: SQL (via db.DB)
+ DB-->>C: JSON response (errorResponse envelope on failure)
+
+ rect rgba(200,120,120,0.15)
+ Note over H,DB: Deviation — auth routes: MountAuthRoutes(r, database, …)
bypasses service/store and queries *db.DB directly.
Admin REST (Server/admin) does the same behind
AdminIPRestrict + RequireAdminAuth.
+ end
+```
+
+**What this shows.** The global chain is assembled in `NewRouter`; note that
+chi's `middleware.RealIP` is deliberately omitted — client IP is resolved via
+`clientIPWithProxies` against configured trusted proxies instead, so spoofed
+`X-Real-IP`/`X-Forwarded-For` headers are not trusted by default. Authentication
+is bearer-token (SHA-256-hashed opaque tokens); authorization is enforced
+inconsistently — sometimes as `RequirePermission` middleware at mount time,
+sometimes in-handler through `svc.Permissions` (an audit finding). The shaded
+region marks the two documented bypass paths of the domain layer.
+
+**Source of truth:** `Server/api/router.go`, `Server/api/middleware.go`,
+`Server/api/auth_handler.go`, `Server/admin/middleware.go`, `Server/service/`.
diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md
new file mode 100644
index 00000000..c2297f21
--- /dev/null
+++ b/docs/architecture/system-overview.md
@@ -0,0 +1,99 @@
+# System Overview
+
+**Verified against:** commit `ddc49f0`, 2026-07-19
+
+OwnCord is a self-hosted chat stack: one Go server binary per community, a Tauri
+desktop client that can hold profiles for many servers (one active connection at
+a time), LiveKit for voice/video media, and an embedded web admin panel.
+
+## D1 — System context and trust boundaries
+
+```mermaid
+flowchart LR
+ subgraph desktop ["Desktop client (Tauri v2)"]
+ WV["Webview (TS)
UI, stores, dispatcher"]
+ subgraph sidecars ["Rust commands"]
+ WSP["ws_proxy
TOFU-pinned WSS"]
+ LKP["livekit_proxy
TOFU-pinned TLS tunnel"]
+ CRD["credentials
OS keychain"]
+ UPD["updater
pinned TLS + minisign"]
+ end
+ WV --- sidecars
+ end
+
+ subgraph server ["Self-hosted Go server (single binary)"]
+ RTR["api router
REST /api/v1"]
+ HUB["ws Hub
real-time"]
+ ADM["admin SPA + REST
(IP-gated)"]
+ PLG["plugin runtime
(wazero, opt-in)"]
+ DBF[("SQLite file
WAL, single writer")]
+ UPS["file storage
uploads/"]
+ end
+
+ LK["LiveKit server
(managed subprocess
or external)"]
+ REL["OwnCord-releases
(GitHub, minisign-signed)"]
+
+ WV -->|"HTTPS REST
⚠ accepts any cert
(no pinning)"| RTR
+ WSP -->|"WSS, fingerprint-pinned"| HUB
+ LKP -->|"TLS, fingerprint-pinned"| LK
+ WV -->|"admin panel (browser)"| ADM
+ HUB <-->|"webhooks + server SDK"| LK
+ RTR --> DBF
+ HUB --> DBF
+ RTR --> UPS
+ PLG -.->|"allowlisted HTTP only"| NET["external hosts"]
+ UPD -->|"via connected server URL"| REL
+ server -->|"self-update check"| REL
+```
+
+**What this shows.** Three transport paths leave the client and only two are
+certificate-pinned: the app WebSocket and the LiveKit tunnel go through Rust
+proxies that pin a trust-on-first-use SHA-256 fingerprint per host; the HTTP
+REST path currently accepts any certificate (an acknowledged gap tracked in the
+audit). The admin panel is served by the same binary but gated to configured
+CIDRs (private ranges by default), with bearer admin auth on top for the plugin
+endpoints. Plugins run in a WASM sandbox whose HTTP capability is allowlisted
+per manifest. Both the server self-updater and the client updater verify
+minisign signatures against pinned embedded public keys.
+
+## D8 — Deployment topology
+
+```mermaid
+flowchart TB
+ subgraph hostbox ["Operator host (or Docker)"]
+ BIN["owncord server binary"]
+ BIN --> CFGF["config.yaml
(koanf: defaults → YAML → OWNCORD_* env)"]
+ BIN --> DATA["data dir
SQLite DB + uploads + TLS certs"]
+ BIN --> LKPROC["livekit-server
(optional managed subprocess)"]
+ BIN --> P1[":8443 HTTPS + WSS
API, WS, admin, uploads"]
+ BIN -.-> P80[":80 ACME HTTP-01
(when TLS mode acme)"]
+ LKPROC --> P2["LiveKit ports
(WS + UDP media range)"]
+ end
+
+ C1["Tauri clients"] --> P1
+ C1 --> P2
+ ADMB["Admin browser
(allowed CIDRs only)"] --> P1
+
+ subgraph constraints ["Single-instance constraints (scale-out blockers)"]
+ R1["in-memory rate-limiter windows
(lockouts persisted, windows not)"]
+ R2["in-memory pub/sub + replay ring buffer"]
+ R3["process-local TOTP replay store"]
+ R4["SQLite single-writer (MaxOpenConns=1)"]
+ end
+ BIN --- constraints
+```
+
+**What this shows.** The deployment unit is one process per community —
+TLS (self-signed, custom, or ACME), the DB, uploads, the admin panel, and
+optionally LiveKit are all owned by that process. The design is explicitly
+single-instance: rate-limit windows, pub/sub, the replay ring buffer, and the
+TOTP replay store are process-local, and SQLite runs with a single writer.
+Horizontal scaling is out of scope today; the constraint boxes name exactly
+what would have to move to shared infrastructure if that ever changes. A
+15-minute maintenance goroutine (expired sessions, orphaned attachments, with a
+circuit breaker) and graceful drain on SIGINT/SIGTERM round out the process
+lifecycle.
+
+**Source of truth:** `Server/main.go`, `Server/config/config.go`,
+`Server/docker-compose.yml`, `docs/deployment.md`, `docs/server-configuration.md`,
+`Client/tauri-client/src-tauri/src/lib.rs`.
diff --git a/docs/architecture/voice-e2ee.md b/docs/architecture/voice-e2ee.md
new file mode 100644
index 00000000..aecf9de1
--- /dev/null
+++ b/docs/architecture/voice-e2ee.md
@@ -0,0 +1,73 @@
+# Voice and End-to-End Encryption
+
+**Verified against:** commit `ddc49f0`, 2026-07-19
+
+Voice/video runs on LiveKit. The Go server issues short-lived scoped tokens and
+relays E2EE key-exchange messages; media flows client↔LiveKit directly. On the
+client, everything funnels through `src/lib/livekitSession.ts`, and — because
+self-hosted servers commonly use self-signed certificates — the LiveKit
+connection is tunneled through a local Rust TLS proxy pinned to the same TOFU
+fingerprint as the main WebSocket.
+
+## D6 — Voice join + E2EE key exchange
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant UI as Client UI
+ participant LKS as livekitSession.ts
+ participant RP as Rust livekit_proxy
(loopback TCP→TLS, TOFU-pinned)
+ participant WS as App WebSocket (Hub)
+ participant SRV as Go server
+ participant LK as LiveKit server
+
+ UI->>WS: voice_join {channel_id}
+ WS->>SRV: permission check (channel-scoped)
+ SRV-->>WS: voice_token (5-min JWT,
CanPublishSources scoped by permission)
+ WS-->>LKS: voice_token payload
+ LKS->>RP: connect ws://127.0.0.1:{port}
+ RP->>LK: TLS (fingerprint-pinned)
+ LKS->>LK: LiveKit signaling + media (via tunnel)
+
+ rect rgba(120,160,220,0.15)
+ Note over LKS,WS: E2EE key exchange (relayed via app WS)
+ LKS->>WS: voice_e2ee_announce {ECDH pubkey}
+ WS-->>LKS: voice_e2ee_announce broadcast to channel
+ Note over SRV: Hub tracks per-channel key holder
(lowest user ID)
+ LKS->>WS: voice_e2ee_offer {wrapped room key, target user}
+ WS-->>LKS: voice_e2ee_offer relayed to target
+ Note over LKS: unwrap room key → LiveKit
ExternalE2EEKeyProvider
+ Note over LKS: on participant leave,
key holder rotates room key
+ end
+```
+
+**What this shows.** The server never holds the room key — it only relays
+announce/offer messages and tracks who the key holder is (deterministically the
+lowest user ID in the channel). Keys are wrapped per-recipient via ECDH, and
+the key holder rotates the room key when a participant leaves so departed
+members cannot decrypt future media. Voice permission enforcement happens twice:
+at `voice_join` (channel permission) and inside the LiveKit JWT itself
+(`CanPublishSources` restricts camera/screenshare per role permission).
+
+Supporting pieces:
+
+- **Server:** `Server/ws/voice_e2ee.go` (relay + key-holder map),
+ `Server/ws/livekit.go` (token minting), `Server/ws/livekit_process.go`
+ (optional managed `livekit-server` subprocess),
+ `Server/ws/livekit_webhook.go` (webhook validated by LiveKit JWT and
+ admin-IP-restricted), `Server/api/livekit_proxy.go` (HTTP reverse proxy).
+- **Client:** `src/lib/livekitSession.ts` (state machine: idle/connecting/
+ connected/reconnecting with a monotonic `joinGeneration` to discard
+ superseded joins), `src/lib/e2eeCrypto.ts` (ECDH, key wrap/unwrap),
+ `src/lib/audioPipeline.ts` + `src/lib/noise-suppression.ts` (RNNoise WASM),
+ `src/lib/screenShare.ts`, `src-tauri/src/livekit_proxy.rs` (tunnel),
+ `src-tauri/src/ptt.rs` (push-to-talk key polling).
+
+This message flow (`voice_e2ee_announce` / `voice_e2ee_offer` /
+`voice_speakers`) is currently **absent from `docs/protocol.md`** — recorded as
+spec drift in [audit-2026-07-19.md §2](../audit-2026-07-19.md).
+
+**Source of truth:** `Server/ws/voice_e2ee.go`, `Server/ws/livekit.go`,
+`Client/tauri-client/src/lib/livekitSession.ts`,
+`Client/tauri-client/src/lib/e2eeCrypto.ts`,
+`Client/tauri-client/src-tauri/src/livekit_proxy.rs`.
diff --git a/docs/architecture/websocket.md b/docs/architecture/websocket.md
new file mode 100644
index 00000000..db148bd9
--- /dev/null
+++ b/docs/architecture/websocket.md
@@ -0,0 +1,114 @@
+# WebSocket / Real-time Engine
+
+**Verified against:** commit `ddc49f0`, 2026-07-19
+
+The `Server/ws` package (~6.9k LOC, the largest in the server) implements the
+real-time engine: a single `Hub` owning all client connections, a topic-based
+pub/sub, a monotonic sequence counter, a 3-tier reconnect replay pipeline, and a
+dual (V1 legacy + V2 typed) command dispatch. Message-type constants live in
+`Server/ws/message_types.go` (client↔server) and mirror
+`Client/tauri-client/src/lib/protocolTypes.ts`.
+
+> Both files claim to be "Generated from docs/protocol-schema.json — single
+> source of truth", but **no such file exists in the repository** — the
+> constants are maintained by hand on both sides. See
+> [audit-2026-07-19.md §3](../audit-2026-07-19.md).
+
+## D4a — Connect, authenticate, replay
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant C as Client (ws.ts via Rust ws_proxy)
+ participant S as ws.ServeWS
+ participant H as Hub
+
+ C->>S: WSS upgrade /api/v1/ws (Origin checked)
+ Note over S: no HTTP AuthMiddleware —
auth is in-band, 10s deadline
+ C->>S: {type:"auth", payload:{token, last_seq}}
+ S->>S: validate token hash → session expiry → user → ban
+ S->>H: register (kicks previous conn of same user)
+ S-->>C: auth_ok {user, server_name, motd, replay_source}
+
+ alt last_seq within in-memory ring buffer (Tier 1)
+ H-->>C: replay EventsSinceFiltered (perm-filtered, fail-closed)
+ else last_seq within events table (Tier 2, max 5000)
+ H-->>C: replay from cold-tier EventStore
+ else too far behind, or channel visibility changed (Tier 3)
+ H-->>C: full "ready" re-sync snapshot
+ end
+
+ loop steady state
+ C->>H: chat_send / reaction_add / voice_join / …
+ H-->>C: seq-stamped broadcasts (chat_message, presence, …)
+ C->>S: ping (every 30s) → pong
+ end
+```
+
+**What this shows.** Auth is deliberately in-band (the WS route mounts without
+`AuthMiddleware`). Every broadcast is assigned a monotonic `seq` under a
+dedicated mutex; the client reports its `last_seq` on reconnect and the hub
+picks the cheapest replay tier. A `visibilityChangeSeq` watermark forces a full
+re-sync whenever channel visibility changed while the client was away, so
+permission changes can never be replayed around. `auth_ok.replay_source`
+(`none|buffer|db`) reports which tier served the reconnect and feeds the
+`ws_reconnect_tier_total` metric.
+
+## D4b — Broadcast fanout and backpressure
+
+```mermaid
+flowchart LR
+ EV["deliverBroadcast
assign seq"] --> RB["EventRingBuffer
(1000, Tier 1)"]
+ EV --> EP["EventPersister
async batched → events table
(Tier 2; drops if queue full)"]
+ EV --> PLG["plugin EventSink"]
+ EV --> PS["PubSub topics
global / channel:N / voice:N / user:N
(per-topic 100 msg/s limit)"]
+ PS --> CH{"per-client queues"}
+ CH --> HI["sendHigh (64)
DMs, mentions"]
+ CH --> NO["send (256)
chat, reactions"]
+ CH --> LO["sendLow (64)
typing, presence"]
+ HI --> WP["writePump
drains high-first"]
+ NO --> WP
+ LO --> WP
+ WP -->|"high/normal full →
disconnect (forces replay)"| X["client"]
+ LO -.->|"low full → silently dropped"| X
+```
+
+**What this shows.** Overflow policy is intentional: dropping a chat message
+would corrupt state, so a full normal/high queue disconnects the client and the
+replay pipeline restores consistency; typing/presence are lossy by design. The
+global `broadcast` channel (1024) drops with a `broadcastDrops` counter when
+saturated.
+
+## D4c — Dual dispatch (V1 → V2 strangler-fig)
+
+```mermaid
+stateDiagram-v2
+ [*] --> handleMessage
+ handleMessage --> V2: registry.hasV2(type)
+ handleMessage --> V1: no V2 handler
+ V2: V2 typed command path
+ V2: strict parse → Command → Result{mutations, events}
+ V1: V1 legacy path
+ V1: lenient parse → registry.Dispatch → imperative handler
+ V2 --> [*]
+ V1 --> [*]
+```
+
+**What this shows.** Both dispatch generations are registered in `NewHub` and
+live simultaneously; each message type is tried against the stricter V2 typed
+path first and falls back to V1. Two parsers and two handler registries must be
+kept in sync until the migration completes — tracked as an audit finding.
+
+The `Hub` also owns: stale-client sweep (90s), revoked-session sweep (30s, plus
+per-connection revalidation every 10 messages), stale-voice-state sweep (60s),
+panic containment on the run loop (3 panics/60s → stop), LiveKit client and
+optional managed subprocess, and the voice E2EE key-holder map
+([voice-e2ee.md](voice-e2ee.md)). Many collaborators are attached
+post-construction via `SetLiveKit` / `SetEventPersister` /
+`SetPluginRegistry` setters that "must be called before Run" — temporal
+coupling noted in the audit.
+
+**Source of truth:** `Server/ws/hub.go`, `Server/ws/serve.go`,
+`Server/ws/client.go`, `Server/ws/handlers.go`, `Server/ws/command.go`,
+`Server/ws/pubsub.go`, `Server/ws/ringbuffer.go`, `Server/ws/event_persister.go`,
+`Server/ws/message_types.go`, `Server/migrations/014_events_table.sql`.
diff --git a/docs/audit-2026-07-19.md b/docs/audit-2026-07-19.md
new file mode 100644
index 00000000..f5a9bd35
--- /dev/null
+++ b/docs/audit-2026-07-19.md
@@ -0,0 +1,177 @@
+# OwnCord — Architectural Audit & Spec-Conformance Review
+**Date:** 2026-07-19
+**Branch:** claude/blueprints-architectural-audit-k927qb (audited tree: `ddc49f0` = main)
+**Scope:** whole-system architecture mapping + code-by-spec conformance. Read-only — no code or spec changes ship with this audit; the companion blueprint set lives in [docs/architecture/](architecture/README.md).
+**Relationship to prior audit:** successor to [audit-2026-04-07.md](audit-2026-04-07.md), which remains the closure tracker for its own findings. Carried-over items are re-verified in §1, not restated.
+
+---
+
+## Finding closure status (maintained; update statuses in place)
+
+Standing rule: every HIGH below gets either a closing commit link or an explicit
+accepted-risk note before the beta gate. MEDIUMs are folded into the backlog
+(§6) and closed opportunistically.
+
+| ID | Sev | Finding | Status |
+|----|-----|---------|--------|
+| A-2026-07-01 | HIGH | `announcement` channel type: documented in 3 specs and offered by the admin API, but hard-rejected by DB triggers | OPEN — needs a product decision (implement or remove) |
+| A-2026-07-02 | HIGH | Client HTTP path accepts any TLS certificate (`allowSelfSigned` hardcoded; no TOFU pinning, unlike WS/LiveKit paths) | OPEN — acknowledged in code comments |
+| A-2026-07-03 | HIGH | Reference specs (api.md / protocol.md / schema.md) frozen at 2026-04-02; systemic drift incl. whole undocumented subsystems (voice E2EE, plugins) | OPEN — refresh proposed as one PR (§6 item 4) |
+| A-2026-07-04 | HIGH | Client unit test suite "KNOWN RED" and non-blocking in CI; E2E never gated | OPEN — supersedes prior #11's scope |
+| A-2026-07-05 | MEDIUM | Dead sqlc layer: `Server/db/dbgen/` (~3.5k LOC) generated + CI-verified but imported by nothing | OPEN |
+| A-2026-07-06 | MEDIUM | Three coexisting DB-access styles (raw `*db.DB` in api/admin/ws, `store.Store` under service, dead dbgen) | OPEN |
+| A-2026-07-07 | MEDIUM | Channel-visibility logic duplicated across ~4 sites with "must mirror" comments | OPEN |
+| A-2026-07-08 | MEDIUM | Protocol constants on both sides claim generation from `docs/protocol-schema.json`, which does not exist in the repo | OPEN |
+| A-2026-07-09 | MEDIUM | Dual V1+V2 WS dispatch (strangler-fig) still live; two parsers/registries to keep in sync | OPEN |
+| A-2026-07-10 | MEDIUM | `api.NewRouter` god-constructor: builds services, hub, LiveKit, updater, admin, plugins; spawns goroutines; mounts everything | OPEN |
+| A-2026-07-11 | MEDIUM | `ws.Hub` mega-object with post-construction `Set*` wiring ("must be called before Run") | OPEN |
+| A-2026-07-12 | MEDIUM | Abandoned SolidJS beachhead still in-tree; `docs/client-architecture.md` describes the abandoned architecture | OPEN |
+| A-2026-07-13 | LOW | Dead schema: `sounds` table survives soundboard removal; `audit_log` + `audit_log_v6` coexist | OPEN |
+| A-2026-07-14 | LOW | Scattered client constants (`#5865F2` ×18, `localhost:8443` ×3); 64 timer call sites with manual lifecycle | OPEN |
+| A-2026-07-15 | LOW | `docs/plans/security-hardening-remediation.md` partly stale (references deleted `store/postgres.go`) | OPEN |
+
+---
+
+## Table of Contents
+1. [Carried-over items from audit-2026-04-07](#1-carried-over-items-from-audit-2026-04-07)
+2. [Spec-conformance matrix](#2-spec-conformance-matrix)
+3. [Server architecture findings](#3-server-architecture-findings)
+4. [Client architecture findings](#4-client-architecture-findings)
+5. [Process & CI findings](#5-process--ci-findings)
+6. [Prioritized improvement backlog](#6-prioritized-improvement-backlog)
+
+---
+
+## 1. Carried-over items from audit-2026-04-07
+
+Re-verified in today's tree. Statuses below reflect the code, not the prior
+audit's table; details stay in [audit-2026-04-07.md](audit-2026-04-07.md).
+
+| Prior # | Sev | Finding (one-line) | Re-verification (2026-07-19) |
+|---------|-----|--------------------|------------------------------|
+| 1–5 | CRITICAL | Plugin governance (timeout, storage isolation, ACL, event rate limit, HTTP exfiltration) | Unchanged since prior closure table; plugins still default-disabled (`plugins.enabled: false`), which is the standing mitigation |
+| 6 | HIGH | `Server/store/` untested | **Still zero `_test.go` files** in `Server/store/`. The "SUPERSEDED — remove in P4" plan has not executed; the package remains the designated abstraction seam with no direct tests |
+| 7 | HIGH | Client unit coverage | Suite is large (157 test files) but currently KNOWN RED and non-blocking — see A-2026-07-04 |
+| 9 | MEDIUM | auth_handler bypasses service layer | **Confirmed open** — `Server/api/router.go:101` passes `database *db.DB` to `MountAuthRoutes` while sibling mounts receive `svc` |
+| 10 | MEDIUM | Audit-trail write failures silently ignored | **Confirmed open** — `Server/admin/handlers_backup.go:55` and `:139` still discard the error: `_ = database.LogAudit(...)` |
+| 11 | MEDIUM | E2E not in CI | **Confirmed open** — no Playwright job exists in `.github/workflows/ci.yml` |
+| W3-4 (remediation plan) | LOW | Contradictory upload cache header | **Confirmed open** — `Server/api/upload_handler.go:309` sets `private, max-age=%d, no-cache` (max-age and no-cache contradict) |
+
+---
+
+## 2. Spec-conformance matrix
+
+The three reference specs were last meaningfully edited **2026-04-02**
+(`git log` on each file); the code is at v1.1.0-alpha.2 with substantial July
+2026 changes. Resolution column: **fix-spec** (doc catches up to code),
+**fix-code** (code is wrong), **decide** (product decision needed first).
+
+### 2.1 `docs/api.md`
+
+| ID | Spec says | Code does | Evidence | Sev | Resolution |
+|----|-----------|-----------|----------|-----|------------|
+| B | `GET /api/v1/info` returns `{name, version}` | `version` removed (anti-fingerprinting, "C-2") | `Server/api/router.go` `infoResponse{Name}` only | MEDIUM | fix-spec |
+| C | `GET /health` returns `version` | `version` removed for the same reason | `Server/api/router.go` `healthResponse{Status, Uptime, OnlineUsers}` | MEDIUM | fix-spec |
+| F | — (absent) | Profile surface exists: `PATCH /api/v1/users/me`, `PUT /users/me/password`, `GET`/`DELETE /users/me/sessions` | `Server/api/profile_handler.go` (`MountProfileRoutes`) | MEDIUM | fix-spec |
+| G | — (absent) | Plugin admin REST surface: `/api/v1/admin/plugins` (list/enable/disable/uninstall/install-zip) | `Server/api/router.go:250` | MEDIUM | fix-spec |
+
+### 2.2 `docs/protocol.md`
+
+| ID | Spec says | Code does | Evidence | Sev | Resolution |
+|----|-----------|-----------|----------|-----|------------|
+| D1 | — (voice E2EE absent) | Full E2EE signaling: client `voice_e2ee_announce`/`voice_e2ee_offer`, server broadcast/relay of both | `Server/ws/message_types.go`, `Server/ws/voice_e2ee.go`; flow in [architecture/voice-e2ee.md](architecture/voice-e2ee.md) | HIGH | fix-spec |
+| D2 | — (absent) | `voice_speakers` server event | `Server/ws/message_types.go` (`MsgTypeVoiceSpeakers`) | LOW | fix-spec |
+| D3 | — (absent) | `user_update` broadcast on profile change | `Server/ws/message_types.go`; wired via `MountProfileRoutes` broadcaster | LOW | fix-spec |
+| D4 | `member_leave` appears only in the seq table | Fully implemented broadcast | `Server/ws/message_types.go` (`MsgTypeMemberLeave`) | LOW | fix-spec |
+| E | `auth_ok` payload = `{user, server_name, motd}` | Also carries `replay_source` (`none\|buffer\|db`) | `Server/ws/serve.go` auth path; CHANGELOG Phase B | LOW | fix-spec |
+
+### 2.3 `docs/schema.md`
+
+| ID | Spec says | Code does | Evidence | Sev | Resolution |
+|----|-----------|-----------|----------|-----|------------|
+| A | Channel types: `text, voice, announcement, dm` (also in api.md and protocol.md; admin API offers `announcement`) | DB triggers **reject** any type outside `text\|voice\|dm` | `Server/migrations/013_channel_type_constraint.sql` vs `Server/admin/handlers_channels.go` | **HIGH** | **decide** — either implement announcement channels end-to-end or remove the type from specs + admin API |
+| H | Migration history stops at "008", with wrong numbering (two `003_*` entries; DM tables labeled 008) | 15 migrations exist, `001`–`015`; real order diverges from 004 onward | `Server/migrations/` directory listing | MEDIUM | fix-spec |
+| I | — (absent) | 9 tables undocumented: `login_attempts`, `settings`, `emoji`, `sounds`, `rate_lockouts`, `user_blocks`, `events`, `plugins`, `plugin_kv` | `Server/migrations/001,011,012,014,015` — see [architecture/data-model.md](architecture/data-model.md) | MEDIUM | fix-spec |
+| J | attachments DDL without `uploader_id` | Column added for upload-ownership checks | `Server/migrations/010_attachment_uploader.sql` | MEDIUM | fix-spec |
+| K | — (sqlc unmentioned) | `sqlc.yaml` + `Server/db/dbgen/` exist (currently dead — A-2026-07-05) | `sqlc.yaml`, `Server/db/dbgen/` | LOW | fix-spec (document whichever way A-2026-07-05 resolves) |
+
+**Systemic conclusion:** drift is not isolated typos — entire subsystems
+(E2EE signaling, plugins, six migrations) postdate the specs. Item A is the
+only case where *code and spec actively contradict at runtime*: an admin can
+select a channel type the database will refuse to insert. Recommended handling:
+one coherent spec-refresh PR (backlog item 4) rather than piecemeal edits, plus
+a decision on A first.
+
+---
+
+## 3. Server architecture findings
+
+Verdict: the intended layering (`api → service → store → db`) is sound and the
+`service` layer respects it; test discipline is excellent (test LOC ≈ 1.6×
+source; race + deadlock + mutation tooling). The findings are about the seams
+that grew around that design.
+
+| ID | Sev | Area | Evidence | Finding | Recommendation | Effort |
+|----|-----|------|----------|---------|----------------|--------|
+| A-2026-07-05 | MEDIUM | Data layer | `Server/db/dbgen/` (~3.5k LOC), `sqlc.yaml`, CI `sqlc-verify` job | sqlc output is generated, version-pinned, CI-verified — and imported by nothing. Hand-written raw SQL in `Server/db/*_queries.go` is what runs. | Decide the Phase-A question: adopt dbgen inside `db.DB` method bodies, or delete `dbgen/` + `queries/` + the CI job. Either ends the illusion of a second data layer. | S |
+| A-2026-07-06 | MEDIUM | Layering | All `Mount*Routes` signatures take `database *db.DB` alongside `svc` (`Server/api/*_handler.go`); `Server/admin` handlers take `*db.DB` | ~359 direct `database.*` calls above the store seam; three access styles coexist. The abstraction exists but cannot be relied on (e.g. for a future backend swap or for test doubles). | Consolidate incrementally: new handlers service-only; migrate one mount per PR, starting with auth (prior #9). | L |
+| A-2026-07-07 | MEDIUM | Correctness risk | `Server/ws/serve.go` (`buildReady`, `computeAllowedChannels`), `Server/ws/hub.go` (`RefreshChannelVisibility`), REST `handleListChannels` | Channel-visibility filtering implemented ~4× with comments instructing they "must mirror" each other. The recent private-channel fixes (e.g. `2bfe6d6`) show this is actively churning — a drift between copies is an information-disclosure bug waiting to happen. | Extract a single `VisibleChannels(userID)` (natural home: `service.PermissionService` or the existing `permissions.Checker`), consume it from all four sites, and add a test asserting REST and WS agree. | M |
+| A-2026-07-08 | MEDIUM | Protocol integrity | `Server/ws/message_types.go` header comment; `Client/…/src/lib/protocolTypes.ts` header + "Extensions (not in protocol-schema.json…)" comments | Both sides claim `docs/protocol-schema.json` is the generated single source of truth. The file does not exist; the two constant sets are maintained by hand and have already grown divergent "extension" entries. | Either commit a real `protocol-schema.json` + generator (best: also emits protocol.md tables), or delete the claim and add a cross-language equality test over the two constant sets. | M |
+| A-2026-07-09 | MEDIUM | Real-time | `Server/ws/handlers.go` (`handleMessage` V2-then-V1 fallback), dual registration in `NewHub` (`Server/ws/hub.go`) | Strangler-fig V1+V2 dispatch is live: two parsers (lenient/strict), two registries, per-type duplication. | Finish the migration: port remaining V1 types to V2, then delete the V1 path. Track remaining types in an issue so the count visibly shrinks. | M/L |
+| A-2026-07-10 | MEDIUM | Composition | `Server/api/router.go:34` (`NewRouter`, ~278 lines) | God-constructor builds rate limiter, TOTP key, storage, services, hub, LiveKit client+process, updater, admin + plugin handlers; spawns goroutines; returns a cleanup closure covering only one of them. Hard to test wiring in isolation; lifecycle ownership is implicit. | Split construction (a `Deps`/`App` struct built in `main.go`) from route mounting (`NewRouter(deps)`); return a composite `io.Closer`. | M |
+| A-2026-07-11 | MEDIUM | Real-time | `Server/ws/hub.go` (`SetLiveKit`, `SetEventPersister`, `SetPluginRegistry`, …) | Hub is a mega-object wired post-construction via setters that "must be called before Run" — temporal coupling; a missed setter is a nil-deref at runtime, not a compile error. | Move required collaborators into `NewHub` params (or an options struct validated before `Run`). Full Hub decomposition is a separate, larger effort (backlog 12). | S (constructor) / L (decomposition) |
+| — | MEDIUM | Layering | `Server/ws/hub.go:182` (`refreshSettingsLocked`) | Hub runs inline `SELECT value FROM settings WHERE key='server_name'` instead of using `SettingsStore` — the only raw SQL in the real-time layer. | Route through the store; folds into A-2026-07-06 but is a 20-minute standalone fix. | S |
+| — | LOW | Scaling posture | `Server/auth/ratelimit.go` (documented), in-memory pub/sub + ring buffer, process-local TOTP replay | Single-instance coupling is structural and *documented* — this is a deliberate design, not a bug. Recorded here so the constraint stays visible ([architecture/system-overview.md D8](architecture/system-overview.md)). | No action now; revisit only if multi-instance ever becomes a goal. | — |
+| A-2026-07-13 | LOW | Schema hygiene | `sounds` table (`Server/migrations/001`), `audit_log` + `audit_log_v6` (`003`) | Dead/duplicated schema: soundboard was removed but its table remains; two audit-log tables coexist after the 003 rebuild. | Add a cleanup migration (drop `sounds`, finish the audit_log consolidation) next time a migration ships anyway. | S |
+
+## 4. Client architecture findings
+
+Verdict: the client's fundamentals are strong — immutable store discipline,
+TOFU pinning in Rust, keychain credentials with IPC redaction, generation
+counters against stale listeners, and an unusually deep test/tooling stack
+(Vitest, dual Playwright suites, Stryker, oxlint + type-checked ESLint, Knip).
+Findings target structure and one security gap.
+
+| ID | Sev | Area | Evidence | Finding | Recommendation | Effort |
+|----|-----|------|----------|---------|----------------|--------|
+| A-2026-07-02 | HIGH | Security | `src/main.ts` (`allowSelfSigned: true` at API-client construction); `src-tauri` http plugin built with `dangerous-settings` | Every REST call accepts any certificate. The WS and LiveKit paths pin TOFU fingerprints in Rust; the HTTP path — which carries the auth token on every request — does not. An active MITM can capture tokens without triggering the cert-mismatch modal. | Implement the acknowledged fix: a TOFU HTTP proxy in Rust (mirror `ws_proxy.rs`), or route REST through a pinned Rust command. Until then this is the client's weakest transport link. | M |
+| A-2026-07-12 | MEDIUM | Coherence | `src/components/solid/` (154 LOC), `src/lib/solidMount.ts`, `src/lib/solidAdapter.ts`, `vite-plugin-solid` config; CHANGELOG "Solid.js migration (abandoned)" | The abandoned migration's beachhead, adapters, build plugin, and test deps remain, and `docs/client-architecture.md` (2026-03-30) still describes a SolidJS client. Two mental models for contributors, one of them false. | Delete the beachhead + adapters + build plugin; replace `client-architecture.md` content with a pointer to [architecture/client.md](architecture/client.md) or a rewrite. | S |
+| — | MEDIUM | Maintainability | `src/lib/livekitSession.ts` (1,719 LOC); `AccountTab.ts` (845), `SidebarArea.ts` (812), `LoginForm.ts` (699) | `livekitSession.ts` owns the connection state machine, E2EE, track management, reconnection, and diagnostics in one class. It is the highest-risk file to modify in the client. | Extract E2EE (already has `e2eeCrypto.ts` as a seam) and track management into collaborators; keep the state machine as the core. Settle for splitting the settings tabs opportunistically. | M |
+| — | MEDIUM | State | `src/stores/voice.store.ts` imports members/auth stores; `auth.store.clearAuth()` reaches into `leaveVoice()` + notification cleanup; business logic in `main.ts` subscribers | Cross-store singleton coupling: teardown ordering lives implicitly in import graphs and bootstrap subscribers. | Introduce a thin session-lifecycle module (login/logout orchestration) that calls stores, so stores stop calling each other. | M |
+| A-2026-07-14 | LOW | Hygiene | `#5865F2` literal ×18 across 11 files (`main.ts`, `ServerPanel.ts`, `DmSidebar.ts`, `MemberPickerModal.ts`, `SidebarArea.ts`, …); `localhost:8443` ×3; 64 `setTimeout`/`setInterval` sites; 12 `innerHTML` uses | Scattered magic values and manual timer lifecycles; `src/lib/constants.ts` holds a single constant. | Centralize into constants/tokens; adopt a tiny `managedTimer(destroyScope)` helper so `destroy()` paths can't leak intervals. | S |
+| — | LOW | Error handling | `catch {}` swallows in `preferences.ts`, `ws.ts` unsubscribe cleanup, `disconnectProxy`; widespread `void`-prefixed fire-and-forget | Mostly deliberate (per lint config), but a handful of swallows hide real failures (e.g. preference persistence silently failing). | Log-at-debug in the swallow sites; keep the pattern otherwise. | S |
+
+## 5. Process & CI findings
+
+| ID | Sev | Evidence | Finding | Recommendation | Effort |
+|----|-----|----------|---------|----------------|--------|
+| A-2026-07-04 | HIGH | `.github/workflows/ci.yml` — `client-tests` job annotated "KNOWN RED pending reboot-plan P2 triage"; no Playwright job | The Go side is gated hard (race, deadlock tag, govulncheck, golangci-lint, sqlc-verify, 4-tag build matrix) but the client's 157-file test suite is red and non-blocking, and E2E never runs in CI. For an AI-first workflow where "quality [is] validated primarily through automated checks" (README), the client half of that promise is currently unenforced. | Triage the red suite to green, flip `client-tests` to blocking, then add at least the web Playwright suite as a nightly non-blocking job (prior #11) before promoting it to a gate. | M |
+| A-2026-07-03 | HIGH | `git log` on `docs/api.md`, `protocol.md`, `schema.md` (all 2026-04-02) vs code churn through 2026-07-19 | No process keeps the reference specs current — the July burst (events table, plugins, E2EE, private channels) shipped without touching them. | After the one-time refresh (backlog 4), add a PR-checklist line (mirroring the blueprint maintenance rule in [architecture/README.md](architecture/README.md)): protocol/API/schema changes update the matching spec in the same PR. | S |
+| A-2026-07-15 | LOW | `docs/plans/security-hardening-remediation.md` W1-3/W3-5 reference `Server/store/postgres.go` (deleted) | The remediation plan predates the Postgres removal; two waves partially target dead code. | Annotate the affected items rather than rewriting the plan. | S |
+| — | LOW | `ci.yml` `tauri-build` job: `if: github.event_name == 'pull_request' && github.base_ref == 'main'` | Full client build (incl. Clippy `-D warnings`, cargo audit) never runs on push to main — a merge that breaks the native build is caught only at the next PR. | Add a push-to-main trigger for `tauri-build` (or a nightly). | S |
+
+## 6. Prioritized improvement backlog
+
+Ranked by severity × effort; quick wins float within tier. S/M/L ≈ hours / days / week+.
+
+| # | Item | Finding | Sev | Effort |
+|---|------|---------|-----|--------|
+| 1 | Decide + resolve the `announcement` channel-type contradiction (implement or strip from specs/admin) | A-2026-07-01 | HIGH | S (decision) |
+| 2 | Delete (or finally adopt) the dead `db/dbgen` sqlc layer; adjust the `sqlc-verify` CI job to match | A-2026-07-05 | MEDIUM | S |
+| 3 | Extract the single channel-visibility function replacing the 4 "must mirror" copies; add REST/WS agreement test | A-2026-07-07 | MEDIUM | M |
+| 4 | One-PR refresh of api.md / protocol.md / schema.md against §2, incl. E2EE + plugins + migrations 009–015; then enforce spec-updates-with-code via PR checklist | A-2026-07-03 | HIGH | M |
+| 5 | Execute the store-layer decision from prior #6: remove `Server/store/` (P4 plan) or test it directly | prior #6 | HIGH | M |
+| 6 | Client HTTP TOFU pinning (Rust proxy mirroring `ws_proxy.rs`) | A-2026-07-02 | HIGH | M |
+| 7 | Stop discarding `LogAudit` errors in `admin/handlers_backup.go`; fix the contradictory upload `Cache-Control` | prior #10, W3-4 | MEDIUM | S |
+| 8 | Remove the SolidJS beachhead + adapters; retire or rewrite `docs/client-architecture.md` | A-2026-07-12 | MEDIUM | S |
+| 9 | Resolve the `protocol-schema.json` ghost: real codegen or an equality test between Go and TS constant sets | A-2026-07-08 | MEDIUM | M |
+| 10 | Green + blocking client unit suite; nightly Playwright | A-2026-07-04 | HIGH | M |
+| 11 | Finish the V2 dispatch migration; delete V1 | A-2026-07-09 | MEDIUM | M/L |
+| 12 | Consolidate DB access behind the service layer (start with auth routes, prior #9); then Hub constructor cleanup and decomposition | A-2026-07-06, -10, -11 | MEDIUM | L |
+
+---
+
+*Blueprints referenced throughout live in [docs/architecture/](architecture/README.md);
+update a diagram and its doc in the same PR as any structural change to its
+source-of-truth files. Line-number evidence in this report is a snapshot of
+commit `ddc49f0`.*