# AGS Vocab — design + handoff

> **Next Claude Code session: read this first, then `docs/SESSION-LOG.md`
> for the chronological journal.** This file is the steady-state design
> of the entire AGS app family. The session log is the history of how
> we got here.

---

## 1. What this is

**AGS Vocab** is a Laravel + Flutter adaptive vocabulary assessment
product for ages 7–14. It's one of three apps in the AGS family — the
others are **AGS Math** (older, separate codebase) and **High School
LMS** (third-party Forma deployment). All three share a single sign-on
service (**AGS Account**) and run on the same DigitalOcean droplet.

Production URLs:

| URL | What | Tech |
|---|---|---|
| https://account.allgifted.com | SSO hub: OTP sign-in + app launcher dashboard | Laravel 11 + Filament |
| https://vocab.allgifted.com | Vocab learner frontend | Flutter web (PWA) |
| https://vocabapi.allgifted.com | Vocab backend API + Filament admin | Laravel 11 + Sanctum + Cashier |
| https://math.allgifted.com | Math frontend | Vue (separate codebase) |
| https://mathapi.allgifted.com | Math backend | Laravel (separate codebase) |
| https://quiz.allgifted.com | Math quiz subsurface | Laravel (separate codebase) |
| https://highschool.allgifted.com | Forma LMS | 3rd-party |
| https://parent.allgifted.com | Parent portal | Node + React (separate codebase) |
| https://allgifted.com | Marketing site | Next.js on Vercel (separate infra) |

Repositories:

| Repo | Purpose |
|---|---|
| https://github.com/2ppaamm/vocab | Vocab backend + Flutter frontend (this repo) |
| https://github.com/2ppaamm/account | AGS Account SSO service |
| `c:\allgifted\mathapi11v2` | Math backend (NOT in this org's GitHub; local-only reference) |
| `c:\allgifted\flutter_demo` | Math Flutter frontend (NOT in this org's GitHub; local-only reference) |
| `c:\projects\ags_parent` | Parent portal (separate repo, separate infra) |

---

## 2. System map

```
                   ┌──────────────────────────────────┐
                   │   Marketing (Vercel, Next.js)    │
                   │   allgifted.com                  │
                   │   Login dropdown → linked apps   │
                   └──────────┬───────────────────────┘
                              │ (link)
                              ▼
                   ┌──────────────────────────────────┐
                   │   AGS Account (SSO)              │  ← Laravel
                   │   account.allgifted.com          │
                   │   OTP login + dashboard          │
                   │   Issues per-app HS256 JWT       │
                   └────┬─────────┬─────────┬─────────┘
                        │         │         │
                        ▼         ▼         ▼
                ┌──────────┐ ┌─────────┐ ┌───────────┐
                │  Vocab   │ │  Math   │ │  Forma    │
                │  (JWT)   │ │  (JWT)  │ │ (magic-   │
                │          │ │         │ │  link)    │
                └────┬─────┘ └─────────┘ └───────────┘
                     │
        ┌────────────┼─────────────────┐
        ▼            ▼                 ▼
     Flutter      Laravel           Anthropic
     PWA          + Filament        (AI Tutor)
                  + Sanctum             ↑
                  + Cashier             │
                       ↓                │
                    Stripe        (HS256 per-app
                  (subscriptions + secret in env)
                   one-shot lives)

        Everything except the marketing site
        runs on ONE DigitalOcean droplet (152.42.223.228, Ubuntu 24.04).
        Apache vhosts route by Host header.
```

---

## 3. Server layout (one droplet)

| | |
|---|---|
| Host | DigitalOcean droplet, 152.42.223.228 |
| OS | Ubuntu 24.04.2 LTS |
| Web server | Apache 2.4.58 + mod_php 8.2.29 (no PHP-FPM) |
| DB | MySQL 8.0.45, root user, password in `/var/www/html/mathapi/.env` (`DB_PASSWORD`) |
| TLS | Let's Encrypt via `certbot --apache`, auto-renew scheduled |
| Composer | 2.7.1 |
| Flutter SDK | `/opt/flutter`, stable channel (currently 3.44.x candidate) |
| Stripe CLI | not installed (use Stripe dashboard for webhook signing secrets) |

### Directory map

```
/var/www/html/
  ├── mathapi/         Math backend (Laravel)         — math.allgifted.com (API)
  ├── mathfe/          Math frontend (Vue dist)       — math.allgifted.com
  ├── quiz/            Math quiz subsurface           — quiz.allgifted.com
  ├── vocabapi/        Vocab backend (Laravel)        — vocabapi.allgifted.com
  ├── vocab/           Vocab Flutter web build        — vocab.allgifted.com
  └── account/         AGS Account (Laravel)          — account.allgifted.com

/etc/apache2/sites-available/
  ├── math.allgifted.com.conf            + -le-ssl.conf
  ├── mathapi.allgifted.com.conf         + -le-ssl.conf
  ├── quiz.allgifted.com.conf            + -le-ssl.conf
  ├── vocab.allgifted.com.conf           + -le-ssl.conf
  ├── vocabapi.allgifted.com.conf        + -le-ssl.conf
  └── account.allgifted.com.conf         + -le-ssl.conf

/etc/letsencrypt/live/
  ├── math.allgifted.com/        (covers math + mathapi)
  ├── quiz.allgifted.com/
  ├── vocab.allgifted.com/       (covers vocab + vocabapi)
  └── account.allgifted.com/

/var/backups/mysql/
  └── api-20260526-063242.sql.gz   (math DB pre-vocab deploy; 2.8 MB)
  └── math_db-20260526-063242.sql.gz
```

### MySQL databases

| DB | Owned by | Tables |
|---|---|---|
| `api` | math | original math schema |
| `math_db` | math (legacy) | small, possibly deprecated |
| `vocab` | this repo | 45 tables — see §6 |
| `account` | account repo | 7 tables — see §5 |

### SSH

Authorized keys on the droplet (in `/root/.ssh/`):
- `authorized_keys` — original DO setup + maintainer keys
- `github_deploy` + `.pub` — repo-pinned deploy key for mathapi GitHub
- `vocab_deploy` + `.pub` — deploy key for `2ppaamm/vocab`
- `account_deploy` + `.pub` — deploy key for `2ppaamm/account`
- `config` — aliases: `Host github-vocab` and `Host github-account` so the
  right key is used per repo

### DNS (Namecheap)

All subdomains are A records → 152.42.223.228:
- `account`, `vocab`, `vocabapi`, `math`, `mathapi`, `quiz`,
  `highschool`, `parent`

`allgifted.com` (root) and `www` point at Vercel (76.76.21.21).

---

## 4. The three apps in detail

### 4.1 AGS Account (`account.allgifted.com`)

Central identity. Owns the canonical `users` table (one row per human).
Issues short-lived HS256 JWTs to consumer apps via a dashboard-launch
flow.

- Repo: https://github.com/2ppaamm/account
- Stack: Laravel 11 + Filament v6 + Sanctum + `firebase/php-jwt`
- DB: `account` schema, 7 tables
- Auth: **OTP only** (email + SMS), no passwords for learners
- See: [account/docs/SSO.md](https://github.com/2ppaamm/account/blob/main/docs/SSO.md) — full SSO contract, JWT spec, integration checklist, secret rotation, threat model

**Quick anatomy:**

| File | Purpose |
|---|---|
| `app/Models/User.php` | Canonical identity. Holds kudos_global, is_premium, role. |
| `app/Models/ClientApp.php` | Registered consumer apps (vocab/math/forma) + per-app `jwt_secret`. |
| `app/Models/OtpCode.php` | OTP codes (hashed). |
| `app/Services/Auth/OtpService.php` | OTP issue + verify + dispatch (email / sms). |
| `app/Services/Sso/SsoTokenService.php` | JWT issuer (HS256, 60s TTL). |
| `app/Http/Controllers/Web/AuthWebController.php` | `/login`, `/verify`, `/logout` web flow. |
| `app/Http/Controllers/Web/DashboardController.php` | `/` (app cards) + `/apps/{slug}/launch` (JWT → redirect to consumer). |
| `routes/web.php` | Public guest routes + auth dashboard. |

### 4.2 Vocab (`vocab.allgifted.com` + `vocabapi.allgifted.com`)

The primary product. Adaptive vocabulary assessment + practice with
the Vocabile score (custom IRT-driven grade-band scale).

- Repo: https://github.com/2ppaamm/vocab (this repo)
- Stack: Laravel 11 + Sanctum + Cashier + Filament v6 + Flutter web
- DB: `vocab` schema, 45 tables
- Auth: OTP (legacy local) + SSO (preferred)

### 4.3 Math (reference only)

Older sibling. Vocab borrows its UX patterns, life system shape, and
AI tutor architecture. Not part of this codebase or these repos.

- Backend: `c:\allgifted\mathapi11v2`
- Frontend: `c:\allgifted\flutter_demo`
- See files referenced throughout this doc with `c:\allgifted\...` paths

---

## 5. Vocab — major subsystems

### 5.1 Multi-tenancy

Vocab supports multiple schools via a `school_id` discriminator on most
tables. In practice there are only 2 schools today (`all-gifted` and
`bayview-primary`); the architecture is in place for more.

**Resolution order** (`app/Http/Middleware/ResolveSchool.php`):
1. `X-Tenant` header (Flutter app sends this)
2. Sanctum bearer token (has `school_id` column)
3. **`schools.custom_domain` match** ← how prod resolves: vocabapi.allgifted.com → all-gifted
4. Subdomain match (`acme.agsvocab.com` → `acme`)
5. Dev fallback to `config('tenancy.default_school_slug')` (local/testing only)

If nothing resolves, `current_school` is null. Production **never silently
picks a tenant** — there's no fallback in prod env. This is intentional
(prevents accidental cross-tenant data exposure).

**Gotcha:** When the OTP endpoint creates a new OTP row it needs
`current_school`. We set `schools.custom_domain = 'vocabapi.allgifted.com'`
on `all-gifted` so the custom_domain branch resolves cleanly. If you
add more schools, each gets its own custom_domain or the Flutter app
must send X-Tenant.

### 5.2 OTP authentication (legacy local)

Email or phone → 6-digit code → 10 min TTL, 5 attempts max. Hashed at
rest. New user gets stamped + school_users pivot row in one go.

- `app/Services/Auth/OtpService.php` — service
- `app/Http/Controllers/Api/AuthController.php` — `POST /api/auth/request-otp`, `verify-otp`, `logout`
- Email dispatch uses Laravel `Mail::raw`, SMTP per SiteConfig (see 5.13)
- SMS dispatch is not wired (placeholder; would plug into Twilio)
- In `local`/`testing` env the API response includes a `dev_code` so
  tests can auto-fill the OTP

### 5.3 SSO (preferred)

`POST /api/sso/exchange` accepts an HS256 JWT issued by
account.allgifted.com (with `aud=vocab`), validates it, upserts the
local user (match by `external_id` → `email` → `phone`), and mints a
fresh Sanctum token.

- `app/Http/Controllers/Api/SsoController.php`
- Shared secret: `SSO_JWT_SECRET` in `.env` matches `account.client_apps.jwt_secret`
- See [account/docs/SSO.md](https://github.com/2ppaamm/account/blob/main/docs/SSO.md) for the full contract

Flutter wiring:
- `lib/main.dart` reads `?sso_token=` from `Uri.base` on launch, calls `ssoExchange`
- `lib/screens/login_screen.dart` has a "Sign in with AGS Account" button beside the OTP form
- Legacy OTP login still works; SSO is additive

### 5.4 Lives + gates (daily reset, Math-style deduct, premium = unlimited)

**Key product decisions:**
- Vocab uses **daily midnight reset (Asia/Singapore)**, not Math's
  5h-per-life regen. (Chosen in the lives v2 session.)
- **Math-style deduct (2026-05-28):** first wrong is FREE; only the final
  wrong (retry-wrong or skip-after-first-wrong) charges 1 heart.
- **Premium = unlimited hearts (2026-05-28):** both `is_unlimited_lives`
  (staff bypass) and `is_premium` (paying subscribers) bypass the
  daily-5 limit.

Mechanics:
- 5 hearts per day for free users, reset lazily on any LivesService call.
- `users.lives_last_reset_date` tracks the boundary.
- Server NEVER deducts in the test flow. The client drives all
  deductions via `POST /api/lives/consume` on:
  - retry-wrong (`_evaluateRetryClientSide` in `test_screen.dart`)
  - skip-after-first-wrong (`_onSkipAfterFirstWrong`)
- Diagnostic **never** deducts (IRT integrity), but IS gated by lives.
- 0 hearts (free user only) → `422` with `code: 205` + payment-ready snapshot.

**Gate matrix:**

| Test type | Free | Premium | Subject to 5 hearts/day |
|---|---|---|---|
| Skill Practice | ✓ | ✓ | Free: ✓ / Premium: ✗ |
| Vocab Diagnostic | premium_required (402) | ✓ | Premium: ✗ (and no deduct anyway) |
| Vocab Path | premium_required (402) | ✓ | Premium: ✗ |

Enforcement:
- Premium gate: `app/Http/Controllers/Api/TestController.php::start` — `is_premium` check before lives check
- Lives gate (start + queue): `app/Services/Gamification/LivesService.php::canAnswer` + `outOfLivesResponse` — premium + unlimited both short-circuit to true
- Client deduct: `mobile/lib/screens/test_screen.dart` (`_evaluateRetryClientSide` for retry-wrong, `_onSkipAfterFirstWrong` for skip)
- Out-of-lives modal: `mobile/lib/widgets/out_of_lives_modal.dart` — shows midnight countdown + pack buttons + premium upsell. (Premium users never see it — they're `is_unlimited`.)
- Lives header: `mobile/lib/widgets/lives_header.dart` — single widget with `full` (5 doodle hearts) and `compact: true` (single heart + count) variants, fade-pulse on empty.

### 5.5 Premium tier (Cashier subscriptions)

- **Monthly:** SGD 20 / mo (Stripe Price: `STRIPE_PRICE_PREMIUM_MONTHLY`)
- **Annual:** SGD 50 / yr (Stripe Price: `STRIPE_PRICE_PREMIUM_ANNUAL`) — "save SGD 190"

Implementation:
- Laravel Cashier 16 (`User extends ... use Billable;`)
- `app/Services/Payment/PremiumSubscriptionService.php` — opens Stripe Checkout via `$user->newSubscription('premium', $priceId)->checkout(...)`
- `POST /api/premium/checkout {plan: 'monthly'|'annual'}` returns `{checkout_url}`
- `GET /api/premium/portal` returns Stripe billing portal URL
- Webhook `customer.subscription.{created,updated,deleted}` syncs `users.is_premium` (denormalised so Flutter can read without joining)

### 5.6 One-shot lives packs (Cashier + Checkout)

- **5 hearts:** SGD 0.99 (`STRIPE_PRICE_5_LIVES`)
- **10 hearts:** SGD 1.99 (`STRIPE_PRICE_10_LIVES`)

Implementation:
- `app/Services/Payment/LivesPurchaseService.php` — `$user->checkout(['mode' => 'payment', ...])` with metadata
- `lives_purchases` table records pending → completed, idempotent on `stripe_checkout_session_id`
- Webhook `checkout.session.completed` (where `metadata.type='lives_purchase'`) credits hearts via `LivesService::purchase()`
- Dev shortcut: `STRIPE_DEV_SHORTCUT=true` in local/testing env credits immediately, bypasses Stripe

### 5.7 Stripe webhook

- `POST /api/stripe/webhook` — outside school middleware (Stripe doesn't know about tenants)
- `Cashier::ignoreRoutes()` called in AppServiceProvider so the default Cashier route at `/stripe/webhook` doesn't collide
- Signature verified by `VerifyWebhookSignature` middleware (uses `STRIPE_WEBHOOK_SECRET` env)
- `app/Http/Controllers/Api/StripeWebhookController.php` extends `Laravel\Cashier\Http\Controllers\WebhookController`:
  - Adds `handleCheckoutSessionCompleted` for one-shot lives
  - Overrides `handleCustomerSubscription{Created,Updated,Deleted}` to sync `is_premium` after Cashier's own bookkeeping

### 5.8 Stripe Products + Prices (bootstrap)

`php artisan stripe:bootstrap-prices [--dry-run]` creates/reuses 4 Products + 4 Prices in Stripe and writes the IDs back to `.env`. Run after rotating Stripe keys or in a fresh environment.

- `app/Console/Commands/StripeBootstrapPricesCommand.php`
- Idempotent: looks up Products by name; reuses Prices that match amount + currency + interval

### 5.9 IRT adaptive engine + per-skill mastery (the Vocabile model)

- 3PL IRT (3-parameter logistic) with grade-band Vocabile scale.
- 3 strategies in `app/Services/Irt/Strategies/`:
  - `DiagnosticStrategy` — adaptive walk, SE-stop, updates canonical theta
  - `SkillPracticeStrategy` — scoped to skill/POS/level/genre, 10 items, NO canonical update
  - `VocabPathStrategy` — adaptive practice, no SE-stop
- `app/Services/Irt/TestSessionService.php` orchestrates start /
  nextQuestion / submitAnswer / candidateQuestions (batch). After
  every recorded response it calls `WordMasteryService::recordAttempt`
  to maintain per-(user, word, skill) state and (when `is_passed`
  flips) `MasteryRollupService::recomputeForUser` to refresh the
  Vocabile rollups.
- `app/Services/Irt/ItemSelector.php` — picks next item by max information
- `app/Services/Irt/VocabileScore.php` — theta → grade-band score

**Per-skill mastery model** (`app/Services/Mastery/`, added 2026-05-27;
retention added 2026-05-28):
- A learner has 4 dimensions per word: Recognition, Recall, Production,
  Pronunciation. A word is "passed for a skill" when
  `correct_streak >= pass_streak` (default 2).
  A word is "fully mastered" only when passed for ALL 4 skills.
- **Retention (2026-05-28):** mastery is no longer a one-way ratchet.
  - `fail_streak` consecutive wrong answers UN-master a passed (word,
    skill) — `is_passed → false`, `failed_at` stamped (default
    `fail_streak = 2`). A correct answer resets `fail_streak`, so a
    single slip between corrects is tolerated.
  - Every correct review sets `last_reviewed_at = now` and pushes
    `review_due_at = now + retest_interval_days` (default 30). When
    `now >= review_due_at`, a passed word is **due** for re-test
    (`StudentWordMastery::isDue()`). Being due does NOT lower the score
    — it's a flag for the practice engine to re-surface the word
    (engine wiring is a follow-up; the flag/column exist now).
  - The three knobs live in the admin-editable `configs` table
    (`mastery_pass_streak`, `mastery_fail_streak`,
    `mastery_retest_interval_days`), resolved by
    `App\Services\Mastery\MasteryConfig::resolve()` (DB wins,
    `config/vocab.php` is the cold-start fallback).
  - `passed_at` stays as the historical first-ever pass; re-mastering
    after an un-master does not overwrite it.
- **Re-surfacing (the half that makes decay bite, 2026-05-28):** a word
  can only un-master if the practice engine re-serves it after it goes
  due. `App\Services\Mastery\DueWords` centralises the "is (word, skill)
  due" query.
  - **Skill Practice** (`SkillPracticeStrategy`): due retest is
    **priority 0** in the pick cascade — ahead of new/never-answered
    content. Anki-style "reviews before new cards."
  - **Vocab Path** (`VocabPathStrategy`): due questions are pulled into
    the candidate pool even when out of the difficulty band, and their
    Fisher information is multiplied by `DUE_BOOST` (2.5) so they rank
    first. Outranks the freshness penalty.
  - **Diagnostic is deliberately excluded** — re-serving mastered words
    would bias the placement estimate. `ItemSelector` (Diagnostic-only)
    stays pure.
- **Prod backfill anchoring (UNRESOLVED until deploy):** the migration
  anchors existing passed rows' `review_due_at` to the MIGRATION DATE
  (fresh clock), NOT their historical last-correct date — anchoring to
  history would flip a large fraction of every learner's vocab to
  "overdue" instantly and flood practice queues the moment re-surfacing
  ships. Re-confirm this choice before running `migrate` on prod.
- **Weighted credit formula** (`config/vocab.php`):
  ```
  SKILL_WEIGHTS = { recognition: 0.15, recall: 0.25,
                    production: 0.35, pronunciation: 0.25 }
  word_credit(user, word) = Σ SKILL_WEIGHTS[s] × passed(user, word, s)
  band_credit(user, band) = AVG(word_credit) for w in band
  aggregate_vocabile = highest band where band_credit >= 0.60
  ```
- **Tables** (`2026_05_27_010000` and `_020000` migrations):
  - `student_word_mastery` gained `skill_id`, `correct_streak`,
    `is_passed`, `passed_at`. Unique on (user, word, skill).
  - `user_skill_state` — per-(user, skill) per-skill Vocabile
  - `user_pos_state` — per-(user, POS) per-POS Vocabile (Nouns, Verbs, …)
  - `user_genre_state` — per-(user, genre) per-genre Vocabile (track-style)
  - `user_vocab_state` — per-user aggregate (weighted Vocabile)
- **APIs** (`app/Http/Controllers/Api/VocabStateController.php`):
  - `GET /api/me/vocab-state` — full breakdown for the signed-in learner
  - `GET /api/parent/children/{accountUserId}/vocab-state` — same for
    the parent portal (Sanctum + `parent-read` ability)
  - Response includes per-skill/POS/genre breakdowns, band-credit
    grid, current edge band, strongest/weakest skill+POS, recent
    attempts. All from rollup tables — sub-100ms.

### 5.10 Question types (22 total — full assessment catalog)

Vocab supports four skill dimensions × multiple shapes per skill:

| Skill | Shapes (existing) | Shapes (LLM-generated, 2026-05-27) |
|---|---|---|
| **Recognition** | definition, listening_mcq, pos_mcq, true_false, multi_select | (none new — recognition is already saturated) |
| **Recall** | synonym, antonym, definition_mcq_reverse, contextual, matching | synonym_in_context, word_form_mcq, collocation_mcq, register_mcq, connotation_mcq, passage_inference |
| **Production** | typed_spelling, fib_letter, fib_word, cloze, contextual | cloze_passage |
| **Pronunciation** | pronunciation (speak the lemma) | read_aloud_sentence (speak a sentence in context) |

Notes on the new types:
- `cloze_passage` — multi-sentence passage with one blank (context tracking)
- `synonym_in_context` — SAT/PSAT "most nearly means" with bolded word in sentence
- `definition_mcq_reverse` — inverse of `definition` (Recall: word→def vs Recognition: def→word)
- `passage_inference` — read passage, infer bolded word meaning (upper-band)
- `word_form_mcq` — morphology (noun↔verb↔adjective forms)
- `collocation_mcq` — phrasal partnerships ("make a decision" vs "do a decision")
- `read_aloud_sentence` — pronunciation in connected speech
- `register_mcq` — formal / informal / neutral / archaic
- `connotation_mcq` — positive / negative / neutral / depends

Distractors for template-generated MCQs are POS-matched via
`app/Services/Seeding/DistractorPicker.php`: same POS + same difficulty
→ same POS + adjacent → same POS + any → any POS + same difficulty.

**LLM content generators** (`app/Services/Content/`, 2026-05-27):
- `ContentGenerator` (base) wraps Anthropic Messages API. Mirrors
  `VocabTutorService` HTTP pattern, returns token counts for cost
  accounting.
- 12 subclasses in `app/Services/Content/Generators/` — one per LLM-
  generated shape. Strict-JSON output schema; defensive parser drops
  items that fail sanity checks (target word missing, fewer than 3
  distractors, duplicates).
- `php artisan vocab:generate-content {shape} [--limit=N] [--batch=N]
  [--dry-run] [--yes]` runs a batch. Idempotent — skips words that
  already have a question of this type. Cost estimate displayed
  up-front (~$1.65 per shape, ~$19.80 for the full 12-shape suite).
- **Anthropic key required to actually run** — key in prod is currently
  invalid (returns 401). Rotate at console.anthropic.com before
  running any generator.

### 5.11 Instant-feedback UX

The "sound delay" fix. Question payload (`presentNextQuestion` /
`queueQuestions` / `submitAnswer` response) ships `correct_option_ids`
and `correct_text` **inline**. Flutter judges locally first, plays
sound + flips to feedback phase in the same frame, then POSTs `/answer`
in the background. Server is still authoritative — if local judge
disagrees with server, server wins.

- Backend: `app/Http/Controllers/Api/TestController.php::buildQuestionPayload` includes both
- Flutter: `mobile/lib/screens/test_screen.dart::_judgeLocal` + `_submitToServer` (instant path then `await` reconcile)

**Threat model:** Inspecting the network tab reveals answers to a
curious learner. Accepted — audience is 8-12yo educational, not
high-stakes assessment. Adaptive paths still depend on the server's
recorded answer for the next pick, so cheating still corrupts theta.

### 5.12 Math-style wrong-answer UI

Ports `c:\allgifted\flutter_demo\lib\widgets\question_feedback_area.dart`.

- **1st wrong:** small red strip `Icons.cancel_rounded` + "Incorrect. Try again!" — **no** answer reveal
- **2nd wrong:** full reveal — "Your answer: X" (red) + "Correct answer: Y" (green) + AI Tutor button
- MCQ-tile soft-green hint of the correct option fires only on `_attemptCount >= 2`

- `mobile/lib/screens/test_screen.dart::_wrongAnswerReveal`

### 5.13 Question prefetch queue

For non-adaptive sessions (Skill Practice), the client prefetches 5
questions ahead so Continue transitions have zero server wait.

- Backend: `GET /api/tests/{id}/queue?count=5` — strategies accept
  `array $extraExclude = []` so picks within one batch don't collide.
  Adaptive paths (Diagnostic / Vocab Path) get N=1 server-side
  regardless of client request (IRT needs previous theta first).
- Flutter: `_questionQueue: List<Map<String, dynamic>>` in test_screen,
  refilled in background when ≤2 remain. `_onContinueOrSkip` dequeues
  before falling back to the per-answer `next_question`.

### 5.14 AI Vocab Tutor (Claude)

On 2nd-wrong reveal, a "Why was this wrong?" button opens a bottom
sheet showing a Claude-generated diagnosis + hint + encouragement.

- Backend: `app/Services/AI/VocabTutorService.php` (Anthropic Messages API)
- Prompts: `app/Services/AI/Prompts/VocabTutorPrompts.php` (system + user, MCQ vs typed vs generic branches)
- Endpoint: `POST /api/questions/{id}/diagnose` (Sanctum-auth)
- Cache: `ai_diagnoses` table, composite unique on (question, hash, model, prompt_version)
- Feature flag: `VOCAB_TUTOR_ENABLED` env (defaults false → 404)
- Privacy: only question + word + submission sent to Anthropic; no user_id / session_id / email
- Model: `claude-haiku-4-5-20251001` by default
- Flutter: `mobile/lib/widgets/tutor_diagnosis_modal.dart`

### 5.15 PWA install

- `mobile/web/manifest.json` — proper name "AGS Vocab", theme `#960000`, scope `/`
- `mobile/web/index.html` — `apple-mobile-web-app-capable` + apple-touch-icon at 192 and 512
- Apache vhost `Cache-Control: no-cache` on `index.html`, `flutter_service_worker.js`, `manifest.json` so PWA updates land immediately
- Hashed assets (main.dart.js etc.) cached normally

### 5.16 SiteConfig + encrypted secrets

`configs` table holds per-tenant + global config (mail SMTP, brand colours, feature flags, etc.). Read at boot via `App\Services\Config\SiteConfig::all()` from `AppServiceProvider`. Mail config can be overridden per tenant.

- `app/Models/Config.php` — encrypt/decrypt mutators for `type=password|secret` values, with **loud-fail** on cross-environment APP_KEY mismatches (returns `null` + `Log::warning` instead of leaking the encrypted blob downstream)
- `app/Services/Config/SiteConfig.php` — fetcher with cache

**Gotcha:** When importing data across Laravel installs, encrypted Config rows need to be re-encrypted with the destination's APP_KEY. `deploy/import-vocab.sh` now prints `NEEDS RESET: <key>` for any row that can't be decrypted on import.

### 5.17 Kudos system (cross-app sync)

- `kudo_events` table records earnings.
- `app/Services/Gamification/OutboundKudosSync.php` ships unsynced
  events to `ALLGIFTED_ACCOUNT_URL` for cross-app totals.
- The receiver endpoint on account is not yet built (§10 outstanding).
- **Architecture decision (2026-05-27):** kudos balance is
  **account-level** — `account.users.kudos_global` is the single
  source of truth. Vocab + math emit events; account aggregates.
  Parent portal reads one number, not three.

### 5.18 Plan sync (cross-app entitlement)

Vocab has its own Cashier-driven premium subscription, BUT the canonical
plan state lives in account. Parallel to kudos:

- `app/Services/Plan/OutboundPlanSync.php` POSTs to
  `ALLGIFTED_ACCOUNT_URL/plans/ingest` on every Cashier lifecycle
  event (created/updated/deleted/renewed) — wired into
  `StripeWebhookController::syncPremiumFlag`.
- Payload: `{account_user_id, app_key=vocab, plan, stripe_customer_id,
  stripe_subscription_id, started_at, renews_at, cancel_at,
  is_unlimited_lives}`.
- Best-effort: silent no-op when ALLGIFTED_ACCOUNT_URL is empty,
  when user has no `external_id` (pre-SSO), or when remote 404s
  (account endpoint not built yet).
- **Architecture decision (2026-05-27):** plans are stored per-app in
  account's `user_app_plans` table (one row per (account_user_id,
  app_key)), NOT a single `plan` column on `users` — because a user
  can be premium on vocab and free on math; the schema must represent
  that. A future "AGS Family Plan" SKU would flip a
  `users.family_plan` boolean without schema change.
- Math has its own Cashier too. Once its `OutboundPlanSync` is added
  (separate codebase task), both products feed account.

### 5.19 Parent portal integration

`c:\projects\ags_parent` — generic Forma-LMS parent portal (Node +
Express + React PWA). Currently sells/deploys to any Forma school.
**Lives on the Forma droplet (159.203.182.235), NOT the AGS droplet.**
URL: `parent.allgifted.com` (singular).

Architectural plan to extend it into a multi-product portal:
- Add a `product_integrations` table (admin-managed: enable/disable
  Forma / Vocab / Math / Reading per deployment).
- Per-product adapter modules: `products/forma.js` (localhost SQL,
  unchanged), `products/{vocab,math,reading}.js` (HTTPS API).
- Switch `parent_students` FK from `forma_user_id` to
  `account_user_id` (canonical AGS Account identity).
- AGS-deployment linking flow switches to account-OTP; Forma-only
  deployments keep Forma-OTP.

Vocab's `/api/parent/children/{accountUserId}/vocab-state` is the
endpoint the portal calls. Sanctum personal access token with
`parent-read` ability.

---

## 6. Vocab DB tables (45 total)

Key tables (not exhaustive):

| Table | Purpose |
|---|---|
| `users` | Learners + staff. `external_id` bridges to account.users.id |
| `schools`, `school_users` | Multi-tenant + memberships |
| `roles`, `enrolments`, `classrooms` | Teacher/student structure |
| `otp_codes` | OTP records |
| `words`, `pos_categories`, `vocabile_levels`, `bloom_levels` | Lexicon + taxonomies |
| `genres`, `genre_word` | 20 genres × M:N |
| `skills` | Skill taxonomy (skill practice scope) |
| `questions`, `question_options`, `question_types` | The 14 question types + their options |
| `test_types` | vocab_diagnostic / skill_practice / vocab_path |
| `test_sessions`, `responses` | Per-session state, per-response IRT outcome |
| `ability_estimates`, `student_word_mastery` | Canonical theta + per-word mastery |
| `kudo_events`, `life_events`, `pronunciations` | Gamification + speech submissions |
| `personal_access_tokens` (Sanctum) | API tokens |
| `subscriptions`, `subscription_items` (Cashier) | Premium subscriptions |
| `lives_purchases` | One-shot lives audit |
| `ai_diagnoses` | AI tutor cache |
| `configs` | Per-tenant + global config (encrypted secrets) |
| `statuses` | Shared lookup |

---

## 7. Deployment runbook

### Vocab code change (most common)

```bash
# Local
git add . && git commit -m '...' && git push

# Server
ssh root@152.42.223.228
cd /var/www/html/vocabapi
git pull
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --optimize-autoloader   # only if composer.json changed
php artisan migrate --force                                                    # only if new migration
php artisan config:cache                                                       # always
systemctl reload apache2                                                       # always (cheap)
bash /tmp/install-flutter-and-build.sh                                         # only if Flutter code changed (~80s)
```

### Account code change

```bash
ssh root@152.42.223.228
cd /var/www/html/account
# (currently no git checkout — see "outstanding" §10. For now scp the
# changed files up, or git clone fresh into a sibling dir + swap)
php artisan config:cache && systemctl reload apache2
```

### Adding a new vhost (e.g. for a new app)

See [account/docs/SSO.md](https://github.com/2ppaamm/account/blob/main/docs/SSO.md) — the "Adding a new client app" section walks the whole flow.

### Rotating Stripe Price IDs

```bash
ssh root@152.42.223.228
cd /var/www/html/vocabapi
php artisan stripe:bootstrap-prices    # idempotent, writes IDs to .env
php artisan config:cache && systemctl reload apache2
```

### Rotating SSO JWT secret for a consumer app

See [account/docs/SSO.md "JWT secret rotation"](https://github.com/2ppaamm/account/blob/main/docs/SSO.md#jwt-secret-rotation).

### Math DB backup before any risky change

Already in the runbooks but worth highlighting:
```bash
DBPASS=$(grep ^DB_PASSWORD /var/www/html/mathapi/.env | cut -d= -f2-)
mysqldump --single-transaction --quick --routines --triggers \
  -uroot -p"$DBPASS" api 2>/dev/null | gzip > /var/backups/mysql/api-$(date +%Y%m%d-%H%M%S).sql.gz
```

---

## 8. Code organization

```
c:\projects\vocabile\
  ├── app/
  │   ├── Console/Commands/
  │   │   ├── RebalanceDistractorsCommand.php          POS-match distractors retro-actively
  │   │   └── StripeBootstrapPricesCommand.php          create/reuse Stripe Products + Prices
  │   ├── Filament/Admin/Resources/                     Admin UI (Users, Words, Questions, ...)
  │   ├── Http/
  │   │   ├── Controllers/Api/
  │   │   │   ├── AuthController.php                    OTP auth
  │   │   │   ├── ConfigController.php                  GET /api/config
  │   │   │   ├── KudosController.php                   kudos status + history
  │   │   │   ├── LivesController.php                   status + purchase (Stripe Checkout) + consume
  │   │   │   ├── PremiumController.php                 checkout + portal
  │   │   │   ├── PronunciationController.php
  │   │   │   ├── SsoController.php                     POST /api/sso/exchange
  │   │   │   ├── StripeWebhookController.php           extends Cashier's
  │   │   │   ├── TestController.php                    start/next/queue/answer/results
  │   │   │   ├── TestTypesController.php
  │   │   │   ├── VocabTutorController.php              POST /api/questions/{id}/diagnose
  │   │   │   └── VoicesController.php                  GET /api/voices (TTS rosters)
  │   │   └── Middleware/
  │   │       └── ResolveSchool.php                     multi-tenant resolution
  │   ├── Models/
  │   │   ├── User.php, School.php, Role.php, ...       core
  │   │   ├── Question.php, QuestionOption.php, ...    content
  │   │   ├── TestSession.php, Response.php, ...        sessions + scoring
  │   │   ├── KudoEvent.php, LifeEvent.php              gamification
  │   │   ├── LivesPurchase.php                         Stripe one-shot audit
  │   │   └── Config.php                                encrypted-at-rest secrets
  │   ├── Services/
  │   │   ├── AI/
  │   │   │   ├── VocabTutorService.php                 Claude diagnose
  │   │   │   └── Prompts/VocabTutorPrompts.php         system + user prompts
  │   │   ├── Auth/OtpService.php
  │   │   ├── Config/SiteConfig.php                     runtime config fetcher
  │   │   ├── Gamification/
  │   │   │   ├── LivesService.php                      daily-reset model
  │   │   │   ├── KudosService.php
  │   │   │   └── OutboundKudosSync.php                 ships to account.allgifted.com
  │   │   ├── Irt/
  │   │   │   ├── TestSessionService.php
  │   │   │   ├── ItemSelector.php
  │   │   │   ├── VocabileScore.php
  │   │   │   └── Strategies/{Test,Diagnostic,SkillPractice,VocabPath}Strategy.php
  │   │   ├── Payment/
  │   │   │   ├── LivesPurchaseService.php              Stripe Checkout (one-shot)
  │   │   │   └── PremiumSubscriptionService.php        Stripe Checkout (subscription)
  │   │   └── Seeding/
  │   │       └── DistractorPicker.php                  POS-matched distractors
  │   └── Providers/AppServiceProvider.php              boots SiteConfig + Cashier::ignoreRoutes
  ├── config/
  │   ├── services.php                                  stripe + anthropic + sso config
  │   ├── cashier.php                                   Cashier overrides (STRIPE_SECRET fallback)
  │   └── tenancy.php                                   default school slug (dev only)
  ├── database/
  │   ├── migrations/                                   59 migrations as of HEAD
  │   └── seeders/                                      ClientAppSeeder + bulk_words seeders
  ├── routes/api.php                                    THE entry point for all backend routes
  ├── tests/Feature/                                    53 feature tests
  ├── mobile/                                           Flutter web app
  │   ├── lib/
  │   │   ├── main.dart                                 boot + SSO callback handler
  │   │   ├── api_client.dart                           all backend calls
  │   │   ├── models/                                   typed JSON wrappers
  │   │   ├── screens/
  │   │   │   ├── splash_screen.dart
  │   │   │   ├── login_screen.dart                     OTP + "Sign in with AGS Account"
  │   │   │   ├── home_screen.dart                      app entry (Diagnostic / Practice / Path)
  │   │   │   ├── test_screen.dart                      THE big one — answer flow
  │   │   │   └── results_screen.dart
  │   │   ├── widgets/
  │   │   │   ├── out_of_lives_modal.dart               buy lives + premium upsell
  │   │   │   ├── premium_upgrade_sheet.dart            monthly/annual picker
  │   │   │   ├── tutor_diagnosis_modal.dart            AI tutor result
  │   │   │   └── ...
  │   │   ├── services/
  │   │   │   ├── sound_service.dart                    correct/wrong/tada
  │   │   │   └── read_aloud_service.dart               TTS bridge
  │   │   └── utils/checkout_launcher.dart              wraps url_launcher
  │   ├── web/                                          PWA platform config (index.html, manifest)
  │   └── pubspec.yaml
  ├── deploy/                                           bash scripts (idempotent, scp-able)
  │   ├── setup-backend.sh                              fresh-droplet backend bootstrap
  │   ├── import-vocab.sh                               local DB → prod
  │   ├── install-flutter-and-build.sh                  upgrade Flutter + build web
  │   ├── enable-vhosts.sh                              a2ensite + configtest + reload
  │   ├── fix-vocab-redirect.sh                         post-certbot HTTP redirect fix
  │   ├── fix-mail-password.sh                          re-encrypt config secrets post-import
  │   ├── fix-tenant-domain.sh                          set custom_domain on schools row
  │   ├── account-scaffold.sh                           bootstrap account/ on droplet
  │   ├── sync-vocab-sso.sh                             copy SSO_JWT_SECRET from account DB
  │   ├── enable-tutor.sh                               copy Anthropic key + flag on tutor
  │   ├── tutor-smoke.sh                                end-to-end Claude round-trip
  │   ├── smoke.sh, final-smoke.sh                      public HTTPS health probes
  │   ├── diag-mail.sh, diag-otp.sh                     diagnostics
  │   └── *.conf                                        Apache vhost templates
  ├── docs/
  │   ├── HANDOFF.md                                    ← THIS DOC
  │   └── SESSION-LOG.md                                chronological journal
  └── CLAUDE.md                                         auto-loaded entry pointer
```

---

## 9. Common pitfalls (the hard-won lessons)

These bit us during the build. Save the next person the same hours.

1. **APP_KEY mismatch on cross-env DB import.** When you `mysqldump` a
   vocab DB from local and import to prod, encrypted Config rows
   (`type=password|secret`) can't be decrypted by the destination
   because APP_KEY differs. Symptom: SMTP fails with "535
   authentication failed" because the password field silently fell
   through as the literal encrypted base64 blob. **Fix:** the
   `Config::getValueAttribute` accessor now returns `null` + logs a
   warning when decrypt fails on a value that looks Laravel-encrypted
   (starts with `eyJ`). `deploy/import-vocab.sh` flags `NEEDS RESET:
   <key>` rows after import.

2. **Tenant resolution fails in production.** ResolveSchool's dev
   fallback only fires in `local`/`testing`. In prod, if the request
   doesn't carry an `X-Tenant` header AND the host doesn't match a
   `custom_domain` OR `subdomain`, `current_school` stays null and
   any school-scoped insert blows up. **Fix:** set
   `schools.custom_domain = 'vocabapi.allgifted.com'` on the primary
   school. Each new school you onboard needs its own custom_domain or
   the Flutter app must send X-Tenant.

3. **Apache `RewriteEngine On` inside `<Directory>` doesn't enable
   vhost-scope rewrites.** Certbot's HTTP→HTTPS redirect rule is at
   vhost scope; if your only `RewriteEngine On` is inside `<Directory>`,
   the redirect never fires. **Fix:** the HTTP vhost should be redirect-only
   (no SPA fallback there); SPA fallback lives in the HTTPS vhost.

4. **500 on unauth API call without `Accept: application/json`.** Pre-
   existing Laravel behaviour: Sanctum tries to redirect unauthenticated
   requests to a "login" web route that doesn't exist, throwing 500.
   With `Accept: application/json` you get a clean 401. Flutter client
   always sends the header, so never trips this. Don't chase it as a bug.

5. **Cashier's bundled `/stripe/webhook` route collides with ours.**
   Call `Cashier::ignoreRoutes()` in `AppServiceProvider::boot()` and
   register your own at `/api/stripe/webhook` with the
   `VerifyWebhookSignature` middleware explicitly attached.

6. **`mobile/web/` was gitignored.** Caused "This project is not
   configured for the web" on server builds. Removed from
   `.gitignore`; the platform config files (index.html, manifest.json,
   icons) are user-editable and belong in git.

7. **`flutter pub get` fails offline mid-deploy.** Use
   `flutter pub get --offline` if the network is flaky and packages
   are already in the pub cache.

8. **Network flakes between this laptop and the droplet.** SSH
   intermittently times out for ~30s windows. Wrap risky commands in
   a small retry loop; for large transfers, tar everything into one
   file and scp once instead of many small files.

9. **PowerShell mangles bash heredocs and `<` chars in inline scripts.**
   Write multi-line bash scripts to local files and `scp` them up,
   then `ssh ... 'bash /tmp/script.sh'`. Don't try to inline them via
   `ssh ... '...'` from PowerShell — quoting will betray you.

10. **Anthropic returns `{"role":"assistant","content":[{"type":"text","text":"..."}]}`,
    not a plain string.** Always extract `$body['content'][0]['text']`. Also
    strip leading/trailing ```` ```json ``` ```` fences before
    `json_decode`. `VocabTutorService::stripCodeFences` handles this.

---

## 10. Outstanding work (by priority)

### Launch-blocking (do these first)

1. **Rotate the Anthropic API key.** Confirmed 2026-05-27 via direct
   curl: current key returns 401 invalid_x-api-key. AI Vocab Tutor in
   prod is silently returning fallback responses right now, AND the
   12 LLM content generators can't run until rotation. Update
   `vocabapi/.env`, `mathapi/.env`, local `.env`, then
   `php artisan config:clear`. (User opted to defer this; not urgent
   unless tutor diagnoses are actually shipping in fallback mode for
   real learners.)

2. **Account-side endpoints:**
   - `POST /api/plans/ingest` + `user_app_plans` table. Vocab is
     POSTing to this on every Cashier event but account currently
     404s. Account schema: see [Plans-are-per-app](../docs/SESSION-LOG.md).
   - `POST /api/kudos/ingest` + `kudo_events` mirror table. Same shape
     as plans/ingest. Both endpoints accept the same shared-secret
     bearer (`ALLGIFTED_ACCOUNT_TOKEN`).

3. **Math-side outbound syncs.** Math at `c:\allgifted\mathapi11v2`
   needs `OutboundPlanSync` (mirror of vocab's pattern, ~½ day) AND
   `OutboundKudosSync` (mirror, ~½ day). Without these, account's
   aggregates only see vocab's events. **Math also has its own
   broken Stripe pair** (pk + sk from two different accounts) that
   should be replaced before either app takes live money.

4. **Run the 12 LLM content generators.** After key rotation:
   ```bash
   for s in cloze cloze_passage synonym antonym contextual \
            synonym_in_context word_form_mcq collocation_mcq \
            read_aloud_sentence passage_inference register_mcq \
            connotation_mcq; do
     php artisan vocab:generate-content $s --yes
   done
   ```
   Total ~$19.80, adds ~60,000 questions. Idempotent — re-runs skip
   already-covered words.

5. **STRIPE_WEBHOOK_SECRET in prod** is still the OLD live secret
   (won't verify webhook signatures, blocks live payment crediting).
   Replace with a test-mode `whsec_test_...` from Stripe Dashboard
   (or from `stripe listen` during dev), config:cache.

6. **End-to-end smoke the AI tutor on prod** AND the full SSO browser
   flow (account → click Vocab card → land signed in) AND the
   instant-feedback / wrong-answer UX. Three flows that are coded but
   not browser-verified.

7. **Flutter renderers for the 8 new question types**: `cloze_passage`,
   `synonym_in_context`, `collocation_mcq`, `word_form_mcq`,
   `read_aloud_sentence`, `passage_inference`, `register_mcq`,
   `connotation_mcq`. Each needs a render path in `test_screen.dart`.
   Read-aloud and passage-inference are the only ones with
   structurally new UX (longer text + STT for read-aloud).

8. **Parent portal multi-product extension** in `c:\projects\ags_parent`.
   Per the architecture in §5.19. Includes `product_integrations`
   table, per-product adapters, `ChildHome.jsx` with per-product tabs,
   linking-via-account-OTP flow, deploy at `parent.allgifted.com`
   (Forma droplet).

### Smaller polish items

9. **Update marketing site's "Login" dropdown** to a single
   "Sign In → account.allgifted.com" link. One-line edit in the
   Next.js repo on Vercel (separate codebase).

10. **Surface `tutor_enabled` in `/api/config`** so the Flutter "Why
    was this wrong?" button hides cleanly when the server-side flag
    is off (today it shows + the modal gracefully shows "Your tutor
    is taking a break").

11. **Bespoke tutor prompt branches** for `matching`, `pronunciation`,
    and the 8 new shapes — today they fall through to a generic branch.

12. **Filament admin widget for tutor cost.** Use
    `ai_diagnoses.cost_input_tokens` + `cost_output_tokens` columns.

13. **Forma magic-link orchestrator.** Implement the `use_magic_link`
    branch in account's `DashboardController::launch` — server-to-server
    call to Forma's API with `magic_link_token`, return one-time URL.

### Larger projects

14. **Phase 2.3–2.10 teacher analytics** — pre-existing backlog from
    earlier sessions.

15. **Pronunciation skill mic widget** — long-standing.

16. **STRIPE_KEY/STRIPE_SECRET_KEY switch to LIVE keys** for real
    money. Rotate `STRIPE_WEBHOOK_SECRET` at the same time. Re-run
    `php artisan stripe:bootstrap-prices` to create live Products.

### Recently resolved

- ~~Convert `/var/www/html/account` to a real git checkout~~ **DONE 2026-05-27.**
  Deploy key registered on `2ppaamm/account`, server tracks `origin/main`.
- ~~Vocab `/api/parent/...` endpoints~~ **DONE 2026-05-27** —
  `/api/me/vocab-state` + `/api/parent/children/{id}/vocab-state`
  shipped. See §5.9 + §5.19.
- ~~Word bank to 5,000~~ **DONE 2026-05-27** — 5,047 words seeded.
- ~~Per-skill mastery model + APIs~~ **DONE 2026-05-27**.

---

## 11. Test accounts + credentials

### Learner-facing

| Email | Role | Password / OTP |
|---|---|---|
| `pamelaliusm@gmail.com` | admin, `is_premium=true`, `is_unlimited_lives=true` | OTP only — code arrives in Gmail (mail.privateemail.com SMTP via `pam@allgifted.com`) |
| `learner@vocabile.test` | free learner | OTP only — code appears in `storage/logs/laravel.log` in dev |
| `admin@vocabile.test` | imported with the dump | OTP only |

### Account-side (SSO)

Pamela is the seeded admin. Same email; signing in at
`account.allgifted.com` first will create the canonical user there, then
vocab's `/api/sso/exchange` will upsert / match on email.

### Stripe (TEST mode currently)

Keys in `vocabapi/.env`:
- `STRIPE_KEY=pk_test_51NcKSe...`
- `STRIPE_SECRET_KEY=sk_test_51NcKSe...`
- `STRIPE_WEBHOOK_SECRET=` — **OLD LIVE SECRET, replace before webhooks work** (see Outstanding §10.4)
- `STRIPE_PRICE_{5_LIVES,10_LIVES,PREMIUM_MONTHLY,PREMIUM_ANNUAL}` — Test-mode Price IDs

Test card: `4242 4242 4242 4242` (any future date, any CVC).

### Anthropic

`ANTHROPIC_API_KEY` on prod is copied from math's `.env` (same org, single
key shared). Model: `claude-haiku-4-5-20251001`. Token cap 400/response.

### MySQL

Root password lives in `/var/www/html/mathapi/.env` as `DB_PASSWORD`. All
three apps (math, vocab, account) reuse it. Backup the math DB before any
risky migration:

```bash
DBPASS=$(grep ^DB_PASSWORD /var/www/html/mathapi/.env | cut -d= -f2-)
mysqldump --single-transaction --quick -uroot -p"$DBPASS" api \
  | gzip > /var/backups/mysql/api-$(date +%Y%m%d-%H%M%S).sql.gz
```

---

## 12. Reference paths (cross-app)

When working on vocab and you need to consult the Math pattern:

| Topic | Math reference |
|---|---|
| Lives service (5h regen model — NOT what we do, but the original) | `c:\allgifted\mathapi11v2\app\Services\LiveService.php` |
| Lives purchase (one-shot Stripe) | `c:\allgifted\mathapi11v2\app\Services\LivesPurchaseService.php` |
| Stripe webhook (raw SDK pattern) | `c:\allgifted\mathapi11v2\app\Http\Controllers\StripeWebhookController.php` |
| Answer grading flow | `c:\allgifted\mathapi11v2\app\Services\AnswerGradingService.php` |
| AI tutor (the original we ported) | `c:\allgifted\mathapi11v2\app\Services\AI\MathTutorService.php` |
| AI tutor prompts | `c:\allgifted\mathapi11v2\app\Services\AI\Prompts\MathTutorPrompts.php` |
| Flutter question screen | `c:\allgifted\flutter_demo\lib\screens\question_screen.dart` |
| Flutter wrong-answer feedback | `c:\allgifted\flutter_demo\lib\widgets\question_feedback_area.dart` |
| Flutter lives header / countdown | `c:\allgifted\flutter_demo\lib\widgets\lives_header.dart` |
| Flutter out-of-lives modal | `c:\allgifted\flutter_demo\lib\widgets\out_of_lives_modal.dart` |
| AGS brand palette | `c:\allgifted\flutter_demo\AGS_MATH_PALETTE.md` and `c:\allgifted\allgifted-web\CLAUDE-brand.md` |

Parent portal (for the planned vocab integration):
- `c:\projects\ags_parent` — Node.js/Express + React PWA. Reads Forma LMS directly via MySQL today; will consume vocab data via the parent-scoped API listed in Outstanding §10.6.

---

## 13. Versioning + git state at handoff

| Repo | HEAD | Branch |
|---|---|---|
| `2ppaamm/vocab` | `0e2a7b4` after this doc commit | `main` |
| `2ppaamm/account` | `0770089` (with `docs/SSO.md`) | `main` |

53/53 PHP tests green. `flutter analyze` clean (only pre-existing style infos). Production is live across all 3 apps.

---

## How to use this doc

Next CC session: read this top-to-bottom on a fresh boot, then skim
the latest 1–2 entries of `docs/SESSION-LOG.md` for what changed since.
That's enough context to start work on anything in §10 without
re-grepping the codebase.

If a section here is wrong, update it as you go. Treat it as
load-bearing.
