Status:
accepted · Version 1.2 · Filed 2026-04-23SPEC-032 v1.2 — Redis Session Plane
Status
accepted — v1.0 reviewed by Lola (Desktop) + Frank; v1.1 incorporates Donna’s implementation-review critique; v1.2 adopts Option B (MCP → backend HTTP/controller/heartbeat → backend issues Redis EXPIRE) after Frank’s steering during implementation review. Redis stays fully inside the backend’s network boundary; MCP clients never connect to Redis directly.
Changelog
1. Summary
Move controller_registrations, master election, session heartbeats, and server-push event routing from Postgres to Redis. Postgres retains durable domain state: leases, capability_tokens, approval_requests, session_deltas, and all project artifacts. Redis is not an optimization or a cache — it is the broadcast domain for Prism’s NetBIOS-style coordination protocol. NetBIOS has the network; Prism has Redis. This is the correct architecture for the election model SPEC-030 §4.1 invokes, eliminates a class of bugs hit in production, and makes single-operator deployments quieter at rest. Redis is backend-only. MCP clients, CLI tools, and external consumers never open a Redis connection; they reach coordination semantics through backend HTTP + gRPC endpoints. This keeps Redis credentials in one place (backend env), scales to cloud-managed Redis without exposing credentials to every user’s machine, and lets the backend authenticate + rate-limit + audit per-user requests. The dividing line between Redis and Postgres is ephemeral vs durable, not hot vs cold.2. Motivation — a real bug, caught live
During the SPEC-030 completion arc on 2026-04-23, Donna’s own master registration was silently released betweenprism_start (12:26Z) and a follow-up verb call 19 min later (12:52Z). Another MCP process on the same machine claimed master at 12:47Z without any signal back. Root cause analysis identified four layered defects sharing one root cause:
Defect 1: sweep_stale does not distinguish masters from peers. controller_service.py::sweep_stale selects all unreleased registrations where last_heartbeat < cutoff. No is_master = false filter. Masters are released identically to peers.
Defect 2: Heartbeat stamping is HTTP-only. stamp_heartbeat fires exclusively through the HTTP auth middleware when X-Prism-Session-Id is on the request. Local-tool stretches (Edit/Read/Bash/Task) bypass the backend entirely; the row ages silently. The 10-minute sweep threshold is easily exceeded within a single coherent work phase.
Defect 3: gRPC Heartbeat handler does not refresh DB. The Heartbeat handler in grpc_runtime/servicer.py constructs a HeartbeatAck and nothing else. It does NOT call stamp_heartbeat. Even with a fully deployed gRPC stream sending 30s keepalives, the Postgres last_heartbeat column would still go stale. Two heartbeat mechanisms, only one connected to liveness — a split brain.
Defect 4: Silent release path. When sweep_stale flips a row, there is no side effect: no pg_notify, no nudge, no rules_reminder. The client has no way to know it lost master. By contrast, the preemption path DOES emit pg_notify — the asymmetry is architectural.
One root cause: We chose durable storage for state that is inherently ephemeral (liveness bounded by connection/TTL), then had to re-invent TTL semantics on top of Postgres with worker loops and heartbeat plumbing. Each emulation layer has gaps. Redis provides these semantics natively.
3. The NetBIOS analogy, taken seriously
SPEC-030 §4.1 invokes NetBIOS master browser election. NetBIOS is persistence-free by design — nodes announce on a broadcast domain, elections happen in the event stream, masters “die” when their announcements stop. Frank’s observation during the bug investigation: “why isn’t this a simple memory table? why did we think this needs to be persisted, do you do it to simulate the netbios broadcast and discovery function?” Correct diagnosis. If we invoke NetBIOS semantics, we must follow through on its persistence model: none. Redis maps 1:1 onto the NetBIOS mental model: atomic CAS (SETNX), native TTL (EXPIRE), pub/sub (PUBLISH/SUBSCRIBE), fast set operations (SADD/SMEMBERS for peer listing). All the Postgres plumbing we built becomes unnecessary. Critical difference from NetBIOS: NetBIOS operates on a well-known network and uses broadcast for discovery. Prism doesn’t have that luxury — it needs an anchor at a known and consistent location that all agents are aware of. Redis IS that anchor. This is why Redis is a hard prerequisite, not an optional optimization (see §9).4. Architectural commitments (load-bearing)
These survive the refactor:- MCP at the boundary — no change. MCP clients talk to the backend via HTTP + gRPC; they never open Redis connections.
- gRPC bidirectional streams inside the control plane — no change; the event-push channel under them switches from asyncpg LISTEN to Redis SUBSCRIBE (backend-side).
- Backend is the only router — no change, and reinforced. All Redis access flows through the backend. External clients authenticate via API keys against the backend; the backend translates to Redis operations. Review flag (Lola): re-evaluate when SPEC-028 (TS MCP) lands. This commitment survives the TS migration more cleanly now that clients never need Redis credentials.
- Capability tokens + leases are DB rows — no change; Postgres retains them.
- Master election is project-scoped — no change.
- CD always wins election when present — no change; implemented via atomic Lua CAS (see §5.2.1).
- Single-master invariant — no change; enforced by atomic
SETNXon a single key plus Lua compare-and-swap for preemption. - Redis is backend-only (new, v1.2) — MCP clients, CLI tools, and any future external consumer reach coordination state through backend HTTP / gRPC endpoints. Redis credentials live in one env (backend); no client ever sees
PRISM_REDIS_URL.
5. What moves to Redis
5.1 Key schema
All keys carry fulltenant_id:project_id scope, even in local mode where tenant is implicit. This prevents mode-branch surprises on upgrade paths (local → LAN) and maintains structural consistency across all deployment modes.
5.2 Semantics — register / elect (prism_start)
- Generate
session_id(UUID). HSET prism:session:{session_id}+EXPIREto TTL (90s).- Try
SETNX prism:master:{tenant}:{project} session_id+EXPIREto TTL.- Success → caller is master;
HSET is_master 1. - Failure → master exists. If caller is
claude_desktop, preempt via the Lua CAS in §5.2.1 — NOT a plainSET ... XX.
- Success → caller is master;
SADD prism:project:{tenant}:{project}:sessions session_id.- Return
controller_statusassembled from Redis reads.
5.2.1 Preemption CAS — atomic via Lua
PlainSET ... XX EX TTL does NOT serialize concurrent preemptions. Two Claude Desktop instances starting within the same TTL window both read “master exists, preempt,” both issue SET ... XX, and the last writer wins with no notification to the intermediate loser. Postgres ix_controller_single_master serialized this for us; Redis needs an explicit atomic CAS.
The preemption operation is:
prism_start when caller is CD and a non-CD master exists:
GET prism:master:...→incumbent_session_id.- Run the Lua script with
incumbent_session_idas the expected value. - Script returns 1 → caller is master.
PUBLISH prism:events:session:{incumbent_session_id}withMasterPreemptedpayload. - Script returns 0 → another preemption beat us, OR the incumbent expired. Re-read the current master; retry if appropriate, or join as peer. Bounded retry (max 3) to avoid livelock.
5.3 Semantics — heartbeat (Option B: via backend, never direct)
Wire path. MCP clients POST to the backend’s/api/v1/controller/heartbeat endpoint every 30s. The backend (inside the Redis network boundary) issues EXPIRE on the session + master keys. MCP clients never open a Redis connection.
- Cloud security. Managed Redis (Upstash/ElastiCache) never exposes credentials to user machines. Only the backend holds the URL. A leaked user API key risks one user’s session; a leaked Redis URL risks the whole coordination plane.
- LAN security. ufw rule on server1 stays locked to admin-IP for Redis port 46379. No need to broaden to the LAN subnet for MCP reachability.
- Single place for Redis knowledge. Backend owns the client, connection pool, Lua registry, retry policy, observability. If we swap Redis for another store later, only the backend changes.
- Per-user authentication + audit. Backend authenticates each heartbeat via API key; can rate-limit and log per-user activity. Direct Redis has no per-user story.
- Client dependency footprint. MCP stays lightweight — no
redislibrary, noPRISM_REDIS_URLenv.
/controller/heartbeat endpoint shape:
Heartbeat ClientEvent (for masters with open CoordinationStreams) routes through the same service method. The X-Prism-Session-Id HTTP header side effect in authforge is deleted in Phase B — heartbeat is explicit via this endpoint, not implicit on every verb.
Death semantics: Client disappears → thread dies → HTTP stops → backend sees no refresh → keys expire naturally after 90s → peers notified via keyspace notification (§5.3.1) → re-election. Clean, structural, no worker involvement.
5.3.1 Re-election trigger via keyspace notifications
When the master key expires naturally (dead master, no release), peers need a signal to race for re-election. Spec’d mechanism:- Redis is configured with
notify-keyspace-events Ex(see §8.6) — enables generic key-expiration events on the__keyevent@{db}__:expiredchannel. - The backend-grpc
ControllerEventListenersubscribes to this channel at startup, filters forprism:master:*key patterns. - On a master-key expiration event, the backend publishes a
master_releasedevent onprism:events:project:{tenant}:{project}with the expired session_id. - Active peers on that project receive the event via their own subscription (or at their next
prism_startviacontroller_statusshowing an empty master). Whichever peer acts first winsSETNXon the now-empty master key. No polling, no timer.
prism_wrap) publishes session_ended explicitly (§5.4). But unclean deaths — process crash, network partition, OS kill — have no publisher. Keyspace notifications cover the unclean path; explicit publish covers the clean path. Both trigger the same subscriber flow.
5.4 Semantics — release (prism_wrap)
Client callsPOST /api/v1/controller/release (or, for masters on a gRPC stream, closes the stream). Backend runs:
stop_event is set before the release POST is sent.
5.5 Semantics — checkpoint (prism_checkpoint, SPEC-031)
prism_checkpoint issues POST /api/v1/controller/heartbeat (same as a regular heartbeat) as part of its flow — refreshing the TTL without DEL. Backend recognizes the checkpoint marker in the request body and skips any SPEC-029 nudge-resolution side effects that prism_wrap would normally trigger. “Save my game” without “leave the table.”
5.6 Server-push events
Producers replacepg_notify('controller_events', ...) with:
PUBLISH prism:events:session:{target} {json_payload}— session-targeted (backend-side)PUBLISH prism:events:project:{tenant}:{project}— broadcast (backend-side)
CoordinationStream subscribes to:
prism:events:session:{caller_session_id}— targeted channelprism:events:project:{tenant}:{project}— broadcast channel
5.7 prism_status
Read-only. Backend doesHGETALL on master’s session hash + iterates SMEMBERS on the project’s session set and returns the routing table over HTTP. No worker coordination, no stale-row filtering. Sub-millisecond on any realistic project scale.
6. What stays in Postgres
These have audit, durability, or integrity requirements that Redis isn’t the right primitive for:
Redis is for “alive if reachable within TTL” state only.
7. What this eliminates
controller_registrationstable → becomes optional append-only audit log, then retired (Phase D).ControllerSweepWorker→ delete.controller_service.stamp_heartbeat→ delete.grpc_runtime/listener.pyasyncpg LISTEN → replaced by Redis SUBSCRIBE.- Partial UNIQUE index
ix_controller_single_master→ moot. - The silent-release bug → fixed structurally via TTL.
- The gRPC-vs-HTTP heartbeat split brain → one path, one primitive.
- The
X-Prism-Session-Idheartbeat side effect in authforge → removed. Heartbeat is explicit via/controller/heartbeat, not a side effect of other verbs.
8. What this adds
8.1 Runtime dependencies
- Redis runtime dependency — new container in all compose stacks.
redis>=5.0inbackend/requirements.txt(async Python client). Backend only — MCP does NOT take this dep under Option B.- New package
backend/app/session_store/— Redis client, pipeline builders, pub/sub machinery, Lua script registration. - New module
mcp/heartbeat.py— dedicated asyncio loop on daemon thread, HTTP client talking to backend/controller/heartbeat(no direct Redis access). - New backend endpoint
POST /api/v1/controller/heartbeat— thin wrapper that validates session + issuesEXPIRE. PRISM_REDIS_URLenv var — backend only. MCP reads onlyPRISM_API_URL+PRISM_API_KEY(unchanged from today).
8.2 Local mode (personal install)
Containerized alongside Postgres + Neo4j. No native install decision forced on the user. File namedocker-compose.personal.yml preserved for now; rename to docker-compose.local.yml aligns with SPEC-019 v1.1 and is a separate migration arc.
docker-compose.personal.yml additions:
PRISM_REDIS_URL: redis://:prism_personal@redis:6379/0
Backend depends_on gains redis: service_healthy.
Password: Fixed dev password prism_personal, matching the Postgres/Neo4j pattern. Since the port isn’t published to the host, even loopback access requires being inside the compose network — password is defense in depth, consistent with other services.
Why no published port? MCP clients don’t need direct Redis access under Option B; only the backend does. Omitting the ports: block makes Redis invisible outside the compose network — tightest possible surface.
8.3 LAN mode (server install)
Containerized, no published port (backend-only access). Same posture as personal mode. docker-compose.server.yml additions:PRISM_REDIS_URL: redis://:${PRISM_REDIS_PASSWORD:-prism_server}@redis:6379/0
bin/prism-server-install.sh diffs:
- New config constant:
PRISM_REDIS_PASSWORD(generated viaopenssl rand -base64 24). - No ufw rule needed for Redis — port isn’t published to the host. Simpler than v1.1’s admin-IP rule.
/etc/prism-server.conftemplate growsPRISM_REDIS_PASSWORDline.- Post-install smoke:
docker compose exec redis redis-cli -a "$PRISM_REDIS_PASSWORD" ping
8.4 Cloud mode (hosted)
Backend runs on a cloud host; Redis is a managed service (Upstash, AWS ElastiCache, Redis Cloud, etc.) reachable over the cloud provider’s private network (VPC peering) or over TLS on the public internet.- Operator provisions managed Redis, gets URL + credentials.
PRISM_REDIS_URLset in the backend’s deployment env. Never in client / MCP env.- MCP clients authenticate to the backend via API key (unchanged from today). Backend uses its Redis credentials to do the actual coordination work.
- A leaked API key compromises one user’s session plane (revocable). A leaked backend Redis URL would compromise the whole cluster — which is why only the backend ever sees it.
install.py --backend=cloudhard-fails if the backend deployment doesn’t havePRISM_REDIS_URLset.- TLS via
rediss://is transparent throughredis-py.
notification-events parameter group; Upstash: enabled by default in paid tiers; Redis Cloud: dashboard toggle).
8.5 SPEC-019 env resolution for PRISM_REDIS_URL
Backend-only resolution, following the existing pattern forPRISM_ALLOWED_ORIGINS and PRISM_WEB_URL:
8.6 Redis configuration
- Persistence: AOF off, RDB off. State is ephemeral by definition — restart = re-register.
- Keyspace notifications:
notify-keyspace-events Ex(generic key-expiration). Required for §5.3.1 re-election. Set via composecommand:arg in local/lan; manual config for cloud (provider-specific). - Eviction policy:
maxmemory-policy noeviction. Session keys are TTL-managed; eviction would corrupt the coordination plane. - Memory budget: ~100KB per active project — negligible.
- Volume mount: Optional, for operator debugging only.
9. Redis availability posture — hard prerequisite
Redis is not optional. Redis IS the broadcast domain. Without it, there is no coordination plane — same as Postgres being down means no domain data. When Redis is unavailable:prism_starterrors on coordination features. It does NOT silently degrade to an in-process singleton.POST /controller/heartbeatreturns 503; MCP heartbeat thread logs a warning and retries.- HTTP + gRPC domain verbs that hit Postgres continue working.
prism_statusreturns an error for controller status, not a degraded response.
redis: service_healthy in compose depends_on ensures backend does not start until Redis is reachable. Install scripts validate Redis connectivity before declaring success.
10. Phased migration
Phase A — Dual-write, Redis-authoritative read
- Add Redis to all compose stacks + install scripts.
- Add
session_storepackage (backend-side Redis client + Lua registry + keyspace listener). - Add
POST /api/v1/controller/heartbeatendpoint on backend. - Add
mcp/heartbeat.py(dedicated-asyncio-loop-on-thread pattern, HTTP client). - Register preemption Lua script at backend startup; cache SHA.
controller_service.registerwrites BOTH Redis AND Postgres.- Reads come from Redis.
ControllerSweepWorkercontinues as safety net (loosened to 30 min TTL).- MCP heartbeat spawned on
prism_start. - Redis keyspace notifications enabled in compose configs.
- Exit criterion: application-level SLI — see §14 criterion #13.
Phase B — Retire Postgres path
- Remove
ControllerSweepWorker. - Remove
stamp_heartbeat. - Postgres row becomes append-only audit.
- Remove
X-Prism-Session-Idheartbeat side effect from authforge.
Phase C — Pub/sub migration
- Dual-path: producers PUBLISH to Redis AND pg_notify.
- Consumers (backend-grpc) subscribe to Redis; LISTEN path logs-only.
- Keyspace-notification-driven re-election wired into backend-grpc
ControllerEventListener. - After one week green, remove LISTEN infrastructure.
Phase D — Optional audit-table retirement
- Drop
controller_registrationsvia forward migration if unused.
11. File-by-file diff summary
Note:
mcp/requirements.txt is NOT modified. MCP does not take a Redis dependency under Option B — it reuses its existing httpx client.
12. Relationship to other specs
- Supersedes SPEC-030 §5 + §11. Does NOT supersede §8, §9, §13.
- Complements SPEC-031 (checkpoint = heartbeat-without-release via the same
/controller/heartbeatendpoint). - Informs SPEC-028 (TS MCP gets a tiny HTTP-ping heartbeat model — no Redis client library needed in TypeScript).
- Uses SPEC-019 for env resolution.
- Corrects wrap-rate inflation from sweep-released sessions.
13. Resolved design questions
14. Acceptance criteria
PRISM_REDIS_URLresolves per SPEC-019 mode profiles on the backend only.- All compose stacks gain Redis service (no host port published).
backend/app/session_store/exists with typed client + pipeline helpers + Lua script registry.prism_startwrites Redis (+ Postgres in Phase A).prism_startreturns identicalcontroller_statusshape.- MCP heartbeat spawns on
prism_startviamcp/heartbeat.py; POSTs to backend/controller/heartbeatevery 30s on its own asyncio loop in a daemon thread. - Backend
/controller/heartbeatendpoint refreshes session + master TTLs via Redis EXPIRE. - TTL expiry → next
prism_startwins master — no worker. - Redis DOWN →
prism_starterrors;/controller/heartbeatreturns 503. - Multi-container backend-grpc: PUBLISH lands on all subscribers.
- Smoke test covers full session-plane round trip: register, heartbeat via HTTP, natural expiry, preempt (via Lua), release, pub/sub delivery to gRPC stream.
- Silent-release bug is fixed structurally (no sweep worker required).
- Phase A exit SLI: over a continuous 7-day window, ≥99% of
prism_startcalls complete without a Redis-coordination error, AND zero code paths read fromcontroller_registrationsfor live decisions (grep-verified). Application-level SLI, measurable via request logs. prism_checkpointissues heartbeat-refresh without DEL.- Concurrent-CD preemption test (§5.2.1 Lua CAS) passes.
- Keyspace-notification-driven re-election test passes.
- MCP does NOT take
redisas a dependency.mcp/requirements.txtunchanged from pre-SPEC-032 state. mcp/heartbeat.pytested in isolation — verify HTTP ping cadence, retry on failure, clean shutdown onstop_event.
15. Authorship + review trail
- Original author: Donna (Claude Code, session a54a1f65) 2026-04-23 16:19Z.
- v1.0 reviewer: Lola (Claude Desktop, session 97e32dbf) 2026-04-23 18:29Z.
- v1.1 implementation reviewer: Donna (Claude Code, session a54a1f65) 2026-04-23 PM. Scope: concurrent-CD race (§5.2.1), heartbeat concurrency model (§5.3), re-election trigger (§5.3.1), measurable acceptance criterion (§14 #13), rationale column in §13.
- v1.2 architectural refinement: Donna (Claude Code) + Frank 2026-04-23 PM. Scope: Option B (backend-mediated heartbeat) adopted after Frank’s steering during implementation review. Redis stays backend-only — cloud credentials never exposed to clients, LAN ufw stays tight, single source of Redis knowledge.
- Steering: Frank — questioned Postgres choice, directed Redis-as-anchor posture, confirmed Option B at implementation review.
- Trigger: silent-master-release bug during SPEC-030 wrap-discussion prep.

