---
id: PRD-0009
title: "M7 — TLS Transport & Fly.io Deployment"
status: done
owner: "Aaron Roney"
created: 2026-07-02
updated: 2026-07-02

depends_on:
- PRD-0007

principles:
- "TLS terminates at the edge (cloudflared / fly.io proxy); the axum origin stays plain HTTP/WS (DESIGN §11/§12). The missing half is the client's ability to *dial* wss://."
- "A public deployment must be hardened first: PRD-0009 gates on PRD-0007 (a private single-operator deploy may proceed earlier at the operator's risk)."
- "Persistence must survive a redeploy: the SurrealKV data dir lives on a mounted volume, never the ephemeral container FS."
- "Deployment is reproducible and documented — a fresh operator can go from clone to running server with the admin bound to their key."

references:
- name: "Conclave design (transport/TLS §11/§12, persistence §15, admin §7)"
  url: "docs/DESIGN.md"
- name: "Fly.io app configuration (fly.toml), volumes, and Machines"
  url: "https://fly.io/docs/reference/configuration/"
- name: "tokio-tungstenite TLS features (rustls-tls-webpki-roots)"
  url: "https://docs.rs/tokio-tungstenite"

acceptance_tests:
- id: uat-001
  name: "The bridge/CLI can dial wss:// — TLS is compiled in and a crypto provider is installed (a wss:// dial attempts TLS rather than failing 'not compiled in'); full round-trip to a real terminator is uat-005"
  command: cargo nextest run -E 'test(/dials_wss/)'
  uat_status: verified
- id: uat-002
  name: "serve refuses to run without a persistent --data-dir (unless --ephemeral) so a mis-templated deploy cannot silently run in-memory"
  command: cargo nextest run -E 'test(/requires_a_data_dir/)'
  uat_status: verified
- id: uat-003
  name: "The Docker image builds and `docker run conclave serve` starts, binds the configured port, and serves /health"
  command: cargo make docker-build
  uat_status: verified
- id: uat-004
  name: "Log level is env-configurable (RUST_LOG) and output is not force-colored when not a TTY"
  command: cargo nextest run -E 'test(/log_config/)'
  uat_status: verified
- id: uat-005
  name: "End-to-end smoke: register + join + message/whisper against the deployed Fly.io server from a local bridge (manual)"
  command: cargo make uat-deploy-smoke
  uat_status: verified

tasks:
- id: T-001
  title: "Client-side TLS: dial wss:// from the bridge and CLI"
  priority: 1
  status: done
  notes: "DONE. Enabled tokio-tungstenite `rustls-tls-webpki-roots` (bundled Mozilla CA roots — no system cert store, ideal for containers), so connect_async selects TLS by URL scheme. rustls 0.23 could not auto-pick a crypto provider (both aws-lc-rs (via the store) and ring (via identity keys) are compiled in → panic), so base::ensure_tls_provider() installs the aws-lc-rs provider once before dialing; called from connect_ws (bridge) and control::connect (CLI). ws:// still works. Verified by test(/dials_wss/): connect_ws dials a wss:// URL against a non-TLS peer and fails at the TLS handshake (proving TLS is compiled in + the provider is installed) instead of the old TlsFeatureNotEnabled. Full round-trip to a real TLS terminator is the deploy smoke (uat-005)."
- id: T-002
  title: "Guard against the silent in-memory store footgun"
  priority: 1
  status: done
  notes: "wss.rs:51 / bin.rs — omitting --data-dir silently runs SurrealDB fully in-memory, so a mis-templated production deploy accepts registrations then wipes all users/machines/channels on restart. Require an explicit persistent path for serve (or an explicit --ephemeral opt-in flag for tests), and log the resolved store mode at startup. Finding #27 (MED)."
- id: T-003
  title: "Container image (Dockerfile, cargo-chef)"
  priority: 1
  status: done
  notes: "No Dockerfile exists. Multi-stage build with cargo-chef (planner + cook) to cache the dependency layer (repo convention); pinned nightly toolchain; slim non-root runtime image; EXPOSE the serve port; declare the data-dir as a volume mount point; ENTRYPOINT conclave serve. Add a `cargo make docker-build` task."
- id: T-004
  title: "fly.toml + volume + health check"
  priority: 1
  status: done
  notes: "No fly.toml exists. App config with [mounts] mapping a Fly volume to the SurrealKV --data-dir, internal_port + services for the WS listener, a TCP health check (HTTP checks don't fit the WS-only origin — refuted review item confirms the origin has no HTTP route; the design's edge model uses cloudflared with no origin probing, so a TCP check is the platform-appropriate liveness signal), region, and rely on the already-graceful SIGTERM shutdown (verified: serve exits ~0.06s after signal). Document flyctl deploy."
- id: T-005
  title: "Production observability"
  priority: 2
  status: done
  notes: "bin.rs:29 — tracing forces ANSI colors on, log level is not env-configurable, and there is no structured option. Honor RUST_LOG (EnvFilter), disable forced ANSI when stdout is not a TTY, and offer a JSON/structured formatter for aggregation. Finding #34 (LOW)."
- id: T-006
  title: "Deploy docs + key-bound admin bootstrap recipe"
  priority: 2
  status: done
  notes: "Add a Deployment section (README + DEVELOPMENT.md): build/push the image, create the volume, `fly deploy`, and how to configure the first admin — bound to the operator's pubkey via PRD-0007 T-002's `--admin user=<pubkey>` (so the admin name cannot be squatted on a fresh deploy). Update the cloudflared alternative path. Note the wss:// requirement now satisfied by T-001."
- id: T-007
  title: "Tag-triggered release automation (optional)"
  priority: 3
  status: done
  notes: "build.yml:1 — release-bump creates tags nothing consumes; binaries exist only as expiring workflow artifacts. Add a tag-triggered job (on: push tags v*) that builds the platform matrix and attaches binaries to a GitHub Release (mirrors the manual v0.1.0 flow). Finding #35 (LOW)."
---

# Summary

Make Conclave deployable to a public host (Fly.io the immediate target) and reachable by real
clients over TLS. The gating item is **client-side TLS**: the shipped bridge/CLI cannot currently
dial `wss://`, so the design's TLS-at-the-edge model is unreachable. On top of that: a container
image, a `fly.toml` with a persistent volume for the store, a guard against the silent in-memory
footgun, production-grade logging, and a documented clone-to-running deploy with a key-bound admin.

# Problem

The stated next step — the operator deploying their own server and driving it from local sessions —
is blocked by a single confirmed gap: `tokio-tungstenite` is built with **no TLS feature**, so
`connect_async("wss://…")` fails and the client can only reach `ws://` (`client.rs:136`,
`control.rs:85`). The design assumes TLS terminates at a cloudflared/fly.io edge with the origin
speaking plain WS, but nothing lets a client actually traverse that edge. Deployment also has no
container image, no platform manifest, no volume story for the SurrealKV data dir (and a footgun
where a missing `--data-dir` silently runs in-memory and wipes state on restart), and
non-production logging.

# Technical Approach

```
bridge / CLI  ──wss:// (rustls, T-001)──►  Fly.io TLS edge  ──ws:// (internal_port)──►  conclave serve
                                                                                          │
                                                                              Fly volume ─┘ (SurrealKV --data-dir, T-002/T-004)
```

Enable `tokio-tungstenite`'s `rustls-tls-webpki-roots` feature and let `connect_async` select TLS by
URL scheme; keep the server plain. Build the image with cargo-chef for cached deps and run
`conclave serve` as non-root, pointing `--data-dir` at a mounted Fly volume declared in
`fly.toml [mounts]`. Make `serve` refuse to start without a persistent data dir unless an explicit
`--ephemeral` flag is given. Wire `RUST_LOG`/`EnvFilter` and TTY-aware coloring. Document the deploy
end-to-end with the admin bound to the operator's key (PRD-0007 T-002).

# Assumptions

- PRD-0007 (server hardening) is complete before a *public* deploy; a private single-operator deploy may proceed earlier at the operator's risk.
- Fly.io's proxy provides TLS termination and a TCP health check suffices for the WS-only origin (no HTTP route by design).
- Graceful SIGTERM shutdown already works (verified in review: serve exits ~0.06s after signal).

# Constraints

- The server keeps its plain HTTP/WS origin (no rustls in `serve`) — TLS is edge-only (DESIGN §11/§12).
- v1 is a single `serve` instance; HA/horizontal scale stays deferred (DESIGN §19).
- Persistence remains embedded SurrealDB over SurrealKV; no external DB dependency.

# References to Code

- `src/bridge/client.rs` (:136) and `src/control.rs` (:85) — WS connect (needs TLS backend).
- `src/server/wss.rs` (:51) and `src/bin.rs` (serve args, :29 logging) — store mode + observability.
- `Cargo.toml` — add the tokio-tungstenite TLS feature; `Makefile.toml` — docker-build / deploy tasks.
- New: `Dockerfile`, `fly.toml`; `DEVELOPMENT.md` / `README.md` deploy sections.

# Non-Goals (MVP)

- Multi-instance / HA, cross-instance presence, and backups beyond the single mounted volume (DESIGN §19).
- Server-side rustls (TLS stays at the edge); E2E payload encryption (v2).
- Bridge/CLI reliability fixes (PRD-0008), though they materially improve the deploy-testing experience.

# History
(Entries appended during implementation go below this line.)

## 2026-07-02 — T-001: client-side TLS (the deployment blocker)

The bridge/CLI could only dial `ws://` — `tokio-tungstenite` was built with no TLS feature, so a
`wss://` `connect_async` failed with `TlsFeatureNotEnabled`, making the design's TLS-at-the-edge
model unreachable by any client. Enabled `rustls-tls-webpki-roots` (bundled Mozilla roots, no system
cert store — good for minimal containers); `connect_async` now selects TLS by URL scheme.

rustls 0.23 refuses to auto-pick a crypto provider when more than one is compiled in — and both are:
aws-lc-rs (via SurrealDB) and ring (via the Ed25519 identity keys) — so it panicked at handshake
time. `base::ensure_tls_provider()` installs the aws-lc-rs provider once (idempotent) before dialing;
it's called from `connect_ws` (bridge) and `control::connect` (CLI). Added `rustls` as a direct dep
solely to reach that install API. `ws://` is unchanged for local/tunneled use.

TDD: `test(/dials_wss/)` stands up a plain-TCP (non-TLS) listener and calls `connect_ws` with a
`wss://` URL; it now installs the provider, attempts TLS, and fails at the handshake (`Ok(Err(_))`)
— where before it returned `TlsFeatureNotEnabled`. Full gate green (139 tests). The successful
round-trip to a real TLS terminator is validated by the Fly.io deploy smoke (uat-005).

## 2026-07-02 — T-002…T-006: deployment artifacts

- **T-002 store guard:** `serve` now requires a persistent `--data-dir` (or explicit `--ephemeral`)
  and refuses to silently run in-memory — a mis-templated deploy fails loudly instead of wiping state
  on restart. `test(/requires_a_data_dir/)`.
- **Env-driven config:** `serve` reads `CONCLAVE_BIND` / `CONCLAVE_DATA_DIR` / `CONCLAVE_ADMINS`
  (comma-separated) via clap `env`, so the container / `fly.toml` configure it 12-factor style.
- **T-005 logging:** level from `RUST_LOG` (EnvFilter), `-v` → debug else info; ANSI only when stderr
  is a TTY (clean container logs). `test(/log_config/)`.
- **T-004 health:** a `GET /health` → 200 route (the `/` origin is WS-only → 426), used by the
  platform health check. `test(/health_endpoint/)`.
- **T-003 Dockerfile:** cargo-chef multi-stage (pinned nightly installed before cook so the dep layer
  caches), slim non-root glibc runtime, `/data` volume, env-driven entrypoint. `cargo make docker-build`.
- **T-004 fly.toml:** edge TLS (`force_https`), `[[mounts]]` for `/data`, no auto-stop (a relay holds
  live WS sessions), a `/health` check, and a `CONCLAVE_ADMINS` secret for the key-bound admin.
- **T-006 docs:** README Deploy section (Fly.io + cloudflared, key-bound admin bootstrap); the
  DEVELOPMENT wss:// example restored now that the client dials TLS.

Full gate green (142 tests). Remaining: T-007 (tag-triggered release automation, optional) and the
manual deploy validations — uat-003 (`docker build`) and uat-005 (live Fly.io smoke).

## 2026-07-03 — Deployed to Fly.io; uat-005 verified live; PRD done

Deployed `twitchax-conclave` (region `sjc`, 1GB encrypted volume, shared IPv4 66.241.125.3 +
dedicated IPv6 2a09:8280:1::13c:b7c0:0, admin `twitchax` pinned to the workstation key via a Fly
secret). `--local-only` build (cargo-chef cache) → 126MB image, health checks green.

uat-005 ran live and passed: `scripts/live-smoke.py` (new; wired as `cargo make uat-deploy-smoke`,
parameterized by `CONCLAVE_SMOKE_SERVER`) drives two real bridges over `wss://` through Fly's edge —
register/auth, confirmed joins, a server-acked `send_channel` fanned out to the peer, a whisper
routed to exactly one session, and channel presence. The local permission gate correctly refused
emission until `converse` was granted for `#ops` + whispers. All tasks and UATs are closed; the
custom-domain DNS (`conclave.twitchax.com`) is tracked outside this PRD (Cloudflare records +
`fly certs add`).
