# Phase 1B — Server-side per-question grading: design

**Status:** design pass only · read-only investigation · no code changes.
**Date:** 2026-05-08.
**Scope:** the three live grading paths — **diagnostic**, **Kiasu Path**, **track practice**.

---

## TL;DR

The premise that "client grades and server reconciles" is **only half-true**. Grading is already server-computed in all three modes — `App\Services\AnswerValidationService::checkAnswer()` is invoked by every active submit endpoint and recomputes correctness from the DB. **What's actually broken is that `correct_answer` is leaked in the question payload** (`QuestionFormatterService.php:39`, `KiasuController.php:376`, etc.), so the client *can* grade locally for instant feedback even though the server doesn't trust the client's verdict. The client's batched submission is then redundant verification — it just persists.

Phase 1B's real work is therefore:
1. **Per-question grading endpoint** so the client can render correct/wrong UI (and lives/Maxile/level-up deltas) without precomputing locally.
2. **Stop leaking `correct_answer` in question payloads** — gated on a client-version header until rollout completes.
3. **Idempotency** — `attempt_ledger` has no unique constraint, so a retried submission today double-credits Maxile and double-deducts lives.
4. **Atomic grade-and-mutate transaction** so correctness, lives, Maxile, kudos, ledger writes commit or roll back together.

Diagnostic IRT, Kiasu adaptive selection, and the lives state machine stay out of scope per the brief.

---

## 1. Current state inventory

### 1.1 Where `correct_answer` leaks into client payloads

Every currently-mounted question-payload endpoint passes the column straight through:

- `app/Services/QuestionFormatterService.php:39` — `'correct_answer' => $q->correct_answer` (used by `TrackController::getQuestions` for `GET /api/tracks/{track}/questions`).
- `app/Http/Controllers/KiasuController.php:376` — `formatQuestions()` returns `'correct_answer' => $q->correct_answer` for `GET /api/kiasu-path/start`.
- `app/Http/Controllers/DiagnosticController.php:96` (Kiasu start path map; second leak) and the diagnostic question-batch builder via `AdaptiveLevelService::maxileQuestion()` returning a Question model with `correct_answer` not stripped.
- `app/Http/Resources/QuestionResource.php:38` — `'correct_answer' => $this->correct_answer` (currently unused by active routes but lying around).

`Question` model (`app/Models/Question.php`):
- `$hidden = ['user_id','created_at','updated_at','pivot']` — `correct_answer` is **not** in `$hidden`. Anything that does `->toArray()` or `response()->json($question)` ships it.
- `$casts` lines 19-25 cover dates only. `answer0..answer4`, `correct_answer`, `type_id` are raw columns.

### 1.2 Active answer-submission endpoints

All three are inside the `auth:sanctum + throttle:60,1` group in `routes/api.php` (post Phase 0).

| Method · Path | Handler | Request body | Mutates |
|---|---|---|---|
| `POST /api/diagnostic/submit` | `DiagnosticController::submitAnswers` (line 121) | `{ session_id, answers: [{ question_id, selected_option_id }] }` | `attempt_ledger`, `diagnostic_field_progress`, `user_field_levels`, `question_user`, `assessment_sessions.item_count`, `users.maxile_level` |
| `POST /api/kiasu-path/submit` | `KiasuController::postAnswers` (line 166) | `{ test, question_id[], answer[] }` | `question_user`, `users.kudos`, `tests.kudos_earned`, `attempt_ledger` |
| `POST /api/tracks/{track}/answers` | `API\TrackController::postAnswers` (line 176) | `{ test, question_id[], answer[] }` | `question_user`, `attempt_ledger`, `users.lives` (free tier), `users.kudos`, `tests.questions_answered` |

Three different request shapes for what is conceptually the same operation — diagnostic uses `selected_option_id`, the other two use `answer[]` parallel arrays. **Designing the new endpoint forces normalisation.**

### 1.3 Grading code path per mode

The good news: in all three modes, correctness is **server-recomputed** from `App\Models\Question.correct_answer` — the request payload's idea of "what the user picked" is the only thing trusted, and a malicious client setting `is_correct: true` is irrelevant because the server doesn't read that field.

| Mode | Validator | Per-answer? | Lives deducted? |
|---|---|---|---|
| Diagnostic (`POST /diagnostic/submit`) | `AnswerValidationService::checkAnswers(...)` (`AnswerValidationService.php:92-123`), called from `DiagnosticController.php:176` | Batched; computes correctness once per submitted answer | **No** — `deductLives = false` (`AnswerProcessingService.php:127` makes diagnostic explicitly skip) |
| Kiasu (`POST /kiasu-path/submit`) | `AnswerProcessingService::processAnswers(...)` → loops `AnswerValidationService::checkAnswer()` (`AnswerProcessingService.php:43`) | Batched | **No** — Kiasu requires premium (`KiasuController.php:178`); `deductLives = false` |
| Track practice (`POST /tracks/{track}/answers`) | `AnswerProcessingService::processAnswers(...)` (`AnswerProcessingService.php:25-172`) | Batched, but inside `DB::transaction` wrapper at `TrackController.php:212` | **Yes** for free users (`deductLives = $isFreeUser` at `TrackController.php:217`); calls `LiveService::deductLife` per wrong answer |

The shared comparator:
- **MCQ (`type_id = 1`):** `AnswerValidationService.php:50-54` — `(int)$question->correct_answer === (int)$selectedOption`.
- **FIB (`type_id = 2`):** `AnswerValidationService::checkFillInBlankAnswer` (lines 131-161) — case-insensitive, whitespace-trimmed, all-of-N comparison across `answer0..answer3`.
- **Kudos (inline, not via `KudosService`):** `AnswerValidationService.php:64-66` — `$isCorrect ? ($question->difficulty_id ?? 0) + 1 : 1`. The `KudosService::calculateKudos` (`app/Services/KudosService.php:10`) is dead on the live path; only `app/Http/Controllers/AnswerController.php:165` (legacy, commented routes) calls it. Phase 1B should converge on one kudos formula — either inline stays, or KudosService is reactivated. Flagged below.

### 1.4 Call graph — `MaxileService`, `KudosService`, `LiveService::deductLife`

**`App\Services\MaxileService`** — *not invoked from any active controller or service.* Maxile updates currently happen inline:
- `DiagnosticController.php:374` — `$user->update(['maxile_level' => $avgMaxile])` (direct write, no service).
- Track and Kiasu paths do **not** update `users.maxile_level` per submit.
- `MaxileService::calculateUserMaxile` and the cascade at `MaxileService::updateMaxilesForPassedSkill` (line 323), `updateMaxilesFromQuestions` (line 713) are present but unwired.

**Implication:** *the new per-answer endpoint must add the per-question Maxile delta computation that doesn't exist today.* The frontend "Maxile delta animation" requirement implies a contract not currently satisfied by any endpoint.

**`App\Services\KudosService`** — only called from `app/Http/Controllers/AnswerController.php:75,165` (legacy, off active routes). Live grading uses the inline formula in `AnswerValidationService.php:64-66`.

**`App\Services\LiveService::deductLife(User $user, int $amount = 1): bool`** — Phase 1A wrapped in `DB::transaction` + `lockForUpdate` (`LiveService.php:191-228`). Confirmed compliant.
- **Service-routed callers:** `AnswerValidationService::checkAnswer` (line 72) — the only path on grading flows.
- **Bypass:** `app/Http/Controllers/StripeWebhookController.php` does `$user->increment('lives', ...)` (forged payload risk per the audit). Outside Phase 1B scope; Phase 1D Stripe rework owns it.

### 1.5 `attempt_ledger` schema and writers

Migration `2025_12_21_221457_change_ledger.php:96-113`:
```
attempt_ledger:
  id              bigIncrements PK
  session_id      unsignedBigInteger  index  FK→assessment_sessions(id) on delete cascade
  question_id     unsignedBigInteger  index
  skill_id        unsignedBigInteger  nullable index
  track_id        unsignedBigInteger  nullable index
  field_id        unsignedBigInteger  nullable index
  is_correct      boolean             nullable
  answer_given    json                not null
  created_at      timestamp
  updated_at      timestamp
  INDEX (session_id, question_id, created_at)
```

**No `UNIQUE` constraint on `(session_id, question_id)`.** A retried submission writes a second row.

Two write sites:
- `DiagnosticController::submitAnswers` line 343 — `DB::table('attempt_ledger')->insert($attemptLedgers)` (raw insert; bypasses the trait).
- `App\Traits\WritesAttemptLedger::writeAttemptsToLedger()` (`app/Traits/WritesAttemptLedger.php:114-196`) — used by `KiasuController.php:271` and `API\TrackController.php:244`.

Both writers append unconditionally. `WritesAttemptLedger::getOrCreateAttemptSession` (line 12-100) does dedupe at session level (one session per `(user_id, test_id, mode)`), but ledger rows themselves are not deduped per question.

### 1.6 Refactor pain points

- **`App\Services\AdaptiveLevelService`** is instance-stateful (`$preloadedLevels` cache, line 40-50). `DiagnosticController::getDiagnosticQuestions` constructs it per request and uses `getStartingMaxile()`, `getQuestionAtLevel()`, `maxileQuestion($q)`. The `maxileQuestion()` method appears to attach computed metadata to a `Question` instance — risk of accidentally serialising `correct_answer` along with the level annotations. Phase 1B must trace this method end-to-end before stripping `correct_answer` globally.
- **`App\Services\KiasuPathService`** is fully static; cursor state lives on `users.has_started_kiasu_path` and the `Test` row keyed by `(user_id, test_type_id=Kiasu, completed=false)`. **No statefulness in the service itself** — cleaner to retrofit than diagnostic.
- **`LoadController` / `LoadQuestions` / `LoadSecondary`** — confirmed off the answer path; legacy noise.
- **Three different request shapes** across the three modes → the new endpoint should canonicalise to one shape. Existing endpoints stay as compatibility shims during rollout.

---

## 2. Proposed endpoint contract

### 2.1 New endpoint

```
POST /api/answers
Headers:
  Authorization: Bearer <sanctum_token>
  Idempotency-Key: <client-generated UUIDv4>     (required)
  X-Client-Version: <semver>                      (required, e.g. "1.4.0")
  Content-Type: application/json
```

**Name rationale:** REST-conventional resource creation — an "answer attempt" is the entity being created, the response is its computed result. `POST /api/answers/check` reads as RPC and obscures the persistence side-effect; the operation IS persistence-with-grading. Plural matches the existing `/api/payments`, `/api/tracks` pattern.

### 2.2 Request body

```jsonc
{
  "session_id": 12345,         // assessment_sessions.id (server-provided when session starts)
  "question_id": 8842,
  "mode": "track",             // "diagnostic" | "kiasu" | "track"
  "answer": {                  // canonical shape; client always sends this
    "type": "mcq",             // "mcq" | "fib"
    "selected_option": 2,      // for type=mcq, integer 0..4
    "fields": null             // for type=fib, ["7","x+1","",""], null otherwise
  },
  "client_meta": {             // optional, used for analytics + kudos time bonus
    "elapsed_ms": 4280,        // tap-to-submit latency
    "shown_at": "2026-05-08T07:45:11.220Z"
  }
}
```

`session_id` is required. Existing batched flows already create one via `WritesAttemptLedger::getOrCreateAttemptSession`; a `POST /api/sessions` that returns `{ session_id, mode }` is implicit in the new contract (today the client gets it from `GET /diagnostic/start`, `GET /kiasu-path/start`, `GET /tracks/{track}/questions`).

### 2.3 Response body

One shape, sufficient for: correct/wrong UI, lives header, Maxile delta, level-up overlay, premium-required guards, kudos animation. No follow-up call required.

```jsonc
{
  "is_correct": false,
  "correct_answer": { "type": "mcq", "selected_option": 3 },     // server-revealed AFTER grading
  "explanation_url": null,                                       // future; null today

  "lives": {
    "current": 2,
    "max": 5,
    "unlimited": false,
    "deducted_this_attempt": true,
    "next_life_in_seconds": 17943,
    "next_life_at": "2026-05-08T12:44:11Z"
  },

  "kudos": {
    "awarded_this_attempt": 1,
    "user_total": 287
  },

  "maxile": {
    "delta": 0,                       // Maxile points earned/lost on this answer
    "user_total_before": 412.5,
    "user_total_after":  412.5,       // diagnostic: changes only on completion;
                                       // track: 0 today (Phase 1B doesn't change that);
                                       // kiasu: same.
    "field_id": 7,
    "field_total_before": 380,
    "field_total_after":  380,
    "level_up": null                  // {"from": 380, "to": 400, "name": "Level 4"} on threshold cross
  },

  "session": {
    "id": 12345,
    "mode": "track",
    "items_recorded": 8,
    "completed": false,               // true when batch ends (e.g. 10/10 in track practice)
    "summary_url": "/api/sessions/12345/summary"   // for end-of-batch UI
  },

  "premium_gate": null                // {"feature":"unlimited_lives","upgrade_url":"…"} when access blocked
}
```

**`correct_answer` IS in the response.** That's safe — it's revealed only *after* the user has committed an answer. The leak today is that it's in the *question payload*, before commit.

**Status codes:**
- `200 OK` — graded successfully (correct OR wrong; both are "graded").
- `409 Conflict` — `Idempotency-Key` matches a previous request with a different body.
- `412 Precondition Failed` — `lives = 0` and the user tried to answer; response includes the `lives` block + `premium_gate`.
- `422` — validation error on payload.
- `429` — throttled.

### 2.4 Existing batched endpoints — recommendation

| Endpoint | Recommendation | Rationale |
|---|---|---|
| `POST /api/tracks/{track}/answers` | **Deprecate** behind `X-Client-Version`. Old clients keep using it; new clients call `/api/answers` per question. | Track practice has the highest answer rate and a UX (lives) that benefits most from instant per-question feedback. |
| `POST /api/kiasu-path/submit` | **Deprecate** same way. | Identical request shape to track; same migration path. |
| `POST /api/diagnostic/submit` | **Keep as summary-only reconciliation.** New per-question endpoint records each answer; this batch endpoint becomes "I'm done — finalise field levels and overall maxile". | Diagnostic's adaptive logic in `DiagnosticController::submitAnswers` (lines 238-374) computes per-field current_level, ceiling detection (2 wrongs), final_level, and average maxile — these are end-of-test computations. Splitting the per-answer recording (now via `/api/answers`) from the end-of-test reconciliation cleanly preserves the existing math without rewriting `DiagnosticFieldProgress` updates as 20 separate transactions. |

### 2.5 Idempotency

**Two-layer:**

1. **`Idempotency-Key` header (client-generated UUIDv4).** Server caches `{key → response}` for 24 h in the existing array/file cache (Redis when prod migrates). On replay with the same key + same body → return the cached response. Same key + different body → `409 Conflict`.

2. **DB-level uniqueness on `attempt_ledger`.** Add `UNIQUE (session_id, question_id)` index in a new migration. Combined with `INSERT … ON DUPLICATE KEY UPDATE id=id` (no-op upsert), a retry without an idempotency key still doesn't double-write. The composite index `(session_id, question_id, created_at)` already exists; the unique version replaces or supplements it.

   **Caveat:** if the product *intends* to allow re-attempts of the same question in one session (current `attempt_ledger` shape suggests it does — there's no version column), then `(session_id, question_id)` is wrong. The actual intended uniqueness must be confirmed with product before the migration ships. Phase 1B step 0.5 task.

The `Idempotency-Key` layer alone is sufficient for the per-answer endpoint; the DB-level constraint is belt-and-braces against bypassed callers (e.g. the legacy batch endpoints during the rollout window).

---

## 3. Server-side grading logic

### 3.1 Where the comparator lives

**Today:** `App\Services\AnswerValidationService::checkAnswer($questionId, $userAnswer, $user, $deductLivesOnWrong): array` is canonical and works.

**Phase 1B:** introduce `App\Services\AnswerGradingService::grade(GradeRequest $req): GradeResult` as the orchestration layer. It owns the transaction; it delegates the comparator to `AnswerValidationService::checkAnswer`; it composes the response shape; it handles idempotency.

Reasons to *not* extend `AnswerValidationService` directly:
- It's currently a stateless comparator + side-effecting life deduction. Mixing in Maxile/Kudos/ledger orchestration bloats it.
- Two callers (`AnswerProcessingService` for batches, `AnswerGradingService` for per-answer) keep concerns separable.
- `AnswerProcessingService` continues to back the deprecated batch endpoints during rollout.

### 3.2 Transactional call graph

```
AnswerGradingService::grade(req)
└─ idempotency cache check (return early on hit)
└─ DB::transaction:
   ├─ assessmentSession = WritesAttemptLedger::getOrCreateAttemptSession(...)
   ├─ result = AnswerValidationService::checkAnswer(question_id, payload.answer, user, deductLivesOnWrong=mode==='track')
   │     └─ this internally calls LiveService::deductLife (already wraps its own transaction → nested OK in MySQL via savepoints)
   ├─ writeAttemptsToLedger(assessmentSession, [result + payload]) with INSERT … ON DUPLICATE KEY UPDATE
   ├─ updateQuestionUser(user, question, result)              // pivot upsert (was AnswerProcessingService:90-106)
   ├─ if mode === 'kiasu' || mode === 'track' && result.is_correct:
   │     user->increment('kudos', result.kudos)               // bypasses $fillable; safe
   ├─ if mode === 'diagnostic':
   │     // do nothing for maxile per-answer; accumulate in diagnostic_field_progress
   │     diagnosticProgress::recordAnswer(field_id, is_correct, current_level)
   ├─ maxileDelta = MaxileService::recordAnswer(user, question, result.is_correct)   // NEW METHOD; see §3.3
   └─ commit
└─ idempotency cache store (key → serialised response, TTL 24h)
└─ return GradeResult
```

**Audit of mutations outside `DB::transaction` today (post Phase 1A):**

| Site | Status |
|---|---|
| `LiveService::deductLife` | ✅ wrapped (Phase 1A, `LiveService.php:191-228`) |
| `LiveService::regenerateLives` | ✅ wrapped (Phase 1A) |
| `LiveService::restoreLives` | ⚠ `forceFill` only, no transaction — Phase 1.5 follow-up flagged in Phase 1A CHANGES.md |
| `AnswerProcessingService::processAnswers` | Wrapped at the **caller** (`TrackController.php:212`), not internally. Kiasu calls it without a wrapper (`KiasuController.php:210`). |
| `DiagnosticController::submitAnswers` | Has manual `DB::beginTransaction` / `commit` / `rollBack` (line ~158). |
| `MaxileService` mutations | Not currently invoked from live paths; whatever Phase 1B wires in will need its own wrapper. |
| Kudos `$user->increment('kudos', ...)` | Atomic at SQL level; safe outside transaction but logically should be inside the grading transaction so a rollback also rolls back the kudos. |

`AnswerGradingService::grade` puts everything under one explicit `DB::transaction` so rollback is unambiguous.

### 3.3 Maxile per-answer

Per-answer Maxile delta is **new** functionality. The smallest viable version:
- Diagnostic mode: no per-answer maxile change in the response (`delta: 0`); end-of-test reconciliation by `DiagnosticController::submitAnswers` continues to compute final field levels and `users.maxile_level`. Frontend animation shows 0 during diagnostic and the big update at the end (acceptable per current UX).
- Kiasu / track: the simplest contract is `delta = is_correct ? +difficulty_id : 0` and apply to `user_skill_levels.current_level` (already the per-skill maxile column per `MaxileService::calculateTrackMaxile:70`). Or `delta = 0` until the algorithm is properly designed. **Recommendation: ship `delta: 0` in Phase 1B, design the per-answer Maxile algorithm in Phase 2.** The response shape carries the field but its value is always 0 for kiasu/track in this phase. The frontend animation framework is in place; the math arrives later.

### 3.4 FIB grading

Already canonical: `AnswerValidationService::checkFillInBlankAnswer` (`AnswerValidationService.php:131-161`) — case-insensitive, trimmed, all-fields-must-match across `answer0..answer3`. **Move:** none. **Improvement:** extract to a pure static helper `FibComparator::compare(array $expected, array $given): bool` so it can be unit-tested without instantiating a Question. Phase 1B step BE7 owns the test coverage.

### 3.5 Stripping `correct_answer` from question payloads

Three sites (§1.1). Wrap each in:
```php
$response = ['question' => $q->question, 'answer0' => $q->answer0, ...];
if ($request->header('X-Client-Version') && version_compare($request->header('X-Client-Version'), '1.4.0', '<')) {
    $response['correct_answer'] = $q->correct_answer;   // legacy clients still get it
}
```
Once adoption % is past the trigger, the conditional + `correct_answer` line are deleted in one PR.

---

## 4. Migration strategy

### Stages

| # | Step | Trigger to start next |
|---|---|---|
| **A** | **Backend ships new endpoint, idempotency, ledger uniqueness, gated payload stripping. Old endpoints unchanged.** Backwards-compatible. Optional: add `X-Server-Capability: per-question-grading` header to advertise the new endpoint. | Backend deploys cleanly; smoke tests pass. |
| **B** | **Flutter client v1.4.0 ships:** uses new endpoint per question; sends `X-Client-Version: 1.4.0`; falls back to old endpoint if `/api/answers` returns 404 (defence). | App-store rollout completes (~7-14 days for App Store + Play Store). |
| **C** | **Server starts conditionally stripping `correct_answer`** when `X-Client-Version >= 1.4.0`. Old clients keep getting it. | A/B telemetry confirms new clients work; per-question latency p95 within budget (§5.1). |
| **D** | **Hard cut.** Strip `correct_answer` unconditionally. Delete the version-gating branches. Mark old batch endpoints with deprecation log + 410 Gone after a sunset window. | **Trigger condition: ≥95% of authenticated requests in the last 7 days carry `X-Client-Version >= 1.4.0`.** Or hard date 90 days post-stage-B, whichever comes first. |

### Backward compatibility — version detection

**`X-Client-Version` request header (semver string).** Recommended over:
- `User-Agent` parsing — Flutter HTTP clients vary; brittle.
- Explicit version field in the request body — every endpoint duplicates it; missing on GETs.
- App Store version inference — server can't see store data.

The Flutter app sets the header once on its `dio`/`http` interceptor. Server reads via `$request->header('X-Client-Version')`. Missing or unparseable → treat as legacy (include `correct_answer`). A Laravel middleware `EnsureClientVersion` (registerable per-route) gives a single check site.

### Worst-case fallout if step D ships too early

The Maxile-conscious child opens the app, sees a question, and the JSON response no longer carries `correct_answer`. The legacy client's local grading code reads `null`, treats it as "wrong", **deducts a life on every question**, and the user blasts through their 5 lives in 30 seconds. UX-blocking, recoverable by app update, but support-load-spiking. Mitigation:
- Stage C's gating must hold for at least 30 days before stage D.
- `tests/Feature/LegacyClientPayloadTest.php` (new) asserts `correct_answer` is in the payload when `X-Client-Version: 1.3.9` is sent — protects against accidental hard-cut from the wrong PR.
- Stage D PR includes a feature-flag (`config('grading.strip_legacy_correct_answer')`) so it can be flipped off in <1 minute without a redeploy.

---

## 5. Risks and decisions

### 5.1 Latency budget — Singapore 4G

- **Median realistic round-trip:** Singapore 4G to a Singapore-hosted Laravel API on a warm connection typically lands at p50 ~80-150 ms HTTP RTT plus server processing.
- **Server processing:** today's `AnswerValidationService::checkAnswer` does 1 question lookup (`Question::find`) + ledger insert + optional life deduction (1 SELECT … FOR UPDATE + 1 UPDATE). With proper indexes, ≤30 ms.
- **Total p50 target:** **250 ms.** p95 target: **600 ms.** Frontend animates the tap response over ~400-500 ms (current Flutter feedback feels instant); the response should land before the animation completes.
- **Indexes needed on the answer-write path:**
  - `attempt_ledger (session_id, question_id)` — **add UNIQUE** (replaces the existing composite-index ordering for this exact lookup).
  - `users (id)` — already PK, used by `lockForUpdate`.
  - `questions (id)` — PK; `Question::find` is fast.
  - `question_user (user_id, question_id)` — confirm composite exists (used by `myQuestions` upserts in `AnswerProcessingService.php:84`); add if missing.
  - `assessment_sessions (user_id, test_id)` — used by `getOrCreateAttemptSession`; confirm.
- **The `attempt_ledger` audit finding "hit on every answer submission" is confirmed** — every grade call does one insert. With a unique constraint and `INSERT … ON DUPLICATE KEY UPDATE id=id`, the cost stays single-row-write.

### 5.2 Offline behaviour

Recommend: **block submission, show a "You're offline — your answer wasn't recorded. Tap to retry." toast**. Reasoning:
- A local queue with deferred grading requires the client to predict the result (lives header, Maxile delta, kudos) — which means *either* shipping the formula client-side (regresses Phase 1B's goal) *or* showing "graded later" UI that creates UX confusion.
- Per-question lives deduction depends on the server's locked view of `users.lives`. Optimistic local deduction will desync.
- The block-and-retry path matches Duolingo's behaviour and the existing `LiveService` race-fix pattern.

The retry path uses the `Idempotency-Key` from the original attempt — server replays the cached response. Safe.

### 5.3 Diagnostic UX — per-question vs hidden batched

Diagnostic shows ~20 questions, no per-question correct/wrong feedback today (`DiagnosticController` returns the result only at the end). **Recommendation: keep that UX, but submit per-question via `/api/answers` with mode=`diagnostic`.**
- The server computes correctness and persists, but the *response* in diagnostic mode omits `is_correct` from the visible UI surface (the `is_correct` field stays in the JSON — it's not a secret, but the client UI doesn't render it during diagnostic).
- This matches IRT principles: the test is for *measurement*, not *learning*; per-question feedback skews subsequent answer behaviour and degrades the measurement.
- The transition to per-question submission still wins on resilience: a child whose phone dies mid-test loses one answer instead of all 20.
- End-of-test still calls `POST /api/diagnostic/submit` for field-level reconciliation (now lighter — it computes maxile from the already-recorded ledger, doesn't process raw answers).

### 5.4 Hard-to-retrofit parts

- **`AdaptiveLevelService::maxileQuestion()`** (`app/Services/AdaptiveLevelService.php` — instance-stateful, builds question payload). Phase 1B BE6 must trace this method to confirm whether removing `correct_answer` from the formatter also removes it from this code path. Risk: 1 day of work to refactor cleanly.
- **`KiasuPathService` raw-SQL question selection** (`KiasuController.php:333` chain). Stateless; safe.
- **`LoadController`, `LoadQuestions`, `LoadSecondary`** — large legacy controllers, off the answer path; ignore.
- **Three different submit-request shapes** — the new endpoint forces one canonical shape; the deprecated endpoints stay until stage D.
- **Stripe webhook bypass of `LiveService`** (`StripeWebhookController` direct `$user->increment('lives', ...)`) — Phase 1D, not 1B.
- **Two divergent kudos formulas** (`AnswerValidationService` inline vs `KudosService::calculateKudos`) — Phase 1B should pick one and document. Recommend: keep inline in `AnswerValidationService` for now (simpler, predictable), delete `KudosService` in Phase 1.5 alongside the legacy `AnswerController`.

---

## 6. Implementation plan

### Backend chunks (PR-sized; estimates in person-days)

| ID | PR | Days | Depends on | Notes |
|---|---|---|---|---|
| BE1 | Idempotency cache layer + `Idempotency-Key` middleware | 1.0 | – | Wraps any endpoint registered with `idempotent` middleware; uses cache backend. Independent. |
| BE2 | Migration: `UNIQUE(session_id, question_id)` on `attempt_ledger` + ensure `question_user (user_id, question_id)` and `assessment_sessions (user_id, test_id)` indexes exist | 0.5 | – | Pre-flight: confirm with product whether re-attempts in same session are intended (§2.5 caveat). |
| BE3 | `POST /api/answers` route + `AnswerGradingService::grade` skeleton; uses existing `AnswerValidationService::checkAnswer`; per-mode response shape | 1.5 | BE1 | Echoes today's batch behaviour for one question. |
| BE4 | `AnswerGradingService` transactional orchestration: ledger upsert, kudos, lives, session.items_recorded; explicit `DB::transaction` wrapper | 2.0 | BE3 | The bulk of the grading rewrite. |
| BE5 | Per-mode response specifics: diagnostic field-progress recording inside the transaction, completion detection, summary URL | 1.5 | BE4 | Pulls in `DiagnosticFieldProgress::recordAnswer` (new method extracted from `DiagnosticController::submitAnswers:238-290`). |
| BE6 | `X-Client-Version`-gated `correct_answer` stripping in `QuestionFormatterService`, `KiasuController::formatQuestions`, and the diagnostic question builder | 1.0 | – (parallel with BE3-5) | One central middleware sets a request flag; formatters check it. |
| BE7 | Tests: `AnswerGradingServiceTest`, `IdempotencyTest`, `FibComparatorTest`, `LegacyClientPayloadTest`, race test (concurrent answers same question) | 2.0 | BE4 | Use the in-memory sqlite scaffolding from Phase 1A. |
| BE8 | Stage-D PR: unconditionally strip `correct_answer`; mark old endpoints deprecated; remove `KudosService` and dead `AnswerController` | 0.5 | BE6 + telemetry | Held until ≥95% adoption. |

**Parallelism:** BE1, BE2, BE6 run in parallel from day 1. BE3 starts when BE1 lands. BE4 needs BE3. BE5 needs BE4. BE7 needs BE4. BE8 is gated on rollout telemetry.

**Critical path:** BE1 → BE3 → BE4 → BE5 → BE7 → ~8 days. With BE2/BE6 parallel: total backend work ~10 person-days, can be done by one engineer in ~2 weeks accounting for review and deploy windows.

### Frontend chunks (in the sibling `flutter_demo` repo)

| ID | PR | Days | Depends on |
|---|---|---|---|
| FE1 | `X-Client-Version` header on every API call; semver bumped to `1.4.0` | 0.5 | – |
| FE2 | Per-question grade call wired into the answer-tap handler; tap-feedback animation duration tuned to mask round-trip | 2.0 | BE3 deployed |
| FE3 | Unified `GradeResponse` model in Dart; lives header, Maxile delta, kudos, level-up overlay all driven by the response | 2.0 | BE5 deployed |
| FE4 | Offline-blocked state with retry; Idempotency-Key generation (UUIDv4 per attempt) and reuse on retry | 1.0 | FE2 |
| FE5 | Remove client-side grading: delete the local correct/wrong evaluator, delete the `correct_answer` consumption code | 0.5 | FE3 stable in prod |

**Parallelism:** FE1 standalone. FE2-4 sequentially as backend pieces land. FE5 is the symmetric counterpart of BE8.

**Total frontend:** ~6 person-days.

---

## 7. Out of scope (do not redesign in Phase 1B)

- **Diagnostic IRT/Maxile algorithm** (averages-claimed-as-IRT per the audit). Phase 2.
- **Kiasu Path adaptive selection** (currently raw SQL by maxile-bucketed level). Phase 2.
- **Lives state machine with absolute `next_life_at`** (current model is per-life timestamps in a JSON array). Phase 1B preserves the existing format; the response's `next_life_at` is computed from the existing queue.
- **Stripe webhook bypass of `LiveService`** — Phase 1D.
- **Two-kudos-formula reconciliation** — flagged but deferred to Phase 1.5 housekeeping (chose the active inline path; KudosService deletion alongside legacy AnswerController removal).
- **Database migration to drop `users.auth0`** — Phase 1 housekeeping (independent track).
