# Phase 1B BE4 — Transactional grade orchestration

**Date:** 2026-05-08 · **Scope:** turn BE3's read-only skeleton into the real grade endpoint. Single `DB::transaction` wraps lock acquisition, attempt count, validation, ledger insert, lives deduction, kudos increment, pivot upsert, and items_recorded increment. Per-mode response specifics (BE5) and `correct_answer` payload stripping (BE6) remain deferred. Backend only.

---

## §1 — Files changed

| File | Type | Notes |
|---|---|---|
| `routes/api.php` | modified | Throttle cleanup (Option (a)): drop dead route-level `throttle:120,1`. |
| `app/Services/AnswerGradingService.php` | rewritten | `grade()` now wraps everything in `DB::transaction`, plus new helpers `deriveTrackAndField`, `upsertQuestionUserPivot`. |
| `tests/Feature/AnswerGradingTest.php` | extended | 3 BE3 cases preserved + 7 new BE4 cases (1 documented skip on the concurrency case). 16 assertions → 73 assertions. |

---

## §2 — Throttle cleanup

```diff
@@ routes/api.php (auth:sanctum group)
     // === Per-question grading (Phase 1B BE3) ===
-    // Tap-by-tap ceiling at 120/min/user (~2/sec). Idempotency-Key guards retry safety.
+    // Effective throttle: the auth:sanctum group's 60/min ceiling, which is more
+    // than enough for tap-by-tap learning (1 answer/sec sustained is faster than
+    // any human can read a math problem).
     Route::post('/answers', [App\Http\Controllers\API\AnswerController::class, 'store'])
-        ->middleware(['idempotent', 'throttle:120,1'])
+        ->middleware(['idempotent'])
         ->name('answers.store');
```

Verified via `php artisan route:list`:
```
POST  api/answers
  - api
  - App\Http\Middleware\Authenticate:sanctum
  - Illuminate\Routing\Middleware\ThrottleRequests:60,1
  - App\Http\Middleware\EnforceIdempotencyKey
```
No `ThrottleRequests:120,1`. Total routes still 240 (no new endpoints).

---

## §3 — `AnswerGradingService::grade()` — transactional

The whole grade body now runs inside `DB::transaction(function () { … })`. Inside, in order:

1. **Lock the user row.** `User::lockForUpdate()->find($user->id)`. Holds for the full transaction.
2. **Apply due lives restoration.** `LiveService::regenerateLives($userLocked)` — itself `DB::transaction`-aware (Phase 1A); Laravel handles nesting via savepoints, the inner `lockForUpdate` on the same user row is re-entrant on the same connection.
3. **Race-safe attempt count.** `DB::table('attempt_ledger')->where(…)->lockForUpdate()->count()`. The lock prevents a concurrent grade from passing the same `$attemptNumber` value at the same instant. Defence-in-depth: BE2's `UNIQUE(session_id, question_id, attempt_number)` is the DB-level safety net even if the lock fails.
4. **Refuse 3rd attempt with 412.** `GradeResult::maxAttemptsExceeded(...)` carries a fresh lives snapshot.
5. **Validate.** Translate canonical → legacy via `translateAnswerForValidator`, call `AnswerValidationService::checkAnswer(deductLivesOnWrong: false)` — this service owns lives, the validator is degraded to a pure correctness oracle.
6. **Determine `$isFinal`.** `$isFinal = $isCorrect || $attemptNumber === 2`. Only the final attempt is durable; mid-stream attempt-1-wrong leaves the pivot untouched.
7. **Derive `track_id`/`field_id`.** New helper (see §4).
8. **Insert ledger row.** Raw `DB::table('attempt_ledger')->insert(...)` with derived skill/track/field. (Unifying with `WritesAttemptLedger` trait remains a Phase 1.5 housekeeping item.)
9. **Lives deduction (final-wrong only).** `if (!$isCorrect && $isFinal && !LiveService::hasUnlimitedLives($userLocked)) { $livesDeducted = LiveService::deductLife($userLocked) === true; }`. **Attempt-1-wrong is free** per the agreed product rule.
10. **Kudos (correct only).** `(($question->difficulty_id ?? 0) + 1)` followed by `$userLocked->increment('kudos', $kudosAwarded)` — see §5 for formula choice.
11. **Pivot + items_recorded (final only).** New `upsertQuestionUserPivot` helper (see §4); `DB::table('assessment_sessions')->where(…)->increment('item_count', 1)`.
12. **Reload + build response.** `$userLocked->refresh()` so the snapshot reflects post-mutation state. Premium gate fires when `!unlimited && lives.current === 0`.

The transaction commit flushes all of (5)…(11) atomically. A throw at any step rolls back everything — no partial ledger row, no dangling kudos increment, no orphaned pivot.

---

## §4 — `track_id` / `field_id` derivation (chosen path + schema evidence)

**Chosen path: `assessment_sessions.test_id → tests.track_id → tracks.field_id`.**

Schema evidence from local `api`:

```
DESCRIBE tests;        →  track_id    int unsigned   YES   MUL    NULL
DESCRIBE tracks;       →  field_id    int unsigned   NO    MUL
DESCRIBE assessment_sessions; → test_id  bigint unsigned  YES  MUL  NULL
```

`tests.track_id` is a real column (nullable for diagnostic tests that don't bind to a single track). `tracks.field_id` is `NOT NULL` — every track lives in exactly one field. `assessment_sessions.test_id` is nullable (sessions can exist before a test row, e.g. ad-hoc kiasu).

**Why not via `skill_track`:** `question.skill_id → skill_track.track_id` is many-to-many. One skill can sit in multiple tracks; the derivation would need an arbitrary tiebreaker (lowest id, etc.) which makes the ledger row's `track_id` non-deterministic. The session knows which track the user is currently inside — using it gives a single, unambiguous answer.

```php
private function deriveTrackAndField(int $sessionId): array
{
    $row = DB::table('assessment_sessions as s')
        ->leftJoin('tests as t', 's.test_id', '=', 't.id')
        ->leftJoin('tracks as tr', 't.track_id', '=', 'tr.id')
        ->where('s.id', $sessionId)
        ->select('t.track_id', 'tr.field_id')
        ->first();

    if (!$row) return [null, null];

    return [
        $row->track_id !== null ? (int) $row->track_id : null,
        $row->field_id !== null ? (int) $row->field_id : null,
    ];
}
```

Returns `[null, null]` for sessions where the join chain is broken (no `test_id`, missing `tests` row, or `tests.track_id` is null — the diagnostic case). The ledger column is nullable and stays null on this path. BE5 will do better for diagnostic sessions when it wires `diagnostic_field_progress`.

### `upsertQuestionUserPivot` — pivot row at session settle-time

```php
private function upsertQuestionUserPivot(
    int $userId,
    int $sessionId,
    int $questionId,
    int $attemptNumber,
    bool $isCorrect,
    int $kudosAwarded,
): void {
    $session = DB::table('assessment_sessions')->find($sessionId);

    $existing = DB::table('question_user')
        ->where('user_id', $userId)
        ->where('session_id', $sessionId)
        ->where('question_id', $questionId)
        ->first();

    $newKudos = (int) (($existing->kudos ?? 0) + $kudosAwarded);

    DB::table('question_user')->updateOrInsert(
        ['user_id' => $userId, 'session_id' => $sessionId, 'question_id' => $questionId],
        [
            'test_id'           => $session->test_id ?? null,
            'test_type_id'      => $session->test_type_id ?? 1,
            'question_answered' => 1,
            'correct'           => $isCorrect ? 1 : 0,
            'answered_date'     => now(),
            'attempts'          => $attemptNumber,
            'kudos'             => $newKudos,
            'updated_at'        => now(),
            'created_at'        => $existing ? ($existing->created_at ?? now()) : now(),
        ]
    );
}
```

Keys on `(user_id, session_id, question_id)` — matches the existing `uq_question_user_session` UNIQUE. `test_type_id` is `NOT NULL` on the pivot schema with no default; falls back to `1` (the legacy generic learning test type) when the session row doesn't carry one. Kudos accumulates if a row already exists (defensive: in practice the pivot is only written on `$isFinal`, but this makes the helper safe to call from other code paths in BE5+).

---

## §5 — Kudos formula choice

**Chosen: inline `($question->difficulty_id ?? 0) + 1`. Do NOT call `KudosService::calculateKudos`.**

Two formulas exist in the codebase today:

| Source | Formula | Used by |
|---|---|---|
| `AnswerValidationService::checkAnswer` (lines 64-66) | `$isCorrect ? ($question->difficulty_id ?? 0) + 1 : 1` | All three live submit endpoints (`AnswerProcessingService`, `DiagnosticController`) |
| `KudosService::calculateKudos` | base + `difficulty_id × multiplier` + streak bonus + time bonus, all gated by per-partner config in `config/partners.php` | Only `app/Http/Controllers/AnswerController.php:165` — the **commented-out legacy** AnswerController, off the active route map |

`KudosService` is dead on the live answer path. Activating it now would silently change kudos values for every existing submit endpoint at the same time (since `AnswerValidationService` is shared). That's a behavioural change with product implications — out of Phase 1B scope.

The clean reconciliation belongs in **Phase 1.5 housekeeping**:
- Pick one formula (likely `KudosService` for its partner-config flexibility).
- Migrate `AnswerValidationService` to call it.
- Delete the legacy `AnswerController`.
- Delete the redundant kudos accumulator in `AnswerProcessingService`.

For BE4, matching the inline rule keeps `/api/answers` consistent with what `tracks/{track}/answers` and `kiasu-path/submit` award today. A user playing in either flow gets the same kudos arithmetic.

---

## §6 — Response shape — mutated state

Comparison vs BE3:

| Block | Field | BE3 (read-only) | **BE4 (mutated)** |
|---|---|---|---|
| `lives` | `current` | regen-projected from queue | **post-regen, post-deduct value from `$userLocked->fresh()->lives`** |
| `lives` | `deducted_this_attempt` | always `false` | **`true` only on final-attempt-wrong with lives system enabled** |
| `lives` | `next_life_in_seconds` / `_at` | from current queue | **from updated queue (deductLife appended a future timestamp if it ran)** |
| `kudos` | `awarded_this_attempt` | always `0` | **`($difficulty + 1)` on correct; `0` on wrong** |
| `kudos` | `user_total` | pre-call value | **post-increment value** |
| `maxile` | `delta` | always `0` | **still `0`** — algorithm is Phase 2; no per-answer maxile in 1B |
| `maxile` | `user_total_before` / `_after` | both = current | **both = current** (no per-answer maxile in 1B) |
| `maxile` | `field_id` | always `null` | **derived per §4 (may still be null for sessions without a track)** |
| `maxile` | `field_total_before` / `_after` | both `null` | **`user_field_levels.current_level` read for `(user, field_id)` if field_id resolved; both equal (no write in 1B)** |
| `maxile` | `level_up` | `null` | **`null`** (no field_total mutation) |
| `session` | `items_recorded` | pre-call value | **post-increment value (incremented only when `isFinal`)** |
| `session` | `completed` | always `false` | **still `false`** — BE5 owns completion detection |
| `session` | `summary_url` | `null` | `null` — BE5 |
| `premium_gate` | – | `null` | **`{feature: 'practice', upgrade_url: '/api/subscription/upgrade-info'}` when post-state `!unlimited && current === 0`** |

---

## §7 — Tests (per-case)

`tests/Feature/AnswerGradingTest.php`. 10 cases total: 3 BE3 preserved, 7 BE4 new (1 skipped). 73 assertions, 0 failures.

The schema setup adds five tables on top of BE3's set: `tests`, `tracks`, `question_user`, `user_field_levels`, plus `test_type_id` on `assessment_sessions`. `setUp()` now defaults to **lives system enabled** (the existing BE3 cases pass either way; BE4 cases need it on by default), with `enableLivesSystem()` / `disableLivesSystem()` helpers for opt-in.

| # | Case | Assertions |
|---|---|---|
| BE3-a | `test_first_correct_answer_returns_200_with_attempts_remaining_zero_and_correct_answer_revealed` | 200; `is_correct=true`; `attempts_remaining=0`; `correct_answer.{type, selected_option}`; one ledger row `attempt_number=1, is_correct=1` |
| BE3-b | `test_first_wrong_answer_returns_200_with_attempts_remaining_one_and_correct_answer_null` | 200; `is_correct=false`; `attempts_remaining=1`; `correct_answer=null`; ledger row `attempt_number=1, is_correct=0` |
| BE3-c | `test_third_attempt_returns_412` | 412; `error=max_attempts_exceeded`; `attempts_remaining=0`; ledger count remains 2 |
| BE4-a | `test_attempt_1_wrong_does_not_deduct_life` | lives stays 5; `deducted_this_attempt=false`; pivot row absent (not final); item_count stays 0 |
| BE4-b | `test_attempt_2_wrong_deducts_one_life` | lives 5→4; `deducted_this_attempt=true`; pivot upserted with `correct=0, attempts=2`; item_count incremented |
| BE4-c | `test_attempt_1_correct_awards_kudos_no_life_deduction` | difficulty=3 ⇒ kudos `+4`; user.kudos 10→14; lives unchanged at 5; pivot `correct=1, kudos=4, attempts=1`; item_count=1 |
| BE4-d | `test_attempt_2_correct_awards_kudos_no_life_deduction` | After preset attempt-1-wrong: kudos `+4`; lives unchanged at 5 (no deduction even though one attempt was wrong); pivot `correct=1, attempts=2` |
| BE4-e | `test_unlimited_user_loses_no_lives_on_final_wrong` | Disables lives system. After preset attempt-1-wrong + attempt-2-wrong: `lives.unlimited=true`, `deducted_this_attempt=false`, raw `users.lives` column unchanged |
| BE4-f | `test_concurrent_attempts_dont_race_past_the_guard` | **Skipped** with documented reason (single-connection PHPUnit harness can't simulate the race; DB-level UNIQUE from BE2 is the actual safety net — verify in staging/parallel-process suite) |
| BE4-g | `test_premium_gate_appears_when_lives_hit_zero` | After preset attempt-1-wrong + attempt-2-wrong with starting lives=1: post-state `lives.current=0`, `premium_gate.feature='practice'`, `premium_gate.upgrade_url='/api/subscription/upgrade-info'` |

---

## §8 — Verify

| Check | Result |
|---|---|
| `composer dump-autoload` | `Generated optimized autoload files containing 9821 classes` |
| `php -d extension=pdo_sqlite -d extension=sqlite3 vendor/bin/phpunit --filter=AnswerGradingTest` | `OK, but some tests were skipped! Tests: 10, Assertions: 73, Skipped: 1` |
| Phase 1A + 1B combined (`AnswerGradingTest\|IdempotencyMiddlewareTest\|LivesRegenerationTest\|OtpSignupRoleAssignmentTest`) | `OK, but some tests were skipped! Tests: 16, Assertions: 93, Skipped: 1` |
| `php artisan route:list` total | **240** (unchanged from BE3) |
| Middleware chain on `POST api/answers` | `api → Authenticate:sanctum → ThrottleRequests:60,1 → EnforceIdempotencyKey` (no second throttle) |

---

## §9 — Operational note

> **BE4 makes /api/answers fully grading.** Lives are deducted on final-attempt-wrong, kudos awarded on correct, the `question_user` pivot upserted on final attempt, and `assessment_sessions.item_count` incremented on final attempt — all inside one `DB::transaction`, with `lockForUpdate` on the user row and on the existing `attempt_ledger` rows for `(session_id, question_id)`.
>
> **Until BE5 ships:** the response's `session.completed` is always `false` and there is no diagnostic `field_progress` block. Diagnostic-flow callers will need to keep using `POST /api/diagnostic/submit` for end-of-test reconciliation; per-question recording works via `/api/answers` from now on.
>
> **Until BE6 ships:** question payloads at `tracks/{track}/questions`, `kiasu-path/start`, and the diagnostic question-batch endpoint **still include `correct_answer`**. The leak isn't closed yet; clients that grade locally will continue to work. The new endpoint is sufficient for clients that *don't* trust the local grade — no migration is forced on them.
>
> **Production deploy ordering:** Phase 1B BE3 + BE4 can ship together. The endpoint exists from BE3; BE4 turns its mutations on. No migrations needed (BE2 already added the `attempt_number` column and unique index).

---

## §10 — NOT in scope (deferred)

- Per-mode response specifics: diagnostic `field_progress` recording, kiasu cursor advance, session.completed detection, summary_url — **BE5**
- `correct_answer` payload stripping with `X-Client-Version` gating across formatters — **BE6**
- KudosService consolidation (two divergent formulas exist) — **Phase 1.5**
- WritesAttemptLedger trait unification with the raw insert in this service — **Phase 1.5**
- Maxile per-answer delta algorithm — **Phase 2**
- Stripe webhook bypass of `LiveService` — **Phase 1D**
- Frontend changes (FE1 onwards) — out of all backend phases
