# Session store: production decision (WP-230E) ## Decision `control-api`'s management-session store (`services/control_api/auth/bff.py` `SessionStore`) is an **in-memory, single-process store, and this is the accepted v1 production design** -- not a placeholder awaiting a Redis migration. `control-api` MUST run as exactly one instance/replica. This document is the load-bearing artifact for that constraint: code and Compose must stay consistent with it, and `scripts/compose_release_check.py` enforces the Compose side of it statically (see below), not just the docstring. ## Why this, not a Redis-backed store `redis` is already a hard Compose dependency of `control-api` (`docker-compose.yml`'s `control-api.depends_on.redis`), which reads as an implied promise that sessions are Redis-backed. They are not: no `HUB_REDIS_*` setting exists, and no code path talks to Redis for sessions. That gap is exactly what this decision closes. A real Redis-backed store was evaluated and deferred rather than built now, for a concrete, checkable reason: it requires a new production runtime dependency (`redis`, or an async client) added to `pyproject.toml` and locked via `uv lock`. At the time of this decision `uv` was not reliably resolvable in the environment this work was executed in, so a lockfile change could not be produced with real confidence -- and a session store touches authentication, the most security-sensitive path in this service. Shipping unlocked or hand-edited dependency pins for that specific subsystem is a worse outcome than shipping the honestly-documented current design. This is a tooling-availability constraint on *when*, not a judgment that Redis-backed sessions are undesirable; re-scoping this work is the correct next step once dependency tooling is confirmed reliable (see "Revisiting this decision" below). ## What "single instance" actually means here - Every session and every CSRF token lives in one process's memory (`SessionStore._records`, a plain `dict` guarded by a `threading.RLock`). - A restart, redeploy, or crash of `control-api` invalidates every active session. Users see this as being signed out and must re-authenticate through Authentik; no data is lost, no state is corrupted, and the BFF's same-origin cookie/CSRF model means there is nothing to reconcile on the client side. - Running two or more `control-api` replicas behind a load balancer would silently split sessions and CSRF tokens across disjoint in-memory stores. A request routed to instance B with a session cookie issued by instance A would see "session expired" even though the session is, from the user's perspective, still valid -- and worse, CSRF token mismatches on that same split would be indistinguishable from a real CSRF attack in the logs. This is a correctness and security-signal problem, not just an HA gap, so it is enforced, not left to operational discipline. ## Enforcement (not just documentation) - `docker-compose.yml` and `docker-compose.prod.yml` do not declare `deploy.replicas` for `control-api` (or anywhere); Compose's default is one container per service, so the constraint already holds today. - `scripts/compose_release_check.py::check_compose` now fails the release gate if `control-api` ever declares `deploy.replicas != 1`. This is a static, automated check (`tests/test_wp150_operations.py` and the new `tests/test_wp230_session_store.py` exercise it), run as part of `python scripts/compose_release_check.py` and the release-readiness gate -- so a future change that tries to scale `control-api` fails loudly before it reaches production, instead of failing silently at 2am when a real user hits the split-brain session bug above. - `SessionStore`'s docstring states the single-instance constraint directly at the point where a future change is most likely to be made, and points back to this document. ## Revisiting this decision Move to a real shared store (Redis-backed, encrypted-at-rest session payloads, TTL-matched key expiry, explicit logout invalidation, and defined behavior for a Redis outage -- fail closed, forcing re-authentication, rather than silently falling back to per-instance memory) when: 1. `uv lock` can be run and verified in the environment doing the work, so the new dependency's lock entry is trustworthy, and 2. there is an actual operational need for more than one `control-api` replica (there is none today -- Tower runs a single instance of every owned service). Until then, this document and the `compose_release_check.py` gate are the enforced contract: single instance, in-memory, sessions do not survive a restart, and that is by design.