# Technical specification

Authoritative reference for the AGS Vocab system. Catalogs every module, public method, database table, API endpoint, formula, and state machine.

> If something here disagrees with code, **code wins** — file an issue and update this doc.

---

## 1. Module catalog

### 1.1 IRT (`app/Services/Irt/`)

#### `ThreeParameterLogistic` (static)
The 3PL probability + Fisher information functions.

| Method | Returns | Purpose |
|---|---|---|
| `probability(theta, a, b, c)` | `float` 0–1 | P(correct \| θ) for parameters (a, b, c). `z` is clamped to ±30 to avoid `exp` overflow. |
| `probabilityForQuestion(theta, Question)` | `float` 0–1 | Same, pulling a/b/c off the question. Respects `irt_model` (1PL/2PL/3PL). |
| `information(theta, a, b, c)` | `float` ≥0 | Fisher information at θ for the item. Returns 0 if p is at floor or ceiling. |
| `informationForQuestion(theta, Question)` | `float` ≥0 | Same, from a Question. |

#### `AbilityEstimator` (static)
EAP estimator.

| Method | Returns | Purpose |
|---|---|---|
| `eap(responses, priorMean=0, priorSd=1, quadPoints=61, thetaRange=6)` | `['theta' => float, 'se' => float]` | Posterior mean and SD of θ given the response pattern. Integrates over a uniform 61-point grid spanning `[-6, +6]` with a normal prior. Numerically stable: subtracts `max(log_likelihoods)` before exponentiating. |

#### `ItemSelector` (static)
Max Information item picker with exposure control.

| Method | Returns | Purpose |
|---|---|---|
| `pickNext(theta, excludeIds[], candidatePoolSize=5)` | `Question?` | Pull questions in the band `[θ-1.5, θ+1.5]` ordered by `exposure_count` ASC, rank by Fisher information, randomly pick from the top K. Widens band if empty. |
| `basePool()` | `Question` query | The pool used by every selector — filters `is_active`, `is_calibrated`, status, and the `show_singlish` admin gate. |
| `totalInformation(theta, Collection<Question>)` | `float` | Convenience for diagnostics. |

#### `VocabileScore` (static)
θ → score → grade level mapping.

| Method | Returns | Purpose |
|---|---|---|
| `fromTheta(theta)` | `int` | `round(650 + 200·θ)` clamped to [0, 1500] |
| `levelForScore(score)` | `VocabileLevel?` | First level whose `[score_min, score_max)` contains the score (open-ended for BEYOND) |
| `levelForTheta(theta)` | `VocabileLevel?` | Composition of the above. |

#### `TestSessionService`
Lifecycle orchestrator. Constructor-injects `LivesService`, `KudosService`, `TestStrategyResolver`.

| Method | Returns | Purpose |
|---|---|---|
| `start(User, testTypeId=1, params=[])` | `TestSession` | Creates the row with strategy-supplied defaults (min/max items, SE threshold). |
| `nextQuestion(TestSession)` | `Question?` | Delegates to the strategy's `pickNext`. Returns null and finalizes when the strategy says stop or pool is empty. Increments `exposure_count`. |
| `submitAnswer(TestSession, Question, QuestionOption, ?responseTimeMs)` | `['response' => Response, 'kudos_awarded' => int, 'life_deducted' => bool]` | Re-estimates θ via EAP including the new response, creates the `responses` row, awards kudos OR deducts life (per strategy), updates session counters, finalizes if stop rule is hit. All in a DB transaction. |
| `finalize(TestSession)` | `TestSession` | Computes final score, writes `ability_estimates` if the strategy says canonical. Idempotent. |
| `shouldStop(TestSession)` | `bool` | Strategy passthrough. |

#### Strategies (`app/Services/Irt/Strategies/`)
All implement the `TestStrategy` interface:

```php
interface TestStrategy {
    public function sessionDefaults(): array;          // ['min_items'=>int, 'max_items'=>int, 'se_threshold'=>float]
    public function pickNext(TestSession): ?Question;
    public function shouldUpdateCanonicalScore(TestSession): bool;
    public function shouldStop(TestSession): bool;
    public function checkEligibility(User, params): ?string;   // null = allowed; else error code
}
```

- **`DiagnosticStrategy`** — defaults `{min: 15, max: 40, se: 0.30}`. Pool = full base pool. Stop on SE≤0.30 after 15 items OR max items. Updates canonical = true. Eligibility = 30-day cooldown for non-premium.
- **`SkillPracticeStrategy`** — defaults `{min: 10, max: 10, se: 0.0}`. Pool = JOIN on `word_pos_category` / `word_vocabile_level` / `genre_word` / `questions.skill_id` depending on `scope_kind`. Picks in order: untested → previously-wrong → least-exposed. Updates canonical = false. Eligibility = scope must be present and pool non-empty.
- **`VocabPathStrategy`** — defaults `{min: 10, max: 10, se: 0.0}`. Pool = base pool with **freshness penalty** (info × 0.6 for words answered in the last 14 days). Updates canonical = true. Eligibility = premium (`is_unlimited_lives=true`) only.

`TestStrategyResolver::for(testTypeId)` dispatches to one of the three.

### 1.2 Gamification (`app/Services/Gamification/`)

#### `KudosService`
| Method | Returns | Purpose |
|---|---|---|
| `awardForResponse(User, Question, Response, ?testTypeId)` | `int` | Returns 0 for Diagnostic, 0 for wrong, else `(word.difficulty.rank ?? 0) + 1` (1–7). Writes a `kudo_events` row + atomically increments `users.kudos`. |
| `grant(User, amount, kind, ?sourceId, ?meta, sourceApp='vocab')` | `KudoEvent` | Low-level entry point used by `awardForResponse` and admin actions. |
| `buildSnapshot(User, ?awardedThisAttempt)` | `array` | `{awarded_this_attempt, user_total}` — embedded in every test API response. |

#### `LivesService`
Constants: `DEFAULT_MAX_LIVES = 5`, `REGEN_MINUTES_PER_LIFE = 20`.

| Method | Returns | Purpose |
|---|---|---|
| `syncRegeneration(User)` | `User` | Walks the `lives_lost_at` queue (JSON timestamps), restores any whose +20min eligibility has passed. Idempotent. |
| `deductOne(User, ?sourceId, ?note, ?testTypeId)` | `bool` | Returns false if Diagnostic, premium, or already at 0. Else decrements, pushes timestamp onto queue, writes `life_events`. |
| `grantUnlimited(User)` / `revokeUnlimited(User)` | `User` | Flips `is_unlimited_lives`, logs to `life_events`. |
| `purchase(User, amount, ?note)` | `User` | Credits lives (capped at 99), logs purchase. |
| `buildSnapshot(User)` | `array` | `{lives, max_lives, is_unlimited, next_regen_in_seconds}` |

#### `OutboundKudosSync`
Cross-product unification. Posts unsynced `kudo_events` to `ALLGIFTED_ACCOUNT_URL/kudos/events`. No-op when URL is empty.

| Method | Returns | Purpose |
|---|---|---|
| `isConfigured()` | `bool` | URL set? |
| `flush(batchSize=200)` | `array` | POSTs the next batch, marks `synced_to_remote_at` on success. Returns `{synced: N}` or `{error: ...}`. |

Console command `php artisan kudos:sync` invokes this.

### 1.3 Auth (`app/Services/Auth/`)

#### `OtpService`
Constants: `TTL_MINUTES = 10`, `MAX_ATTEMPTS = 5`, `CODE_LENGTH = 6`.

| Method | Returns | Purpose |
|---|---|---|
| `classifyContact(string)` | `'email'\|'sms'\|null` | Email regex / `^\+?[0-9]{8,15}$` for phone. |
| `request(contact, ?ip)` | `array` | Invalidates prior unused codes for this contact, hashes a new 6-digit code into `otp_codes`, dispatches via the channel, returns `{otp, hint, channel, dev_code?}`. dev_code only in local/testing. |
| `verify(contact, code)` | `['user' => User, 'created' => bool]` | Looks up latest active code for the contact, checks attempts, validates via `Hash::check`, marks consumed, creates or returns the User. |

Dispatch logic:
- Always logs `[OTP] Code for X: 123456` to `storage/logs/laravel.log`
- If SMTP is configured (`mail_host`, `mail_username`, `mail_password` all set), additionally `Mail::raw(...)`
- SMS dispatch is a TODO (Twilio/MessageBird driver)

### 1.4 Config (`app/Services/Config/`)

#### `SiteConfig`
Cached reader for the admin-editable `configs` table.

Constants: `CACHE_KEY = 'site_config:all'`, `CACHE_TTL_SECONDS = 60`.

| Method | Returns | Purpose |
|---|---|---|
| `all()` | `array` | Cached map of `key => casted_value`. |
| `get(key, ?fallback)` | `mixed` | Single-key lookup. |
| `publicAll()` | `array` | Only keys with `is_public=true` — used by `GET /api/config`. |
| `flush()` (static) | `void` | Drops the cache. Called by Config admin save hooks. |

### 1.5 API controllers (`app/Http/Controllers/Api/`)

| Controller | Endpoints | Purpose |
|---|---|---|
| `ConfigController` | `GET /api/config` | Public branding for Flutter startup |
| `VoicesController` | `GET /api/voices` | Reader roster, filtered by `default_accent_code` |
| `AuthController` | `POST /api/auth/request-otp`, `POST /api/auth/verify-otp`, `GET /api/auth/me`, `POST /api/auth/logout` | OTP flow + session info |
| `TestTypesController` | `GET /api/test-types` | 3 test types + scope picker payload |
| `TestController` | `POST /api/tests`, `GET /api/tests/{s}`, `GET /api/tests/{s}/next`, `POST /api/tests/{s}/answer`, `GET /api/tests/{s}/results` | Test lifecycle |
| `LivesController` | `GET /api/lives`, `POST /api/lives/purchase` | Lives status + purchase stub |
| `KudosController` | `GET /api/kudos`, `GET /api/kudos/history` | Kudos snapshot + last 100 events |
| `PronunciationController` | `POST /api/pronunciations` | User audio submission, grades on transcribed_text substring match |

Full request/response shapes in [api-reference.md](api-reference.md).

### 1.6 Filament admin (`app/Filament/Admin/`)

| Resource | Model | Group | Purpose |
|---|---|---|---|
| `WordResource` | `Word` | Content | CRUD for vocabulary entries |
| `QuestionResource` | `Question` | Content | CRUD for test questions |
| `PosCategoryResource` | `PosCategory` | Taxonomy | Parts of Speech editor |
| `VocabileLevelResource` | `VocabileLevel` | Taxonomy | Grade bands + score ranges |
| `BloomLevelResource` | `BloomLevel` | Taxonomy | Bloom's Taxonomy levels (Remember–Create) |
| `SkillResource` | `Skill` | Taxonomy | Recognition / Recall / Production / Pronunciation |
| `WordDifficultyLevelResource` | `WordDifficultyLevel` | Taxonomy | Common → Rare ranks |
| `GenreResource` | `Genre` | Taxonomy | The 20 usage genres |
| `VoiceResource` | `Voice` | Taxonomy | Reader personas + per-voice toggle |
| `TestSessionResource` | `TestSession` | Sessions | All test sessions, filterable by user/status |
| `KudoEventResource` | `KudoEvent` | Gamification | Read-only ledger |
| `LifeEventResource` | `LifeEvent` | Gamification | Read-only ledger |
| `UserResource` | `User` | User Management | Add admins, grant unlimited lives |
| `StatusResource` | `Status` | System | The 5 status rows (shared with AGS Math) |
| `ConfigResource` | `Config` | System | The admin-tunable settings — the single most-used resource |

Branding: a render hook in `AdminPanelProvider::panel()` injects `resources/views/filament/admin-theme.blade.php` into every page's `<head>` — applies the crimson sidebar, gold accents, AGS Math visual style.

Authentication: custom OTP login at `App\Filament\Admin\Pages\Auth\OtpLogin` replaces the default password form.

Dashboard widgets: `OverviewStats` (5 stat cards), `RecentSessions` (last 10 sessions table).

---

## 2. Database catalog

### 2.1 Content

#### `words`
| Column | Type | Default | Notes |
|---|---|---|---|
| `id` | bigint PK | | |
| `status_id` | FK statuses | null | Public (3) by default for seeded entries |
| `lemma` | varchar(100) | | UNIQUE |
| `pos` | varchar(20) | null | Free-text legacy POS; the M:N pivot is authoritative |
| `cefr_level` | varchar(4) | null | Legacy column kept for compat |
| `frequency_rank` | int unsigned | null | Corpus rank (e.g. SUBTLEX) |
| `word_difficulty_level_id` | FK word_difficulty_levels | null | |
| `definition` | text | null | |
| `is_singlish` | bool | false | Hidden in tests when `show_singlish` config is off |
| `tags` | JSON | null | Free-form admin labels |
| `pronunciation_ipa` | varchar(80) | null | IPA notation |
| `pronunciation_respelling` | varchar(80) | null | "FUN-ear-uhl" etc. |
| `pronunciation_audio_path` | varchar(191) | null | Storage path |
| timestamps | | | |

Indexes: `lemma` (unique), `cefr_level`, `frequency_rank`, `is_singlish`, `word_difficulty_level_id`, `status_id`.

#### `questions`
| Column | Type | Default | Notes |
|---|---|---|---|
| `id` | bigint PK | | |
| `status_id` | FK statuses | null | |
| `word_id` | FK words | | |
| `bloom_level_id` | FK bloom_levels | null | |
| `skill_id` | FK skills | null | |
| `format` | varchar(30) | `mc4` | `mc4` \| `synonym` \| `definition` \| `cloze` \| `contextual` |
| `stem` | text | | The prompt shown to the learner |
| `correct_option_index` | tinyint unsigned | null | 0-based |
| `discrimination` | decimal(8,4) | 1.0 | IRT `a` |
| `difficulty` | decimal(8,4) | 0.0 | IRT `b` |
| `guessing` | decimal(8,4) | 0.25 | IRT `c` (3PL only) |
| `irt_model` | varchar(10) | `3PL` | `1PL` \| `2PL` \| `3PL` |
| `is_calibrated` | bool | false | Only true items are servable |
| `is_active` | bool | true | |
| `exposure_count` | int unsigned | 0 | Incremented per administration |
| `p_value` | decimal(6,4) | null | Classical proportion correct, computed offline |
| timestamps | | | |

#### `question_options`
| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `question_id` | FK questions | |
| `position` | tinyint unsigned | UNIQUE per question (0-based display order) |
| `label` | varchar(255) | The option text |
| `is_correct` | bool | Exactly one row per question should be true |

#### `pronunciations` (user audio submissions)
| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `user_id` | FK users | |
| `question_id` | FK questions nullable | |
| `response_id` | FK responses nullable | |
| `word_id` | FK words | |
| `audio_path` | varchar(191) | Storage path under `pronunciations/` |
| `transcribed_text` | varchar(255) | Device-side STT output |
| `is_correct` | bool | Normalised substring match |
| `confidence` | decimal(4,3) | STT confidence 0–1 |
| `duration_ms` | int unsigned | |
| `attempt_no` | smallint unsigned | Per (user, word) |

### 2.2 Taxonomies

#### `pos_categories`, `vocabile_levels`, `bloom_levels`, `skills`, `word_difficulty_levels`, `genres`, `statuses`
All share the lookup pattern: `id`, `code` (unique slug), `name`, `description`, `color_hex`, `display_order`, `status_id`, timestamps. Specifics:

- `vocabile_levels` adds `short_name`, `score_min`, `score_max` (score_max nullable → BEYOND)
- `bloom_levels` adds `level_number` (1–6), `difficulty_offset` (added to IRT b), `item_format_hint`
- `skills` adds `mode` (`receptive` | `productive`)
- `word_difficulty_levels` adds `rank` (1–6), `theta_anchor`
- `genres` adds `icon` (Heroicon name)
- `statuses` is *fixed* 5 rows (Only Me, Restricted, Public, Draft, Archived) — shared schema with AGS Math

#### `voices`
| Column | Type | Notes |
|---|---|---|
| `id`, `code`, `name` | | Identity |
| `accent_code` | varchar(8) | BCP-47 e.g. `en-US` |
| `accent_label` | varchar(32) | Human-readable, e.g. `American` |
| `gender` | varchar(12) | `female` \| `male` \| `neutral` |
| `character` | varchar(24) | `girl` \| `boy` \| `man` \| `woman` \| `teacher` \| `coach` \| `elder` |
| `voice_hints` | JSON | Ranked substring list for browser TTS voice matching |
| `pitch` | decimal(3,2) | 0.80–1.30 typical |
| `rate` | decimal(3,2) | 0.70–1.20 typical |
| `letter`, `placard_color`, `mood`, `intro` | | Stickman appearance + greeting |
| `is_active` | bool | Toggles rotation membership |
| `status_id`, `display_order` | | |

### 2.3 M:N pivots

| Pivot | Composite PK | Extra |
|---|---|---|
| `word_pos_category` | `(word_id, pos_category_id)` | `is_primary` |
| `word_vocabile_level` | `(word_id, vocabile_level_id)` | `is_primary` |
| `genre_word` | `(genre_id, word_id)` | `is_primary` |

All have timestamps. Cascade-delete on the word side.

### 2.4 Sessions + results

#### `test_sessions`
- `user_id`, `test_type_id` (FK), `scope_kind` (skill/pos/level/genre/null), `scope_id` (nullable), `updates_canonical` (bool)
- `status` (in_progress/completed/abandoned), `theta`, `theta_se`, `vocabile_score` (nullable until finalized)
- `items_administered`, `items_correct`, `min_items`, `max_items`, `se_threshold`
- `started_at`, `finished_at`

#### `responses`
- `test_session_id`, `question_id`, `question_option_id` (nullable for skipped)
- `is_correct`, `kudos_awarded`, `life_deducted`
- `response_time_ms`, `theta_before`, `theta_after`, `theta_se_after`, `sequence_no`
- UNIQUE `(test_session_id, question_id)` — no double-answer
- UNIQUE `(test_session_id, sequence_no)` — strict ordering

#### `ability_estimates`
Final theta snapshot per completed session: `user_id`, `test_session_id`, `theta`, `theta_se`, `vocabile_score`, `level_code` (FK code, not id).

#### `test_types` (3 fixed rows)
- `code` (`vocab_diagnostic` / `skill_practice` / `vocab_path`)
- `name`, `tagline`, `description`, `icon`, `color_hex`
- `requires_premium`, `cooldown_days`, `updates_canonical_score`
- `status_id`, `display_order`

### 2.5 Identity + gamification

#### `users`
- `name`, `email` (nullable for phone-only), `phone` (nullable), `role` (`admin`/`user`)
- `email_verified_at`, `phone_verified_at`
- `lives`, `max_lives`, `lives_lost_at` (JSON timestamp queue), `is_unlimited_lives`, `kudos`
- `password` (legacy column, nullable, unused — OTP-only)

#### `otp_codes`
- `contact`, `channel` (email/sms), `code_hash` (bcrypt), `attempts`, `expires_at`, `consumed_at`, `requested_ip`

#### `personal_access_tokens` (Sanctum)
Standard `laravel/sanctum` table.

#### `kudo_events` (immutable ledger)
- `user_id`, `source_app` (vocab/math/...), `source_kind`, `source_id` (polymorphic ref), `amount` (signed), `meta` (JSON)
- `awarded_at`, `synced_to_remote_at` (null until pushed to central AllGifted account service)

#### `life_events` (audit log)
- `user_id`, `kind` (lost/regen/purchased/admin_adjusted/unlimited_granted/unlimited_revoked)
- `delta` (signed), `lives_after`, `source_id`, `note`, `occurred_at`

### 2.6 Configuration

#### `configs`
- `key` (unique slug), `value` (text, nullable, encrypted if type is password/secret), `default_value`
- `type` (`string`/`int`/`bool`/`color`/`url`/`json`/`font`/`password`)
- `category` (`branding`/`theme`/`typography`/`layout`/`content`/`feature`/`mail`)
- `label`, `description`, `is_public`, `display_order`

Encryption: the `Config` model has a mutator/accessor that calls `Crypt::encryptString`/`decryptString` when type ∈ {`password`, `secret`}. The rest of the app reads `$config->value` and gets plaintext transparently.

---

## 3. API contract summary

All routes prefixed with `/api`. See [api-reference.md](api-reference.md) for full request/response.

### Public
| Method | Path | Purpose |
|---|---|---|
| GET | `/config` | Public branding for Flutter startup |
| GET | `/voices` | Reader voice rotation pool |
| POST | `/auth/request-otp` | Dispatch a 6-digit OTP |
| POST | `/auth/verify-otp` | Verify OTP, return bearer token |

### Authenticated (Sanctum `auth:sanctum`)
| Method | Path | Purpose |
|---|---|---|
| GET | `/auth/me` | Current user + lives + kudos |
| POST | `/auth/logout` | Revoke current token |
| GET | `/test-types` | 3 types + eligibility + scope picker |
| POST | `/tests` | Start a session (accepts `test_type`, optional `scope_kind`, `scope_id`) |
| GET | `/tests/{session}` | Session state |
| GET | `/tests/{session}/next` | Next question |
| POST | `/tests/{session}/answer` | Submit answer; returns response + next + lives + kudos |
| GET | `/tests/{session}/results` | Final results |
| POST | `/pronunciations` | Submit audio + transcription |
| GET | `/lives` | Lives snapshot |
| POST | `/lives/purchase` | Buy lives (dev: instant; prod: 501 stub) |
| GET | `/kudos` | Kudos snapshot |
| GET | `/kudos/history` | Last 100 events |

---

## 4. Formulas + constants

### IRT
- `P(correct | θ) = c + (1 - c) / (1 + exp(-a · (θ - b)))`
- Information `I(θ) = a² · ((p - c)² · (1 - p)) / ((1 - c)² · p)`
- EAP: posterior mean over 61 quadrature points spanning [−6, +6] with N(0, 1) prior

### Scoring
- `vocabile_score = round(650 + 200 · θ)`, clamped to `[0, 1500]`
- Level lookup: first `vocabile_levels` row where `score_min ≤ score AND (score_max IS NULL OR score_max > score)`

### Kudos
- `correct in Diagnostic → 0`
- `correct in Skill Practice or Vocab Path → (word.difficulty.rank ?? 0) + 1`  (1–7 kudos)
- `wrong → 0`

### Lives
- `MAX_LIVES = 5` (default; per-user `max_lives` may override)
- `REGEN_MINUTES_PER_LIFE = 20`
- Diagnostic and premium users: no deduction
- Regen logic: walk `lives_lost_at` queue, restore one for each timestamp that's ≥20min old; update queue

### Cooldown (Diagnostic)
- `COOLDOWN_DAYS = 30` for non-premium
- `eligible_at = last_finished_at + 30 days`

### OTP
- `TTL_MINUTES = 10`
- `MAX_ATTEMPTS = 5`
- `CODE_LENGTH = 6` (decimal, zero-padded)

### SiteConfig cache
- `CACHE_TTL_SECONDS = 60`
- Manual flush via `SiteConfig::flush()` after admin writes

---

## 5. State machines

### Test session
```
[idle] ──start──▶ [in_progress] ──answer──▶ [in_progress] ──(stop rule)──▶ [completed]
                       │                            │
                       └─ user_abandon ──────────▶ [abandoned]
```

`status` column transitions; `completed`/`abandoned` are terminal. Server enforces no double-answer per question via the UNIQUE index on `(test_session_id, question_id)`.

### Lives regeneration
```
[full]   ─(wrong answer)──▶ [4/5] ─(+20min)──▶ [5/5]
[4/5]    ─(wrong answer)──▶ [3/5]
...
[0/5]    ─(refill/regen/grant)──▶ [N/5]
```

On every read, `LivesService::syncRegeneration` processes the queue. The queue is JSON of ISO timestamps; processed entries are removed.

### OTP
```
[no code] ──request──▶ [code_issued] ──(success)──▶ [consumed]
                            │
                            ├─(expire after 10min)──▶ [expired]
                            └─(5 wrong attempts)────▶ [exhausted]

A new request for the same contact invalidates any prior unconsumed code.
```

---

## 6. Background jobs

| Command | Schedule | Purpose |
|---|---|---|
| `php artisan kudos:sync` | Suggested: every 1 min (cron) | POST unsynced `kudo_events` to `ALLGIFTED_ACCOUNT_URL/kudos/events`. No-op when URL is empty. |

Other jobs to add later:
- Lives regen sweep (currently lazy; could be pre-emptive)
- Item exposure rebalancing (offline scoring of p-values, recalibrate b)
- Question retirement (auto-mark Archived when exposure_count > N and p_value extreme)

---

## 7. Cross-platform unification (with AGS Math)

| Surface | How |
|---|---|
| `statuses` | Shared 5 fixed IDs (Only Me / Restricted / Public / Draft / Archived) |
| Kudos ledger | `kudo_events.source_app` field; both apps push to central via `OutboundKudosSync` |
| Auth flow | OTP-only, same endpoint shape; the central account service could become the IdP later |
| Branding | `configs` table — each AGS deployment re-skins via admin |
| Logos / mascots | AGS logo asset shared across products (`assets/logo.png`) |

---

## 8. Configuration knobs (key configs only)

| Key | Type | Default | Purpose |
|---|---|---|---|
| `default_accent_code` | string | `en-US` | School-wide reader accent |
| `show_singlish` | bool | true | Toggle Singlish vocab visibility in tests |
| `feature_lives_enabled` | bool | true | Master kill-switch for lives |
| `feature_kudos_enabled` | bool | true | Master kill-switch for kudos |
| `feature_otp_only` | bool | true | When off, future password flows could be re-enabled |
| `primary_color` | color | `#960000` | Crimson — admin sidebar + page titles |
| `secondary_color` | color | `#BF9237` | Gold — kudos, rewards |
| `cta_color` | color | `#88C808` | Lime — primary action buttons |
| `primary_font` | font | `Raleway` | Google Fonts family |
| `logo_path` | string | `brand/logo.png` | Path under `public/` |
| `mail_host` | string | `mail.privateemail.com` | SMTP host |
| `mail_password` | password | (encrypted) | SMTP password — encrypted at rest |
| `mail_from_address` | string | `pam@allgifted.com` | Sender address |

Full list in `database/seeders/ConfigSeeder.php`.
