# `/api/answers` rewrite — design

**Status**: design draft, awaiting review before implementation
**Date**: 2026-05-23
**Scope**: single grading orchestrator that replaces the four parallel grading paths in production today

---

## Why

The current grading surface is four parallel implementations:

| Endpoint | Controller | Service |
|---|---|---|
| `POST /api/tracks/{id}/answers` | `API\TrackController::postAnswers` | `AnswerProcessingService::processAnswers` |
| `POST /api/kiasu-path/submit` | `KiasuController::postAnswers` | `AnswerProcessingService::processAnswers` |
| `POST /api/answers` (Phase 1B) | `API\AnswerController::store` | `AnswerGradingService::grade` |
| `POST /api/diagnostic/submit` | `DiagnosticController::submitAnswers` | inline |

Plus two **dead-code** services that still ship: `app/Services/DiagnosticService.php`,
`app/Services/KudosService.php`.

Real bugs observable today (verified empirically, see commit history and
`STREAKS.md` / `MAXILE.md` / `MASTERY.md`):

1. **Two cascade systems** (`Question::processProgressFor` and
   `MaxileService::updateMaxilesFromQuestions`) both write
   `users.maxile_level`. No reconciliation.
2. **Phase 1B `/api/answers` runs neither cascade**. Maxile delta is
   always 0; the response is stale.
3. **Cascade picks draft tracks**
   (`Question::processProgressFor:285-289` has no `status_id = 3` filter).
   A Primary 4 track-practice answer cascades to a Primary 6 Draft
   track's field.
4. **`total_correct_attempts` lost-write** across iterations in the
   same batch. Streak counters are maintained, but the lifetime
   counter only increments once per batch.
5. **`field_user` "monotonic" guard fails across month boundaries**.
   Eloquent's `syncWithoutDetaching` ignores the `(user, field,
   month_achieved)` composite PK and overwrites the older row,
   including the month column. A user with field maxile 300 in
   October can have it overwritten to 285 in May.
6. **Three batch controllers + per-tap controller**, each repeating
   phases 1–4 inconsistently.
7. **Kudos partner config (`partners.php` `kudos.*`)** points at
   unreachable code (`KudosService::calculateKudos`). Flipping the
   `streak_bonus_enabled` flag has no runtime effect.

This design replaces the four paths with one orchestrator. The
controllers stay (route compatibility) as thin shims.

---

## Goal

One grading path, one cascade, mode-aware branching only where it must
differ. The orchestrator owns the four phases:

1. **Log** — append-only record of inbound submission.
2. **Grade** — compute correctness, apply user-state side effects
   (lives, kudos, pivot).
3. **Cascade** — mastery state machine and maxile recompute.
4. **Advance** — per-mode session progression (complete / next batch).

Plus a **Step 0** pre-gate (auth, validation, idempotency, attempt cap)
and a **Step 5** response shaper.

---

## The steps

### Step 0 — Pre-gate

Runs before any state mutation.

- **0.1 Authenticate** via Sanctum. Reject **401** if no user.
- **0.2 Validate payload** against shape:
  `session_id`, `question_id`, `mode`, `answer.{type, selected_option | fields}`.
  Reject **422** on mismatch.
- **0.3 Ownership check.** `assessment_sessions.user_id == auth user`.
  Reject **403** otherwise.
- **0.4 Idempotency check.** Read `Idempotency-Key` header (or derive
  server-side from `(user, session, question, attempt_number)`). If a
  completed response exists for this key, return it verbatim — don't
  re-grade.
- **0.5 Attempt-cap check.** Count existing `attempt_ledger` rows for
  `(session, question)` with `lockForUpdate`. If `count + 1 >
  max_attempts_for_mode`, return **412** — no state change.
- **0.6 Acquire user lock.** `User::lockForUpdate()->find(user.id)`
  held through Step 3. Single source of truth for concurrent grades.

### Step 1 — Log

Append-only, no business decisions.

- **1.1 Derive context once**: `skill_id`, `track_id`, `field_id` from
  question + session.
- **1.2 Insert into `attempt_ledger`**: `session_id`, `question_id`,
  `user_id`, `attempt_number`, `answer_given` (JSON), `received_at`,
  `skill_id`, `track_id`, `field_id`. Grade fields (`is_correct`,
  `graded_at`) left NULL.
- **1.3 Return `ledger_id`** for Step 2 to update in place.

> The DB UNIQUE constraint on `(session, question, attempt_number)`
> makes this insert race-safe against the Step 0.5 attempt-cap check.

### Step 2 — Grade and apply user-state side effects

- **2.1 Grade** (pure, no I/O):
  `AnswerValidator.grade(question, answer)` → `{ is_correct, correct_answer }`.
  - MCQ: int compare on `correct_answer`.
  - FIB: per-slot `trim` + `strtolower` compare against `answer0..answer3`.
- **2.2 Update ledger row**: `is_correct`, `graded_at = now()` on the
  row from Step 1.
- **2.3 Compute `isFinal`**: `is_correct OR attempt_number == max_attempts_for_mode`.
- **2.4 Apply user-state mutations** (only on `isFinal`):
  - **Lives**: if `!is_correct && !user.unlimited && mode != diagnostic` →
    `user.lives--` (floor 0).
  - **Kudos**: `user.kudos += kudosFor(is_correct, question.difficulty_id, mode)`.
    - `mode == diagnostic` → award 0.
    - Otherwise: `is_correct ? (difficulty_id ?? 0) + 1 : 1`.
  - **Pivot upsert** `question_user` settled-answer row keyed on
    `(user, session, question)`. Mid-attempt-wrongs do not write the
    pivot.
- **2.5 Advance session counter**:
  `assessment_sessions.item_count += 1` on `isFinal` only.

> Steps 1 + 2 in one DB transaction. Failure here rolls back, ledger
> stays "received but ungraded" so a retry re-grades cleanly.

### Step 3 — Cascade (mastery state machine → maxile)

Runs only on `isFinal`. Single `MaxileCascade.run(user, question,
is_correct, test)`.

- **3.1 Mastery state machine — `skill_user`**:
  - Increment `noOfTries`, plus `total_correct_attempts` or
    `total_incorrect_attempts`.
  - Update `correct_streak` / `wrong_streak`.
  - **Upgrade rule**: if `correct && question.difficulty > difficulty_passed
    && correct_streak >= passThreshold` → `difficulty_passed = question.difficulty`,
    reset `correct_streak = 1`.
  - **Demote rule**: if `!correct && question.difficulty <= difficulty_passed
    && wrong_streak >= failThreshold` → `difficulty_passed = max(0, difficulty_passed - 1)`,
    reset `wrong_streak = 1`.
  - Set `skill_passed = difficulty_passed >= tierCount`.
- **3.2 Skill maxile** (interpolated, monotonic):
  - `computed = level.start + (difficulty_passed / tierCount) * (level.end - level.start)`;
    cap at `level.end` if `skill_passed`.
  - `skill_user.skill_maxile = max(existing, computed)`.
  - **Track selection**: `skill.tracks.where(status_id, 3).orderByDesc(level.start).first()`
    — only public tracks. **Fixes draft-track bug.**
- **3.3 Track maxile** (live):
  - Re-count `passedSkills` for the chosen track from `skill_user`.
  - `track_passed = (passedSkills == totalSkills)`.
  - `track_maxile = passed ? level.end : level.start + (passedSkills/totalSkills) * range`.
  - Write via direct `DB::table('track_user')->updateOrInsert(...)` —
    not Eloquent pivot sync.
- **3.4 Field maxile** (monotonic per (user, field, current month)):
  - `avgTrackMaxile = avg(track_maxile across user's public tracks in field)`.
  - Direct `updateOrInsert` keyed on `(user_id, field_id, current_month)`.
    **Fixes Eloquent composite-PK bug.**
  - Guard: only write if `avgTrackMaxile > existing`.
- **3.5 User maxile**:
  - `users.maxile_level = avg(MAX(field_maxile) per field)` across user's
    positive `field_user` rows.

- **3.6 Mode-specific per-field cursor update (kiasu only)**:
  - Find or create `kiasu_field_progress` row for `(user, field_id)`
    where field_id is the cascade-chosen field (Step 3.2's
    `track.field_id` for the public track picked for this skill).
  - Apply threshold state machine — mirrors the skill cascade in 3.1
    but at the per-(user, field) cursor:

    ```text
    if correct:
        correct_streak++; wrong_streak = 0
        if correct_streak >= Config::passThreshold():
            current_level = nextPublicLevelUp(field, current_level)
            correct_streak = 1   # reset (counts the streak-completing answer)
    else:
        wrong_streak++; correct_streak = 0
        if wrong_streak >= Config::failThreshold():
            current_level = nextPublicLevelDown(field, current_level)
            wrong_streak = 1
    ```

  - `nextPublicLevelUp` / `nextPublicLevelDown` use
    `AdaptiveLevelService::getNextLevelUp` / `getNextLevelDown` against
    the field's public levels. Clamped at field min/max.
  - This cursor drives the next batch's `FieldRoundSelector` query for
    this field (see [tests-start-design](tests-start-design-2026-05-23.md#shared-fieldroundselector-diagnostic--kiasu)).
  - **Diagnostic does NOT run this step.** Its cursor lives in
    `diagnostic_field_progress` and moves on the one-shot boundary
    rule inside Step 4 (advance), not here. Track does not run this
    step either.

> Wrapped in its own transaction. Failure logged but does NOT roll back
> Step 2 — Phase 1B's "answer save survives cascade failure" contract
> preserved.

### Step 4 — Advance session (mode-aware)

`SessionAdvancer.next(session, mode)`:

- **mode = `track`**:
  - Count unanswered `question_user` for this test.
  - If 0 → `tests.completed = true`, write `test_score`, set `summary_url`,
    **recompute `users.maxile_level`** (see "Completion-time user maxile recompute" below).
  - Else → return remaining unanswered.
- **mode = `kiasu`**:
  - If `answered_count == questions_per_test` → complete the test,
    **recompute `users.maxile_level`** (see below).
  - Else if `uncompleted_count == 0 && answered_count < questions_per_test` →
    top up via `KiasuPathService::getKiasuPathQuestions`, return new batch.
  - Else → return remaining batch.
- **mode = `diagnostic`**:
  - Run boundary-IRT walk on the field for this question (current logic
    from `DiagnosticController:276-362`, lifted into the service).
  - If every public field has `completed = true` → write
    `assessment_sessions.{status='completed', end_maxile}`, set
    `users.maxile_level = avg(final_level across fields)`, set `summary_url`.
  - Else → return one question per still-incomplete field.

#### Completion-time `users.maxile_level` recompute (non-diagnostic)

When a `track` or `kiasu` test completes (and only at that moment), run
a fresh canonical recompute of `users.maxile_level`:

```text
users.maxile_level = avg(MAX(field_maxile) per field
                         across user's field_user rows
                         where field_maxile > 0)
```

This is the same formula Step 3.5 runs per-answer; the completion-time
write is the **canonical settlement** that's guaranteed to reflect every
answer in the test (no race, no partial-cascade weirdness, no
stale-cache risk). Diagnostic settles via its own field-finals average
on completion and **does not** go through this path.

Default interpretation: this is **additive**. Step 3.5 keeps writing
`users.maxile_level` per-answer for live freshness; Step 4 overwrites
with the canonical value on completion. Replace-mode alternative
(skip Step 3.5's user-level write, only settle on completion) is in
Open Questions.

### Step 5 — Build response

Single shape, regardless of mode:

```json
{
  "is_correct": bool,
  "attempts_remaining": int,
  "correct_answer": null | { "type": "mcq", "selected_option": int }
                       | { "type": "fib", "fields": [str, str, str, str] },
  "kudos":  { "awarded_this_attempt": int, "user_total": int },
  "lives":  { "current": int, "max": int, "unlimited": bool,
              "deducted_this_attempt": bool,
              "next_life_in_seconds": int|null,
              "next_life_at": iso8601|null },
  "maxile": { "delta": int,
              "user_total_before": float, "user_total_after": float,
              "field_id": int|null,
              "field_total_before": int|null, "field_total_after": int|null,
              "level_up": null | { "level": int, "name": str } },
  "session": { "id": int, "mode": str, "items_recorded": int,
               "completed": bool, "summary_url": str|null },
  "next_questions": [...] | null,
  "premium_gate": null | { "feature": str, "upgrade_url": str }
}
```

`correct_answer` is revealed only on `isFinal`. Strip per
`X-Client-Version` gate (Phase 1B BE6 deferred until clients adopt the
new version format).

---

## Per-mode branching matrix

| Concern | Track | Kiasu | Diagnostic |
|---|---|---|---|
| Step 1 log | same | same | same |
| Step 2.1 grade | MCQ/FIB | MCQ/FIB | MCQ only |
| Step 2.4 lives | deduct if free user | unlimited (premium gate) | **never deduct** |
| Step 2.4 kudos | award | award | **award 0** |
| Step 3 cascade | full skill→track→field→user | full skill→track→field→user **+ kiasu per-field cursor (3.6)** | **field-only** (no skill/track update; cursor moves inline in Step 4) |
| Step 4 advance | complete when 0 unanswered | refill batch up to `questions_per_test` | boundary-IRT walk, one Q per incomplete field |
| Completion-time `users.maxile_level` recompute | **yes** — canonical settle | **yes** — canonical settle | no (settles via field-finals avg already) |
| Max attempts per question | 1 | 1 | 1 |

Per-tap learning mode (today's `mode: "track"` on `/api/answers`) is the
exception — `max_attempts = 2` (free retry, lives-deducting final).
Decide whether to keep this or unify at 1 per question — see Open
Questions.

---

## Persistence model

| Table | Owned by | Write semantics |
|---|---|---|
| `attempt_ledger` | Step 1 insert + Step 2.2 update | Append-only insert; one update for grade outcome. Idempotent on `(session, question, attempt_number)`. |
| `question_user` | Step 2.4 | Upsert on `isFinal` only. Represents settled answer. |
| `users.kudos`, `users.lives` | Step 2.4 | Increment/decrement only. Locked via `User::lockForUpdate` from Step 0.6. |
| `assessment_sessions.item_count` | Step 2.5 | Increment on `isFinal` only. |
| `skill_user`, `track_user`, `field_user` | Step 3 | Read-modify-write inside cascade transaction. Composite-PK aware. |
| `kiasu_field_progress` | Step 3.6 (kiasu only) | New table. Per-`(user, field)` cursor + streak counters. See [tests-start-design](tests-start-design-2026-05-23.md#new-table--kiasu_field_progress). |
| `users.maxile_level` | Step 3.5 | Final write of cascade. |
| `assessment_sessions.status`, `completed_at`, `end_maxile`, `summary_url` | Step 4 | Set on completion. |
| `tests.completed`, `test_score`, `kudos_earned` | Step 4 (track + kiasu) | Set on completion. |

---

## Cross-cutting contracts

| Concern | Contract |
|---|---|
| Transactions | Step 1 + 2 in one tx (answer save). Step 3 in its own tx. Step 4 in its own tx. |
| User locking | `User::lockForUpdate` taken in Step 0.6, held through Step 3. Released after. |
| Failure | Step 0 fail → 4xx, no state. Step 1 fail → 5xx, no state. Step 2 fail → rollback both, ledger row gone. Step 3 fail → answer save survives, cascade logged. Step 4 fail → answer + cascade survive, no next-batch returned (client refetches). |
| Idempotency | Server-derived key from `(user, session, question, attempt_number)` if header absent. Replay of completed request returns cached response from Step 5. |
| Concurrency | Two simultaneous grades for the same user serialize on `User::lockForUpdate`. Both run, both update lives/kudos/maxile in order. |

---

## What changes vs today

| Bug | Fix |
|---|---|
| Two cascade systems both writing `users.maxile_level` | One `MaxileCascade` service. `MaxileService` deleted. |
| Phase 1B endpoint runs neither cascade | Step 3 runs unconditionally on `isFinal`. Maxile in response is fresh. |
| Cascade picks DRAFT tracks | Step 3.2 adds `where(status_id, 3)` to the track selection. |
| `total_correct_attempts` lost-write across batch | Use atomic `DB::table('skill_user')->where(...)->increment(...)` for counters, or read-with-lock pattern. Read-modify-write in the same loop iteration is the bug; counters move to atomic increments. |
| `field_user` monotonic guard breaks across month boundaries | Step 3.4 uses direct `updateOrInsert` keyed on `(user_id, field_id, current_month)` — Eloquent pivot sync replaced. |
| Three batch controllers + per-tap, each repeating phases 1-4 | Controllers stay as route shims (5 lines each), all four call into the single orchestrator. |
| `KudosService` and `DiagnosticService` are dead code | Delete both. |
| Kudos partner config points at unreachable code | Delete the `partners.php` `kudos.*` block. Partner config retains `lives.*` only. |
| FIB normalization documented as "two coexisting behaviors" | One normalization: trim + lowercase. The legacy `Question::correctness` is dead — delete. |

---

## Open questions

1. **Per-tap or batch as canonical?**
   Phase 1B's per-tap shape is cleaner. Track + kiasu still send batches.
   **Recommend**: orchestrator accepts both shapes; batch is internally
   looped as N per-tap operations, each in its own transaction. Batch
   failure half-way leaves clean partial state.

2. **Cascade trigger location.**
   Synchronous (current) vs queued. Queueing makes per-tap fast but
   adds eventual-consistency on `users.maxile_level`.
   **Recommend**: synchronous, with try/catch escape hatch.

3. **Idempotency key — required or derived?**
   Today Phase 1B requires it via middleware; other paths don't.
   **Recommend**: server-side derivation if header absent. Client may
   still send for stronger guarantees.

4. **Mode source.**
   Today `/api/answers` reads `mode` from the request payload. Could
   instead derive from `session.test_type_id`.
   **Recommend**: derive from session, ignore client-supplied mode —
   removes one way for client and server to disagree.

5. **Attempt cap per mode.**
   Today Phase 1B per-tap allows 2 attempts; batch endpoints allow 1.
   **Recommend**: keep current behavior, but make it explicit and
   per-mode configurable in one place.

6. **Diagnostic's `users.maxile_level` write.**
   Today Step 4 (diagnostic completion) writes `users.maxile_level =
   avg(final_level across fields)` directly, bypassing the cascade.
   Should it instead write `field_user` rows and let `users.maxile_level`
   be derived in Step 3.5? **Recommend**: write field_user, let derived
   value flow up — one path for `users.maxile_level`, not two.

7. **Non-diagnostic completion-time recompute: additive or replace?**
   - **Additive (default)**: Step 3.5 keeps writing `users.maxile_level`
     per-answer for live freshness mid-test; Step 4 overwrites with the
     canonical value on completion. Users see their maxile move during
     the test; completion settles to the exact value.
   - **Replace**: skip Step 3.5's user-level write for non-diagnostic
     (still updates skill/track/field per answer). Only Step 4
     completion writes `users.maxile_level`. Cheaper per-answer
     (one fewer DB round-trip), cleaner separation of "per-answer
     progress" vs "settled user score". Downside: a user who never
     finishes a test never sees their maxile move at the top level.
   - **Recommend**: additive — the per-answer write is cheap and the
     "I answered, my score went up" feedback loop is product-valuable.
     Replace-mode would require a UX decision (do we hide mid-test
     maxile changes?).

---

## Implementation phases (suggested)

Each phase is independently shippable:

1. **Phase 0 — fix the high-impact bugs in place** (no architectural
   change): draft-track filter, `total_correct_attempts` atomic
   increment, field_user composite-PK fix. Tests added for each.
2. **Phase 1 — extract `MaxileCascade` service** (replaces
   `Question::processProgressFor` and `MaxileService::updateMaxilesFromQuestions`).
   Single source of truth for the cascade. All four current paths
   still work, they just delegate.
3. **Phase 2 — extract `AnswerOrchestrator`** with Steps 0-5.
   Controllers become 5-line shims. Per-mode logic isolated to Step 4.
4. **Phase 3 — delete dead code**: `KudosService`, `DiagnosticService`,
   `partners.php` `kudos.*` block, legacy `AnswerController`,
   `Question::correctness`.
5. **Phase 4 — consolidate response shape**. Batch endpoints start
   returning the unified shape from Step 5 (additive, doesn't break
   Flutter). Once Flutter adopts, drop the legacy fields.

---

## Out of scope (named so they don't surface as bugs later)

- `users.maxile_level` recomputed across all historical activity on
  schema change. If `tierCount` or level ranges change, existing user
  maxiles need a backfill — not covered here.
- Skill or field deletion. If a content edit removes a skill the user
  has progress in, the cascade's `passedSkills / totalSkills` ratio
  shifts — no current handling.
- Multi-tenant partner config beyond `default | telco | schools`. If
  partner config grows, the lookup may need to move to DB.

---

## Decisions needed before implementation starts

- Lock in the six Open Questions above.
- Confirm the per-mode branching matrix matches product intent
  (especially: diagnostic awards 0 kudos, diagnostic never deducts
  lives, diagnostic cascade is field-only, non-diagnostic completion
  triggers `users.maxile_level` canonical recompute).
- Confirm the implementation-phases order, especially Phase 0's
  in-place bug fixes — these can ship ahead of the architectural work
  if there's appetite for quick wins.
