# ADR: Session lifecycle — TTL and replay protection ## Status Accepted ## Date 2026-04-28 ## Context `InMemoryGameSessionStore` had no TTL and no cleanup mechanism. Every session created stayed in memory until the process restarted. Additionally, `evaluateAnswer` never removed a question from the answer key after evaluating it, meaning the same question could be submitted multiple times and receive a valid result each time — a potential exploit in multiplayer and a correctness bug in singleplayer. ## Decision Add a `ttlMs` parameter to `GameSessionStore.create()` so both the in-memory and future Valkey implementations handle expiry consistently. Delete questions from the answer key after evaluation. Delete the session when the last question is answered. ## Options considered ### Option A — Delete on last answer only Simple. Covers replay protection and normal session completion. Abandoned sessions (player starts game, never finishes) still leak memory. ### Option B — Delete on last answer + TTL on the interface ✅ Delete on answer covers normal flow. TTL covers abandoned sessions. TTL on the interface means `ValKeyGameSessionStore` can use Redis-native `EXPIRE` without any interface changes during migration. Chosen because it closes the memory leak entirely and makes the Valkey migration a zero-interface-change operation. ### Option C — TTL hardcoded inside InMemoryGameSessionStore only Simpler short-term. But the interface wouldn't carry the TTL parameter, so `ValKeyGameSessionStore` would need a different mechanism — inconsistency between implementations. ## Consequences - Sessions expire after 30 minutes of inactivity regardless of completion state - Submitting the same question twice throws `NotFoundError` on the second attempt - Sessions are deleted automatically when the last question is answered - `GameSessionStore.create()` now requires a `ttlMs` argument — any future implementation must honour it - `ValKeyGameSessionStore` can implement TTL via Redis `EXPIRE` with no interface changes - `InMemoryGameSessionStore` stores `{ data, expiresAt }` entries instead of raw `GameSessionData` — expiry is checked lazily on `get()` ## Affected files - `apps/api/src/gameSessionStore/GameSessionStore.ts` — `ttlMs` added to `create` - `apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts` — TTL implementation - `apps/api/src/gameSessionStore/InMemoryGameSessionStore.test.ts` — new test file - `apps/api/src/services/gameService.ts` — passes TTL to `store.create`, deletes question after evaluation, deletes session when empty - `apps/api/src/services/gameService.test.ts` — replay protection and session cleanup tests added ## References - [Redis EXPIRE command](https://redis.io/commands/expire/) --- ## Setup guide / implementation notes 1. `GameSessionStore.ts` — add `ttlMs` to `create`: ```ts create(sessionId: string, data: GameSessionData, ttlMs: number): Promise; ``` 2. `InMemoryGameSessionStore.ts` — wrap stored data with expiry: ```ts type SessionEntry = { data: GameSessionData; expiresAt: number }; ``` Check expiry on `get()`, delete expired entries lazily. 3. `gameService.ts` — pass TTL when creating session: ```ts await store.create(sessionId, { answers: answerKey }, 30 * 60 * 1000); ``` After evaluating an answer: ```ts session.answers.delete(submission.questionId); if (session.answers.size === 0) { await store.delete(submission.sessionId); } ``` 4. When implementing `ValKeyGameSessionStore`, pass `ttlMs` to Redis `EXPIRE`: ```ts await valkey.set(sessionId, serialize(data), "EX", Math.ceil(ttlMs / 1000)); ```