# 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<void>;
   ```

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));
   ```
