# AllGifted Math — Backend Reference

**Generated 2026-05-24.** This file is the engineering ground-truth for the AllGifted Math Laravel API. It supersedes any conflicting content in `STREAKS.md`, `MAXILE.md`, `MASTERY.md`, `ANSWER_GRADING.md`, `QUESTION_ASSIGNMENT.md`, `CONFIGURATION.md` — those are kept for topical depth but defer to this file on disputes. Citations are `file:line` against the working tree at this commit; if you find a mismatch, the code wins and this doc needs an update.

---

## 1. Stack & system boundaries

| Layer | Choice |
|---|---|
| Runtime | PHP 8.2.28 |
| Framework | Laravel 11 |
| DB | MySQL 8 |
| Web | Apache 2 (prod) / `php artisan serve` (dev) |
| Mobile auth | Sanctum bearer tokens (SHA-256 hashed at rest) |
| Web admin auth | OTP only — no passwords (`feedback_admin_otp_no_password.md`) |
| Admin UI | Filament v3 at `/cp` + legacy Bootstrap admin at `/admin` (being phased out) |
| Errors | Sentry Laravel SDK, wired via `bootstrap/app.php` → `\Sentry\Laravel\Integration::handles($exceptions)` |
| Hosting | DigitalOcean droplet, prod at `https://mathapi.allgifted.com` |

**Repo invariants** (`CLAUDE.md`):
- `.env`, `.env.save`, `.env.*` are gitignored
- `configs.mail_*` columns must stay NULL (SMTP comes from `.env`)
- No `ShouldBeEncrypted` jobs — keeps `APP_KEY` rotation cheap
- Sanctum tokens are key-independent — APP_KEY rotation does not invalidate them
- Production server is git-read-only (pull + restart; never commit)

---

## 2. API catalog

All routes from `routes/api.php`. Public routes have no middleware; protected routes go through `auth:sanctum` + `throttle:60,1`.

### 2.1 Auth

| Method | Path | Controller | Notes |
|---|---|---|---|
| POST | `/api/auth/request-otp` | `OTPController::sendOtp` | `throttle:otp-attempts` (5/min by IP). Body: `contact` (email or phone), `channel` (email/sms/whatsapp). Sends OTP via `OTPService::issue()` + `dispatch()`. |
| POST | `/api/auth/verify-otp` | `OTPController::verifyOtp` | `throttle:otp-attempts`. Body: `contact` (or `identifier` for legacy), `otp_code` (6 digits). Returns `{ token, user }` where `token` is a Sanctum plaintext token (`<id>\|<random>`). |

OTP storage: hashed in `users.otp_code`, expiry in `users.otp_expires_at` (5 min TTL). `OTPService::issue()` (`app/Services/OTPService.php:204`) generates a 6-digit code, stores its bcrypt hash, returns plaintext for transport. `OTPService::dispatch()` ships via SMTP (always emails if email present) and optionally Twilio SMS/WhatsApp.

### 2.2 Tests, summary, lives

| Method | Path | Controller / Service | Notes |
|---|---|---|---|
| POST | `/api/tests/start` | `API\TestsController::start` → `TestStartOrchestrator::start` | Unified mode-dispatched. Body: `mode` (`track`\|`diagnostic`\|`kiasu`), `track_id` (required when `mode=track`). |
| GET | `/api/tests/{id}/summary` | `API\TestSummaryController::show` | Ownership-gated. Returns mode-specific body (track: score+accuracy; diagnostic: per-field final_levels + end_maxile). |
| POST | `/api/lives/purchase` | `LivesController::createPurchaseIntent` | Creates Stripe PaymentIntent for lives packages. |
| GET | `/api/me` | inline closure (routes/api.php:57) | Returns `{ id, name, firstname, lastname, email, phone_number, date_of_birth, birth_year, maxile_level, game_level, last_test_date, created_at, updated_at }`. |
| GET | `/api/user/profile` | `HomeController::profile` | Fuller profile shape used by the Flutter Settings page. |
| GET | `/api/user/subscription-status` | `HomeController::subscriptionStatus` | Subscription expiry + plan. |

### 2.3 Per-tap answer grading (Phase 1B)

| Method | Path | Controller / Service | Notes |
|---|---|---|---|
| POST | `/api/answers` | `API\AnswerController::store` → `AnswerGradingService::grade` | Middleware: `idempotent` (Idempotency-Key required). Authoritatively grades a single answer, runs the maxile cascade, advances the session, returns a rich body. Schema in 3.2 below. |

Server-side grading is **non-negotiable** — never trust client-supplied `is_correct`. The endpoint inspects `question.correct_answer` (MCQ) or `answer0..3` (FIB) directly.

### 2.4 Diagnostic (legacy + new)

The unified `/api/tests/start` (mode=diagnostic) + `/api/answers` is the path forward. These legacy endpoints stay live until the Flutter client cuts over (Stage B).

| Method | Path | Controller | Notes |
|---|---|---|---|
| GET | `/api/diagnostic/status` | `DiagnosticController::getStatus` | Current cooldown, eligibility. |
| POST | `/api/diagnostic/start` | `DiagnosticController::start` | Legacy entry. |
| POST | `/api/diagnostic/submit` | `DiagnosticController::submitAnswers` | Legacy batch submit. |
| GET | `/api/diagnostic/result` | `DiagnosticController::getResult` | Current session result. |
| GET | `/api/diagnostic/last` | `DiagnosticController::lastResult` | Last completed session result. |
| GET | `/api/diagnostics/last` | (same) | Plural alias for the cooldown screen's "View Last Results". |
| POST | `/api/diagnostic/abandon/{sessionId}` | `DiagnosticController::abandonDiagnostic` | Cancel an in-progress session. |
| POST | `/api/diagnostic/hint` | `DiagnosticController::storeHint` | **Public** — pre-auth hint event log. |

### 2.5 Kiasu Path (legacy + new)

| Method | Path | Controller | Notes |
|---|---|---|---|
| GET | `/api/kiasu-path/start` | `KiasuController::startKiasuPath` | Legacy entry. |
| POST | `/api/kiasu-path/submit` | `KiasuController::postAnswers` | Legacy batch submit. |

### 2.6 Track endpoints (legacy)

| Method | Path | Controller | Notes |
|---|---|---|---|
| GET | `/api/tracks` | `API\TrackController::index` | List public tracks. |
| GET | `/api/tracks/{track}/questions` | `API\TrackController::getQuestions` | Question set for a track (pre-Phase-1B). |
| POST | `/api/tracks/{track}/answers` | `API\TrackController::postAnswers` → `AnswerProcessingService` | Legacy batch submit. Will be retired once Flutter clients adopt per-tap. |

### 2.7 Payments & subscriptions

All `auth:sanctum`.

| Method | Path | Notes |
|---|---|---|
| POST | `/api/subscription/create-premium` | `SubscriptionController::createPremium` |
| POST | `/api/payments/create-session` | Stripe Checkout Session. Amount derived from `subscription_plans` row — never from request body. |
| POST | `/api/payments/create-payment-intent` | Stripe PaymentIntent. Same server-derived amount rule. Sends `Idempotency-Key` to Stripe. |
| POST | `/api/payments/verify` | Verify a completed payment. |
| GET | `/api/payments/status/{transactionId}` | Status check. |
| GET | `/api/payments/plans` | List plans. |
| GET | `/api/payments/plans/{planId}` | One plan. |

### 2.8 Stripe webhook (public)

| Method | Path | Notes |
|---|---|---|
| POST | `/api/stripe/webhook` | `StripeWebhookController::handle`. **Public** but signature-verified via `stripe-signature` header — no bypass paths. Cross-checks amount via `expectedLivesAmountCents()` and `expectedSubscriptionAmountCents()` before crediting (`app/Http/Controllers/StripeWebhookController.php`). |

### 2.9 Question reports (user-facing)

| Method | Path | Notes |
|---|---|---|
| POST | `/api/questions/{question}/report` | `API\QuestionReportController::store`. Authenticated user flags a question; lands as a `question_reports` row. |

### 2.10 Health + Sentry

| Method | Path | Notes |
|---|---|---|
| GET | `/api/health` | `HealthController::__invoke`. Returns `{ status, checks: { database, redis, queue }, timestamp }`. Public, no throttle. |
| GET | `/api/_sentry-test` | Throws `\Exception('Sentry test')`. Temporary, used to confirm Sentry capture; remove after first event lands. |

---

## 3. Flow diagrams

### 3.1 POST /api/tests/start

```
                       ┌──────────────────────────────────────┐
POST /api/tests/start  │  TestsController::start              │
{mode, track_id?}      │     ↓ (DB::transaction)              │
                       │  TestStartOrchestrator::start        │
                       │     ↓                                │
                       │  lockForUpdate(user)                 │
                       │     ↓                                │
                       │  match(mode):                        │
                       └──────────────────────────────────────┘
                                  │
        ┌─────────────────────────┼─────────────────────────┐
        │                         │                         │
        ▼                         ▼                         ▼
  ── 'track' ──             ── 'diagnostic' ──         ── 'kiasu' ──
  validate trackId          plan-gate via                premium gate
  load Track w/             AccessControlService          (403 if not)
  skills+field+level        ::canAccessDiagnostic         │
        │                         │                       │
  resume incomplete?        30-day cooldown                resume incomplete?
  (test_type_id=2,          for non-premium                (test_type_id=1,
   completed=0)             via assessment_                completed=0)
        │                   sessions                       │
  if no: create Test              │                  if no: create Test
  (test_type_id=2)           resume in_progress           (test_type_id=1)
        │                   AssessmentSession?             │
  TrackSelector::select           │                  FieldRoundSelector
   pick N questions          if no: create Test           (mode=kiasu)
        │                    (test_type_id=3)              │
  assignQuestions            FieldRoundSelector       assignQuestions
  (question_user rows)      (mode=diagnostic)         (question_user rows)
        │                         │                       │
        └─────────────────────────┴───────────────────────┘
                                  ▼
                       buildResponse:
                       {
                         ok, test_id, session_id, mode,
                         is_new_test, questions:[...],
                         batch_size, questions_per_test,
                         lives: {current, max, unlimited, next_life_in_seconds, next_life_at}
                       }
                                  ▼
                              HTTP 200
```

Key gates per mode:
- **Track**: `track_id` required, track must have `status_id=3` AND at least one public skill.
- **Diagnostic**: `AccessControlService::canAccessDiagnostic()` consults `FeatureAccessService` → `subscription_plan.feature_limits.diagnostics`. Free users hit a 30-day cooldown vs. last completed.
- **Kiasu**: `users.access_type === 'premium'` required (403 otherwise).

### 3.2 POST /api/answers (per-tap)

```
POST /api/answers
{ session_id, question_id, mode, answer }
Idempotency-Key: <client-supplied>

    │
    ▼
┌─────────────────────────────────────────────────────────────┐
│ Idempotent middleware                                        │
│   key-hit → replay cached response, status 200               │
│   miss → continue                                            │
└─────────────────────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────────────────────┐
│ AnswerController::store                                      │
│   → StoreAnswerRequest::authorize() (owns session)           │
│   → DB::transaction {                                        │
│       AnswerGradingService::grade(user, session, question,   │
│                                    mode, answer)             │
│   }                                                          │
└─────────────────────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────────────────────┐
│ AnswerGradingService::grade                                  │
│                                                              │
│   1. mark(question, answer) → GradeResult{is_correct,       │
│         correct_answer, attempts_remaining}                  │
│      MCQ: int(selected_option) === int(correct_answer)       │
│      FIB: per-blank exact match (answer0..3, trimmed,        │
│           case-insensitive) — short-circuits on first miss  │
│                                                              │
│   2. ledger row appended to attempt_ledger                   │
│      (session_id, question_id, is_correct, mode, payload)    │
│                                                              │
│   3. upsert question_user pivot                              │
│      (user_id, session_id, question_id) PK                   │
│      sets question_answered=1, correct=is_correct,           │
│      attempts++, kudos += awarded                            │
│                                                              │
│   4. MaxileCascade::run(user, question, isCorrect, test)     │
│      see 3.5 below                                           │
│                                                              │
│   5. KudosCalculator::calculate(question, isCorrect, mode)   │
│      diagnostic → 0; else correct → difficulty+1, wrong → 0  │
│                                                              │
│   6. LiveService::deductLifeIfApplicable(user, mode,         │
│                                          isCorrect)          │
│      track/kiasu wrong → -1 life (clamped at 0)              │
│      diagnostic → no-op                                      │
│      correct → no-op                                         │
│                                                              │
│   7. SessionAdvancer::next(user, session, mode, question,   │
│                            isCorrect)                        │
│      → {completed, summary_url, next_questions}              │
│      see 3.3 / 3.4 / 3.5 below                              │
│                                                              │
│   8. return body                                             │
└─────────────────────────────────────────────────────────────┘
    │
    ▼
Response body:
{
  is_correct: bool,
  correct_answer: int|string|null,         // MCQ index OR FIB joined
  attempts_remaining: int|null,
  lives:   { current, max, unlimited, next_life_in_seconds, next_life_at },
  kudos:   { awarded_this_attempt: int, total: int },
  maxile:  { skill, track, field, user },  // post-cascade values
  session: { completed: bool, summary_url: string|null },
  next_questions: [...]|null               // if mode advanced and gave new batch
}
```

**Ghost-grade behaviour** (`reference_phase1b_ghost_grade.md`): the idempotency cache write happens AFTER the grade transaction commits. If the cache write fails, the client gets a 500 but the DB is updated. Accepted for beta; revisit before public launch.

### 3.3 Diagnostic boundary IRT walk

`SessionAdvancer::advanceDiagnostic` (`app/Services/Tests/SessionAdvancer.php:139`) + `runBoundaryWalk` (line 194). One field per round; cement per field; aggregate when all public fields cement.

```
Just answered a question in field F.
    │
    ▼
DiagnosticFieldProgress::firstOrCreate(session, field)
    initial current_level = AdaptiveLevelService::getStartingMaxile(field)
    │
    ▼
levels = SELECT DISTINCT l.* FROM levels l
         JOIN tracks t ON t.level_id = l.id
         WHERE t.field_id = F AND l.status_id=3 AND t.status_id=3
         ORDER BY start_maxile_level
    │
    ▼
currentLevel = max(l in levels where l.start_maxile_level <= current_level)
history[currentLevel.id] = isCorrect ? 'right' : 'wrong'
    │
    ▼
  ┌─ isCorrect ─────────────────────┐    ┌─ !isCorrect ────────────────────┐
  │                                 │    │                                  │
  │ nextUp = levels[idx+1]          │    │ nextDown = levels[idx-1]         │
  │                                 │    │                                  │
  │ if nextUp && history[nextUp]    │    │ if nextDown && history[nextDown]│
  │      === 'wrong':               │    │      === 'right':                │
  │   ➜ CEMENT at current.end       │    │   ➜ CEMENT at nextDown.end       │
  │      complete = true            │    │      complete = true             │
  │                                 │    │                                  │
  │ elif !nextUp:                   │    │ elif !nextDown:                  │
  │   ➜ CEMENT at current.end       │    │   ➜ CEMENT at current.start      │
  │      at_system_maximum=true     │    │      below_assessment_range=true │
  │      complete = true            │    │      complete = true             │
  │                                 │    │                                  │
  │ else: step up                   │    │ else: step down                  │
  │   current_level = nextUp.start  │    │   current_level = nextDown.start │
  └─────────────────────────────────┘    └──────────────────────────────────┘
    │
    ▼
On cement: write/update field_user(user, field, month_achieved) with
    field_maxile = final_level   (only if > existing for the month)

When every public field has cemented:
    completeDiagnostic()
      → users.maxile_level = avg(final_level across cemented fields)
      → assessment_sessions.status = 'completed', completed_at = now
      → tests.completed = 1, test_score = 100
```

### 3.4 Kiasu cursor advance (Step 3.6)

`MaxileCascade::advanceKiasuCursor` (`app/Services/Maxile/MaxileCascade.php:292`). Runs ONLY when the test is a kiasu test (`test_type_id === 1`). Reuses the same `no_rights_to_pass` / `no_wrongs_to_fail` thresholds as the skill cascade.

```
per-(user, field) row in kiasu_field_progress
    current_level, correct_streak, wrong_streak
    │
    ▼
On answer in field F:
  ┌─ correct ──────────────────────┐    ┌─ wrong ─────────────────────────┐
  │ correct_streak++                │    │ wrong_streak++                  │
  │ wrong_streak = 0                │    │ correct_streak = 0              │
  │ if correct_streak >= toPass:    │    │ if wrong_streak >= toFail:      │
  │   current_level =               │    │   current_level =               │
  │     AdaptiveLevelService::      │    │     AdaptiveLevelService::      │
  │       getNextLevelUp(cursor, F) │    │       getNextLevelDown(cursor,F)│
  │   correct_streak = 1            │    │   wrong_streak = 1              │
  └────────────────────────────────┘    └─────────────────────────────────┘
    │
    ▼
upsert kiasu_field_progress row
```

`toPass` = `Config::passThreshold()` = configs.no_rights_to_pass (default 3).
`toFail` = `Config::failThreshold()` = configs.no_wrongs_to_fail (default 2).

### 3.5 Maxile cascade — System B (live since 2026-05-24)

`MaxileCascade::run` (`app/Services/Maxile/MaxileCascade.php:67`). Called after every graded answer that reaches `isFinal` (any mode except diagnostic).

**RESPONSIVENESS contract**: NO monotonic guards at any scope. Skill, track, field, and user maxile can ALL decrease after weak recent performance. The legacy max(existing, computed) clamps were removed wholesale on 2026-05-24.

```
┌─────────────────────────────────────────────────────────────┐
│ Question Q answered (correct/wrong) by user U                │
└─────────────────────────────────────────────────────────────┘
    │
    ▼
pickTrackForSkill(Q.skill, sessionTrack=test.track):
   if sessionTrack is public AND skill belongs to it → use it
                                                       (session is the
                                                        user's context)
   else fall back to highest-level public track for the skill
    │
    ▼
field = track.field, difficulty = Q.difficulty_id
    │
    ▼
┌─ skill_user — System B skill_maxile + staircase bookkeeping ─┐
│ Direct DB read (NOT Eloquent — relation cache loses writes   │
│ in batch loops).                                              │
│                                                               │
│ Streak/tier state (still driving track_passed boolean only): │
│   noOfTries++                                                 │
│   correct: correct_streak++, wrong_streak=0                  │
│   wrong:   wrong_streak++,  correct_streak=0                 │
│   Tier-up:   correct AND diff > difficulty_passed             │
│              AND correct_streak >= toPass                     │
│              → difficulty_passed = diff; correct_streak = 1   │
│   Tier-down: wrong AND diff <= difficulty_passed              │
│              AND wrong_streak >= toFail                       │
│              → difficulty_passed -= 1; wrong_streak = 1       │
│   skill_passed = (difficulty_passed >= Difficulty::tierCount())│
│                                                               │
│ skill_maxile (System B — REPLACES staircase formula):        │
│   = MaxileService::calculateSkillMaxile(user, skill)         │
│   which computes: avg over the LAST N attempts where         │
│     each correct contributes:                                │
│       level.start + (difficulty/maxDiff) * (level.end-start) │
│     each wrong contributes:                                  │
│       level.start                                            │
│   N = configs.maxile_lookback_window (default 5)             │
│   FLOOR: if fewer than 3 attempts exist, returns level.start │
│   CLAMP: bounded to [level.start, level.end]                 │
│   NOT MONOTONIC — replaces any prior value, can drop         │
│                                                               │
│ upsert skill_user                                            │
└──────────────────────────────────────────────────────────────┘
    │
    ▼
┌─ track_user — AVG over System-B skill_maxile (NOT monotonic) ┐
│ track_passed (boolean) — still uses skill_passed counts:      │
│   passedSkills = COUNT skill_user.skill_passed=1              │
│                    WHERE skill ∈ track.public_skills          │
│   totalSkills  = COUNT track.public_skills                    │
│   track_passed = (passedSkills == totalSkills) AND total > 0  │
│                                                                │
│ track_maxile — AVG over skill_maxile (NEW since 2026-05-24): │
│   = AVG(skill_user.skill_maxile)                              │
│       WHERE skill ∈ track.public_skills                       │
│         AND skill_maxile > 0                                  │
│   Clamped to [level.start, level.end]                         │
│   If track_passed: track_maxile = level.end                   │
│   NOT MONOTONIC — replaces any prior value, can drop          │
│                                                                │
│ upsert track_user                                             │
└──────────────────────────────────────────────────────────────┘
    │
    ▼
┌─ field_user — AVG over track_maxile (NOT monotonic) ─────────┐
│ avgTrackMaxile = AVG(track_user.track_maxile)                 │
│                    WHERE track.field_id = field.id            │
│                      AND track.status_id = 3                  │
│                      AND track_maxile > 0                     │
│                                                                │
│ ALWAYS upsert current-month row (removed write-if-greater     │
│ guard 2026-05-24). Current-month row tracks current ability. │
│ Older months persist as record but no longer lock in a        │
│ high-water mark.                                              │
└──────────────────────────────────────────────────────────────┘
    │
    ▼
┌─ users.maxile_level — AVG over LATEST field_maxile ─────────┐
│ For each field with field_user data:                          │
│   pick the row with MAX(month_achieved) — the LATEST snapshot │
│   read its field_maxile                                       │
│ user_maxile = AVG across fields                               │
│                                                                │
│ Changed 2026-05-24 from MAX-across-months to LATEST-per-field│
│ so a weak current month REPLACES a strong prior month for     │
│ that field. Inactive months: the most recent prior row        │
│ persists (no decay just from inactivity).                     │
└──────────────────────────────────────────────────────────────┘
    │
    ▼
┌─ Kiasu cursor (mode-aware) ──────────────────────────────────┐
│ if test.test_type_id == 1:                                    │
│   advanceKiasuCursor(user, field.id, isCorrect)               │
│   (see 3.4 above)                                             │
└──────────────────────────────────────────────────────────────┘
    │
    ▼
returns { skill_maxile, track_maxile, field_maxile, user_maxile }
```

**Multi-field skill attribution (Skill 1 / fields 37+40):** `pickTrackForSkill` now prefers the session's track when the skill belongs to it. Track-mode answers attribute to the user's actual context. Kiasu-mode (sessions have no track) still falls back to the highest-level public track — known limitation when a skill spans multiple fields.

---

## 4. Subsystems

### 4.1 Answer grading

**MCQ** — `AnswerGradingService::mark` (and `AnswerValidationService::checkAnswer` for the legacy track path): `(int) $selected_option === (int) $question->correct_answer`. `correct_answer` is the 0-indexed correct option.

**FIB** — fields `answer0..answer3` are the accepted blanks; the client sends `answer.fields[]` with one value per blank. Comparison is `trim()` + `mb_strtolower()` + short-circuit on first miss. If any answer slot is non-empty in the question, the client must provide a non-empty match.

**Authoritative**: never trust client `is_correct`. The endpoint regrades.

**Per-mode response shape** (intentional, drives Flutter UI):
- track/kiasu — base shape (see 3.2)
- diagnostic — adds `field_progress` block
- track summary — `test_score`, `accuracy`
- diagnostic summary — `field_progress[]` with `final_level` per field + `end_maxile`

### 4.2 Kudos

`KudosCalculator::calculate(Question $q, bool $isCorrect, string $mode)` (`app/Services/Kudos/KudosCalculator.php:28`):

| Mode | Correct | Wrong |
|---|---|---|
| `diagnostic` | **0** | **0** |
| `track`, `kiasu` (default) | `(difficulty_id ?? 0) + 1` | **0** |

Diagnostic earns no kudos so the IRT walk isn't biased by reward-seeking behavior. The "1 for wrong" consolation that previously existed in `AnswerValidationService` was an unintentional carryover from the deleted `KudosService`'s `incorrect_consolation` config; current behaviour aligns both paths on "0 for wrong" (Phase 1B convention).

`KudosCalculator::modeFromTest(?Test $t)` returns `'diagnostic'` when `test_type_id == 3`, else `'track'`. The calculator only branches diagnostic-vs-not, so `'track'` covers both track + kiasu.

### 4.3 Skill mastery (tier upgrade / downgrade)

State machine driven by two thresholds from `configs`:
- `no_rights_to_pass` (default **3**) — consecutive corrects to upgrade tier
- `no_wrongs_to_fail` (default **2**) — consecutive wrongs to downgrade tier

Per `skill_user` row:
- `difficulty_passed` — highest difficulty tier proven mastered (0..tierCount)
- `correct_streak`, `wrong_streak` — reset on opposite outcome
- `skill_passed` — `difficulty_passed >= Difficulty::tierCount()` (i.e. mastered the top tier)

**Upgrade rule** (`MaxileCascade.php:132`):
```
if isCorrect AND difficulty > difficulty_passed AND correct_streak >= toPass:
    difficulty_passed = difficulty   # one-shot jump to whichever tier the
                                     # current question was at
    correct_streak = 1               # reset (counts this success)
```

**Downgrade rule** (`MaxileCascade.php:135`):
```
if !isCorrect AND difficulty <= difficulty_passed AND wrong_streak >= toFail:
    difficulty_passed = max(0, difficulty_passed - 1)
    wrong_streak = 1
```

**Why "difficulty > difficulty_passed" matters**: getting a tier-0 question right when you've already passed tier-3 doesn't bump you back to tier-1. Mastery is monotonic except for explicit demotion.

### 4.4 Track mastery

Track is "passed" iff every public skill in the track is passed (`MaxileCascade.php:194`). No streak — pure all-or-nothing over the skill set:

```
track_passed = (passedSkills == totalSkills) AND totalSkills > 0
```

Track maxile interpolates linearly within the track's level band by the *ratio of passed skills* — useful as a progress display:

```
track_maxile = passed ? level.end_maxile_level
                      : level.start + (passedSkills / totalSkills) * range
```

### 4.5 Maxile — System B (live since 2026-05-24)

Cascade is in `MaxileCascade.php` (see 3.5 diagram). **Maxile is responsive at every scope — it can decrease.** Key invariants:

- **skill_maxile** = average of the last N attempts (N = `configs.maxile_lookback_window`, default 5) computed by `MaxileService::calculateSkillMaxile` (`app/Services/MaxileService.php:23–70`). Each correct attempt contributes `level.start + (difficulty/maxDiff) × range`; each wrong contributes `level.start`. Result is clamped to `[level.start, level.end]`. **Floor**: fewer than 3 attempts → returns `level.start` (the floor). **NOT monotonic** — a 5-question wrong streak after a strong run pulls it DOWN.
- **track_maxile** = `AVG(skill_user.skill_maxile)` over the track's public skills with `skill_maxile > 0`, clamped to `[level.start, level.end]`. If `track_passed`: clamped to `level.end`. **NOT monotonic** — moves with the underlying skills.
- **field_maxile** = `AVG(track_user.track_maxile)` over the field's public tracks. Always upserts the current-month row (no write-if-greater guard). **NOT monotonic** within a month.
- **users.maxile_level** = `AVG` across fields of the **LATEST** `field_user.field_maxile` per field (picks the row with `MAX(month_achieved)`, not `MAX(field_maxile)`). **NOT monotonic** — a weak current month replaces a strong prior month for that field. Inactive months: most-recent prior row persists.
- **Staircase state (`difficulty_passed`, `correct_streak`, `wrong_streak`, `skill_passed`)** is still maintained on `skill_user` but only drives the boolean `track_passed` rollup and the legacy admin's per-skill display. It NO LONGER feeds `skill_maxile`.
- **Lookback window**: `configs.maxile_lookback_window` = 5 (pinned 2026-05-22). Drives `MaxileService::getLookbackWindow`. Default if config row missing: 10. Verified live via the System-B observation suite (`ops/.tmp-system-b-verify.php`).

**Verified end-to-end** (`ops/.tmp-system-b-verify.php`, 2026-05-24):
- 2 correct under floor → skill_maxile = level.start ✓
- 5 correct at diff=3, level [300,400], maxDiff=3 → skill_maxile = 400 ✓
- + 4 final-wrong → skill_maxile drops to 320 (last-5: 1 correct@400 + 4 wrong@300) ✓
- users.maxile_level mirrors the drop 400 → 320 ✓

### 4.6 Question selection

Three selector services in `app/Services/Tests/`:

**`TrackSelector::select(User, Track, int $count)`** — picks `$count` questions from skills within the track. Filters:
- `status_id = 3` (Public)
- `is_diagnostic = 0`
- `qa_status NOT IN ('flagged', 'needs_revision')`
- Not already in this user's `question_user` for any in-progress test of this track
- Distinct skills first (round-robin across the track's skills, then back-fill from same skills if needed)

**`FieldRoundSelector::select(User, AssessmentSession, string $mode, int $batchSize)`** — shared by diagnostic + kiasu. Strategy plug-ins:
- **Diagnostic strategy**: for each public field that isn't cemented in this session, pick one question at the field's current cursor level. Tags each picked question with `_picked_field_id` so the formatter routes responses to the right `field_progress` row (avoids the field-attribution bug where Skill 1 belongs to multiple fields).
- **Kiasu strategy**: for each public field, pick one question at `kiasu_field_progress.current_level` for that user. Round-robin across fields up to `batchSize`.

Same QA filter applies to both selectors.

**`QuestionFormatter::formatMany(Collection)`** — strips internal fields, maps to the Flutter-facing question shape, honors the `_picked_field_id` hint.

### 4.7 Competency progression and regression

**Two parallel models**:
- **Staircase booleans** (`skill_passed`, `track_passed`): mastery flags driven by `difficulty_passed` tier progression. Used for display ("you've passed this track") and to inform the cascade's track_passed rollup. NOT used for maxile values anymore.
- **System B maxile** (`skill_maxile`, `track_maxile`, `field_maxile`, `users.maxile_level`): responsive numeric placement driven by recent performance. CAN drop at every scope.

| Subject | Progress mechanism | Regression mechanism |
|---|---|---|
| Skill — `difficulty_passed` (display flag) | `correct_streak >= no_rights_to_pass` on a higher-difficulty question → bump | `wrong_streak >= no_wrongs_to_fail` on a question at-or-below current tier → demote |
| Skill — `skill_maxile` (System B, responsive) | Each `isFinal` answer recomputes from last N attempts. Higher difficulty correct → contribution pushes UP toward level.end | Wrongs in the window contribute `level.start` → average drops toward level.start. **A 4-question final-wrong streak after a strong run drops skill_maxile measurably.** |
| Track — `track_passed` (display flag) | All public skills become `skill_passed=1` → flips to 1 | Any skill demotes (`difficulty_passed` < tierCount) → flips back to 0 |
| Track — `track_maxile` | AVG over current `skill_maxile` values for the track's public skills, clamped to level band | If any skill_maxile drops, the average drops with it |
| Field — `field_maxile` | AVG over current track_maxile in the field; written every cascade run | Current-month row replaced on every cascade — drops when underlying tracks drop |
| User — `users.maxile_level` | AVG across fields of LATEST `field_maxile` per field | Drops when any field's latest snapshot drops (or when an inactive field's old row is overtaken by other fields' drops) |
| Kiasu cursor | `correct_streak >= toPass` → step level up via `AdaptiveLevelService::getNextLevelUp` | `wrong_streak >= toFail` → step level down via `getNextLevelDown` |
| Diagnostic IRT walk | Right → step up to next level; if next level is already 'wrong', cement | Wrong → step down; if next level is 'right', cement |

**Important nuance** (verified 2026-05-24): the cascade only fires on `isFinal` answers. `isFinal = isCorrect || attemptNumber === 2`. First-attempt-wrong is "free" — it lands in `attempt_ledger` but doesn't update `question_user`, so it doesn't enter the System-B average. To affect `skill_maxile`, a wrong answer must be the user's second (final) attempt on that question. This is a product rule (2 tries per question), not a bug.

**Why user maxile can now drop**: 2026-05-24 decision. The cascade was inverted to track recent ability instead of best-ever placement. This trades off the "your maxile never goes down" reassurance against the responsiveness needed for accurate placement decisions.

### 4.8 Lives

`LiveService` (`app/Services/LiveService.php`) — partner-tier-aware, integrates with `config/partners.php` to map `access_type` → `max_lives` / `restore_seconds`.

| Mode | Wrong answer | Correct answer |
|---|---|---|
| Track | `users.lives = max(0, lives - 1)` | no-op |
| Kiasu | same as track | no-op |
| Diagnostic | **no-op** (intentional — see 4.2) | no-op |

Premium / unlimited tiers have `unlimited = true` and never deduct (`access_type === 'premium'` typically maps to unlimited lives by partner config).

Lives regenerate via `lives_restore_queue` + `lives_lost_at` timestamps; `getLivesInfo()` returns `{ lives, max_lives, unlimited, next_life_in_seconds, next_life_at }`.

---

## 5. Configuration

Single `configs` row (treated as a singleton). Edited via Filament Configuration page at `/cp/configuration-page`. **Mail fields intentionally absent from the form** — `configs.mail_*` must stay NULL per `CLAUDE.md`; SMTP lives only in `.env`.

| Column | Default | Where used |
|---|---|---|
| `no_rights_to_pass` | 3 | `Config::passThreshold()` — skill tier upgrade (drives `difficulty_passed` staircase + kiasu cursor up) |
| `no_wrongs_to_fail` | 2 | `Config::failThreshold()` — skill tier downgrade + kiasu cursor down |
| `maxile_lookback_window` | 5 | **System B — drives skill_maxile** via `MaxileService::getLookbackWindow` (`app/Services/MaxileService.php:422`). Last-N attempts averaged. Pinned 2026-05-22. Fallback if config missing: 10. |
| `questions_per_test` | 20 | Total cap for track + kiasu tests. Bumped from 10 via migration `2026_05_24_140000_set_questions_per_test_to_twenty.php`. |
| `kiasu_path_questions_per_batch` | 5 | Batch size for the field-round selector (diagnostic + kiasu) |
| `site_name`, `site_shortname`, `site_url`, `email` | brand | View composer-shared `$siteSettings` |
| `main_color`, `black_color`, `white_color`, `secondary_color`, `tertiary_color`, `success_color`, `error_color`, `warning_color`, `info_color` | brand palette | Drives CSS variables in `layouts/admin.blade.php` AND the Filament theme overlay at `public/css/admin-theme.css` |
| `primary_font`, `secondary_font` | Raleway / Georgia | Same |
| `maintenance_mode`, `maintenance_message`, `timezone` | misc | Shared via View composer |

Migrations that pin / update these values:
- `database/migrations/2026_05_22_120000_pin_maxile_config_baseline_to_configs.php` — sets the three mastery / lookback values
- `database/migrations/2026_05_23_140000_align_configs_branding_with_allgifted_web.php` — aligns brand palette with allgifted-web Tailwind config

---

## 6. Cross-cutting

### 6.1 Sanctum guard

API context **must** specify the `sanctum` guard explicitly — the default `web` guard returns null for bearer-token requests. Pattern (`feedback_sanctum_guard_in_api.md`):

```php
// In an API controller constructor
public function __construct() {
    $this->middleware('auth:sanctum');
    $this->middleware(function ($request, $next) {
        $this->user = Auth::guard('sanctum')->user();
        return $next($request);
    });
}

// In FormRequests (which can't use that constructor)
$this->user('sanctum')   // NOT bare $this->user()

// Helpers
auth('sanctum')->user()           // NOT auth()->user()
Auth::guard('sanctum')->user()    // NOT Auth::user()
```

### 6.2 Idempotency middleware

`POST /api/answers` requires an `Idempotency-Key` header. Middleware (`app/Http/Middleware/EnforceIdempotencyKey.php`) caches `(user_id, path, key) → response` for ~24 hours via sha256 keyed against the canonicalised request body. Replay returns the cached response. Body-hash mismatch on the same key → 409.

**Durable replay guard** (`AnswerGradingService::grade`, added 2026-05-24): in addition to the cache, the grading service checks `question_user.question_answered = 1` for (user, session, question) at the top of the transaction (with `lockForUpdate`). If the question is already settled — i.e. a prior grade committed but the middleware's cache write failed — the service reconstructs the response from committed state and returns without re-running the cascade, kudos increment, or lives deduction. This closes the double-grade window that existed when the cache was the only guard.

Replay-via-DB-guard caveat: the reconstructed response returns `next_questions: null` and `session.completed: false` regardless of the original advance result. The client recovers the next batch by calling `/api/tests/start` (auto-resume) or `/api/tests/{id}/summary`.

### 6.3 X-Client-Version

Header format `1.4.0+2` (semver+build), driven by Flutter's `package_info_plus`. When parsing for version gating: split on `+`, compare only the semver portion.

### 6.4 QA workflow

All QA actions delegate to `app/Services/QAService.php`. Two surfaces today:
- Legacy Blade admin: `/admin/qa/*` — `app/Http/Controllers/QAController.php` (refactored to delegate to QAService)
- Filament: `/cp/qa/queue` (clickable scope cards) + `/cp/qa/review?scope=<scope>&question=<id>` (sequential reviewer)
- Filament `QuestionResource` bulk actions also call QAService

**Workflow rules** (`QAService::approve`):
- Permission check: must be admin OR have `qa_approve_any` / `qa_approve_p2p3`
- Self-approval block: reviewer can't approve their own QA edit
- Open-issue block: cannot approve if any `qa_issues` are status='open'
- On approve: `qa_status = 'approved'`, `status_id = 3` (Public), `published_at = now`
- On flag: `qa_status = 'flagged'`, creates a `qa_issues` row with reviewer + description, `status_id = 4` (Draft), `published_at = null`
- Audit log: every action writes to `review_history` table (silent skip if table missing)

### 6.5 Sentry

Wired via `bootstrap/app.php`:
```php
->withExceptions(function (Exceptions $exceptions) {
    \Sentry\Laravel\Integration::handles($exceptions);
})
```

DSN in `.env` as `SENTRY_LARAVEL_DSN`. Sample rate `SENTRY_TRACES_SAMPLE_RATE=0.0` by default — no tracing, errors only.

---

## 7. What's retired

| Removed | Replaced by |
|---|---|
| Staircase formula for `skill_maxile` (max(existing, level.start + (difficulty_passed/tierCount) × range)) | System B average via `MaxileService::calculateSkillMaxile` (2026-05-24). Staircase state machine still maintained for `difficulty_passed` / `skill_passed` booleans. |
| Monotonic `max(existing, computed)` clamps at skill/track/field/user scope | All clamps removed 2026-05-24 — maxile is fully responsive |
| `field_user` write-if-greater guard | Always upserts current-month row (2026-05-24) |
| `users.maxile_level` = AVG of MAX-field_maxile across months | AVG of LATEST field_maxile per field (2026-05-24) |
| GET `/api/_sentry-test` public error-injection endpoint | Removed 2026-05-24 — was polluting Sentry with fake 500s |
| `app/Http/Controllers/AnswerController.php` (top-level) | `app/Http/Controllers/API/AnswerController.php` (Phase 1B per-tap) |
| `app/Http/Controllers/CheckAnswerController.php` | Folded into `AnswerGradingService` + `AnswerValidationService` |
| `app/Services/DiagnosticService.php` | `TestStartOrchestrator` + `SessionAdvancer` |
| `app/Services/KudosService.php` | `app/Services/Kudos/KudosCalculator.php` |
| Question model's `correctness()` + `answered()` | `AnswerGradingService::mark()` |
| Question model's inline `processProgressFor` logic | `app/Services/Maxile/MaxileCascade.php` (Question::processProgressFor is now a 3-line delegate) |
| `skill_user.fail_streak` column | Renamed to `wrong_streak` (migration `2026_05_23_120000`) |
| Hardcoded `sk_test_...` in LivesPurchaseService | `config('services.stripe.secret')` + throw if missing |
| Client-supplied `amount` on PaymentController | Server-derived from `SubscriptionPlan::find($plan_id)` |
| Auth0 directory | Deleted — do not reintroduce |

---

## 8. Test coverage

End-to-end test scripts in `ops/.tmp-*` (gitignored; local-only):

| Suite | Coverage |
|---|---|
| `test-start-tests` | 40 checks across /api/tests/start (track/diag/kiasu, gating, resume, error paths) |
| `step4-tests` | 22 checks — SessionAdvancer (track completion, kiasu top-up, diagnostic walk) |
| `phase1-tests` | 17 checks — /api/answers grading pipeline |
| `kudos-tests` | 19 checks — KudosCalculator across modes |
| `diag-gates-tests` | 6 checks — diagnostic access + cooldown gates |
| `items-8-9-10-tests` | 13 checks — summary endpoint + qa_status filter + webhook amount cross-check |
| `flutter-e2e` | 20 checks — full Flutter-shape round-trip (auth + start + answer + summary + lives) |
| `stripe-smoke` | webhook signature verification + amount cross-check |

Run all with: `for s in ops/.tmp-*-tests.php; do php artisan tinker --execute="require '$s';"; done`

---

## 9. Production deploy checklist

```bash
ssh root@mathapi.allgifted.com
cd /var/www/html/mathapi
git fetch origin && git pull
php artisan migrate --force                 # --force needed for non-interactive prod
php artisan config:clear && php artisan config:cache
php artisan route:cache
php artisan queue:restart
systemctl restart apache2
chown -R www-data:www-data storage bootstrap/cache    # required after root artisan
tail -f storage/logs/laravel.log                       # watch for 30s
```

Production server is **git-read-only** — never `git add` or `git commit` on prod. Prod consumes commits; it doesn't author them.

---

*This document is generated from the working tree. When code and doc disagree, the code wins. Re-run the generator (just rewrite this file) when subsystems change.*
