# Configuration

> **⚠ Status (2026-05-23)**: Two material changes since this doc was
> written:
>
> 1. **`partners.php` `kudos.*` block was DELETED.** It pointed at
>    `KudosService` which is also deleted. The active kudos formula is
>    in `KudosCalculator::calculate` — a single rule, mode-aware,
>    not partner-configurable. `partners.php` retains only `lives.*` and
>    `features.*`.
> 2. **`configs.maxile_lookback_window` was pinned** via the May-22
>    migration (`2026_05_22_120000_pin_maxile_config_baseline_to_configs.php`)
>    to capture the dev value (5) so it propagates to prod via the
>    standard `php artisan migrate` flow.
> 3. **New columns** added since this doc: `kiasu_field_progress`
>    table (per-(user, field) cursor for kiasu Step 3.6 advance).
>
> See [SYSTEM.md](SYSTEM.md) for current truth.

This doc covers every layer of configuration in the AllGifted Math
backend: the singleton `configs` DB row, the static lookup tables that
behave like config (statuses, difficulties, levels, test_types), and the
file-based partner config in `config/partners.php`. Each layer has a
distinct purpose and audience.

| Layer | Storage | Audience | Mutable at runtime? |
|---|---|---|---|
| `configs` row | DB singleton, 1 row in `configs` table | App-wide site settings, pass/fail thresholds, Kiasu Path tuning | Yes (admin UI) |
| `statuses` | DB lookup, 5 rows | Soft-delete / publication state for every owned entity | Effectively no |
| `difficulties` | DB lookup, 3 rows | Question difficulty tiers; drives mastery thresholds | Theoretical yes; nothing in code expects changes |
| `levels` | DB lookup, 13 rows | Maxile bands per school year; drives all maxile math | Adding/editing requires reseeding |
| `test_types` | DB lookup, 4 rows | Distinguishes Kiasu / Track Practice / Diagnostic / Assignment | Effectively no |
| `fields`, `tracks`, `skills`, `questions` | DB content, large | Content hierarchy with status flags | Yes (admin UI + content port) |
| `partners.php` | PHP file in `config/` | Per-partner lives + kudos rules | Code change + deploy |
| `.env` | File on disk | Secrets, DB connection, mail, environment toggles | Restart required |

---

## 1. The `configs` singleton

One row in the `configs` table. **One row only** — the model
(`app/Models/Config.php`) treats it as a singleton:

```php
public static function current() {
    return cache()->remember('app.config', 3600, function () {
        return static::first() ?? static::create([...]);
    });
}
```

Cached for an hour under `app.config`. Writes go through
`Config::updateSettings` which clears the cache.

### Columns that drive runtime behavior

Verified against the live `api` DB (1 row, id=1):

| Column | Type | Live value | Purpose | Read via |
|---|---|---|---|---|
| `no_rights_to_pass` | int | **3** | Consecutive corrects needed to upgrade a difficulty tier in a skill | `Config::passThreshold()` → [[MASTERY.md]] |
| `no_wrongs_to_fail` | int | **2** | Consecutive wrongs needed to downgrade a difficulty tier | `Config::failThreshold()` → [[MASTERY.md]] |
| `questions_per_test` | int | **2** | Used in Kiasu Path batches and via `Config::getQuestionsPerTest()` (fallback 20) | [[QUESTION_ASSIGNMENT.md]] |
| `maxile_lookback_window` | int | **10** | Last-N attempts considered by `MaxileService::calculateSkillMaxile` | [[MAXILE.md#system-b]] |
| `number_of_teaching_days` | int | 5 | Used in some scheduling logic | (legacy) |
| `kiasu_path_questions_per_batch` | int | 5 | Questions added per Kiasu Path top-up | [[QUESTION_ASSIGNMENT.md#2-kiasu-path]] |
| `kiasu_path_premium_only` | bool | 1 | Gate Kiasu Path to premium users | `KiasuController::postAnswers` checks `access_type === 'premium'` |
| `kiasu_path_min_questions_for_skill` | int | 3 | (Not currently read in selection) | — |
| `kiasu_path_max_questions_per_test` | int | 20 | (Not currently read; KiasuPathService uses `questions_per_test`) | — |
| `kiasu_path_weak_skill_threshold` | int | 60 | Below-60 score = "weak" skill | (Kiasu scoring, not in current selection SQL) |
| `kiasu_path_strong_skill_threshold` | int | 80 | Above-80 score = "strong" skill | — |
| `kiasu_path_failed_skill_weight` | int | 60 | Weight in adaptive selection | — |
| `kiasu_path_new_skill_weight` | int | 30 | — | — |
| `kiasu_path_struggling_skill_weight` | int | 10 | — | — |
| `kiasu_path_exclude_recent_hours` | int | 24 | Recency window for Kiasu selection | — |
| `kiasu_path_require_diagnostic` | bool | 1 | Block Kiasu Path until diagnostic done | (intent, not currently enforced) |
| `kiasu_path_focus_tracks_count` | int | 3 | Number of weak tracks to prioritize | — |
| `self_paced` | bool | 1 | Self-paced learning mode toggle | — |
| `timezone` | varchar | UTC | Site default timezone | — |
| `date_format` | varchar | d/m/Y | Display format | — |
| `time_format` | varchar | 12 | 12h or 24h display | — |
| `maintenance_mode` | bool | 0 | Toggle site-wide maintenance message | — |
| `maintenance_message` | text | null | Shown when `maintenance_mode = 1` | — |

> **Heads up — the local DB has thresholds different from the model's
> documented defaults.** `Config::passThreshold()` returns whatever's in
> the row (3 here), not the documented fallback (2). The fallback only
> applies if the row is missing or NULL, which won't happen — the
> singleton is created at first call. Reseed if a fresh env shows
> different mastery behavior.

> **Same key, different reads — `questions_per_test`.** The Laravel
> config layer has a `Config::get('questions_per_test', 20)` call in
> `QuestionAssignmentService::findQuestionsForTrack` that reads from
> `config/app.php`-style sources (default 20). The DB column has
> default 10 (current value 2). Two different reads of the same name.
> See [[QUESTION_ASSIGNMENT.md#gotchas]].

### Columns that don't drive runtime behavior

The `configs` table has ~50 columns. Most are theming (colors, fonts,
font sizes), mail settings (DO NOT USE — see CLAUDE.md), and admin
display preferences. They don't affect grading, maxile, or selection.

**Mail config columns** (`mail_host`, `mail_port`, `mail_username`,
`mail_password`, `mail_encryption`, `mail_from_name`,
`mail_from_address`): **must remain NULL on every environment.** SMTP is
configured exclusively via `.env`. See CLAUDE.md.

### Admin UI

The `configs` row is editable via `Admin\ConfigurationController`. Use
that, not direct SQL. Writes invalidate the `app.config` and
`questions_per_test` caches.

---

## 2. `statuses` — soft-delete / publication

5-row lookup that every owned entity references via `status_id`:

| `id` | `status` | Meaning |
|---|---|---|
| 1 | Only Me | Unpublished — only creator can see |
| 2 | Restricted | Restricted by community |
| 3 | **Public** | Everyone can see — used as the "is live" filter |
| 4 | Draft | Draft, not to be published |
| 5 | Archived | Old user test, rolled back when starting a new diagnostic (added 2026-05-11) |

**Rule of thumb**: any query against content (`questions`, `tracks`,
`skills`, `levels`, `fields`, `difficulties`, `test_types`, etc.) that
serves end-users should filter `status_id = 3`. The codebase has many
`->public()` scopes and explicit `where('status_id', 3)` clauses for
this.

`status_id = 5` (Archived) is a recent addition for the diagnostic
rollback flow; not used by content selection.

---

## 3. `difficulties` — question difficulty tiers

3-row lookup:

| `id` | `difficulty` | `short_description` | `status_id` |
|---|---|---|---|
| 1 | 1 | 1 - Knowledge and Comprehension | 3 |
| 2 | 2 | 2 - Application and Analysis | 3 |
| 3 | 3 | 3 - Synthesis and Evaluation | 3 |

Read via `Difficulty::tierCount()` which returns
`max(difficulty) WHERE status_id = 3` — **currently 3**. This number is
load-bearing:

- It's the denominator for skill maxile interpolation (see
  [[MAXILE.md#system-a]]):
  `skill_maxile = level.start + (difficulty_passed / tierCount) * range`
- It's the threshold for `skill_passed` (see [[MASTERY.md]]):
  `skill_passed = (difficulty_passed >= tierCount)`

If a 4th tier is added with `status_id = 3`, every existing user's
mastery flag becomes invalid (their `difficulty_passed = 3` is no
longer "mastered"). Coordinate with a backfill before touching this
table.

Cached for the request lifetime in `MaxileService::$maxDifficultyCache`.

---

## 4. `levels` — maxile bands by school year

13-row lookup. Each row defines a `start_maxile_level` /
`end_maxile_level` range that a track sits inside.

Live data:

| `level` | `description` | `age` | Range | `status_id` |
|---|---|---|---|---|
| 0 | Kindergarten | 0 | 0–100 | 3 |
| 100 | Primary/Grade/Year 1 | 7 | 100–200 | 3 |
| 200 | Primary/Grade/Year 2 | 8 | 200–300 | 3 |
| 300 | Primary/Grade/Year 3 | 9 | 300–400 | 3 |
| 400 | Primary/Grade/Year 4 | 10 | 400–500 | 3 |
| 500 | Primary/Grade/Year 5 | 11 | 500–600 | 3 |
| 600 | Primary/Grade/Year 6 | 12 | 600–700 | 3 |
| 700 | Sec 1 / Year 7 | 13 | 700–800 | **4** (Draft) |
| 800 | Sec 2 / Year 8 | 14 | 800–900 | **4** |
| 900 | Sec 3 / Year 9 | 15 | 900–1000 | **4** |
| 1000 | Sec 4 / Year 10 | 16 | 1000–1100 | **4** |
| 1100 | Pre-U1 / Year 11 | 17 | 1100–1200 | **4** |
| 1200 | Pre-U2 / Year 12 | 18 | 1200–1300 | **4** |

**Today, only Kindergarten through Primary 6 (levels 0-600) are public.**
Secondary and pre-U levels exist as Draft (status_id = 4). They are
filtered out of selection, maxile defaults, and age-to-maxile mappings:

- `AdaptiveLevelService::getDefaultMaxileLevel()` filters `status_id = 3`.
- `AdaptiveLevelService::maxileLevelFromAge()` filters `status_id = 3`
  — without this clamp, an out-of-range age (e.g. an adult test user
  at 60) falls through to the secondary/pre-U levels and overshoots
  the diagnostic's bounded primary range. See the inline comments at
  `AdaptiveLevelService.php:248-282`.

If product wants to expose secondary content, **flip `status_id = 3`
for the relevant level rows AND audit every `status_id = 4` filter in
the code** — some don't expect those levels.

### Ranges are 100-wide

Every level has a 100-unit range. The maxile interpolation in
[[MAXILE.md]] divides this range by `Difficulty::tierCount()` to
get the per-tier step. With 3 tiers, each tier earns the user
`100/3 ≈ 33.33` maxile. With 4 tiers, it'd be 25. Hardcoded
range widths aren't required for the math, but they're assumed
across the codebase (and there's an old bug-fix comment in
`Question::processProgressFor` about removing a hardcoded `100`).

---

## 5. `test_types` — test categorization

4-row lookup:

| `id` | `test_type` | `description` |
|---|---|---|
| 1 | Kiasu Path | Adaptive AI-powered personalized learning path |
| 2 | Track Practice | Practice questions from selected track |
| 3 | Diagnostic | Diagnostic test to determine user level |
| 4 | Assignment | Teacher assigned practice test |

`tests.test_type_id` and `question_user.test_type_id` reference this.
Most controllers hardcode the ID they need:

- `QuestionAssignmentService::createTest` writes `test_type_id = 2` for
  track practice.
- `KiasuPathService::firstOrCreateKiasuPath` looks up the ID by name
  (`TestType::where('test_type', 'Kiasu Path')->value('id')`).
- `KiasuPathService::assignQuestionsToTest` hardcodes
  `question_user.test_type_id = 1` regardless of the actual test type
  — known inconsistency (see [[QUESTION_ASSIGNMENT.md#gotchas]]).

---

## 6. Content hierarchy — `fields` / `tracks` / `skills` / `questions`

Not strictly config, but they're the data that everything else is keyed
on:

```
fields  ─┐
         │   (has many)
         ▼
       tracks ── level_id ──→ levels
         │
         │   (many-to-many via skill_track)
         ▼
       skills
         │
         │   (has many)
         ▼
      questions ── difficulty_id ──→ difficulties
                ── skill_id ─────────→ skills
                ── is_diagnostic ──── (partition flag)
                ── type_id ──────────→ 1 (MCQ) | 2 (FIB)
                ── status_id ────────→ statuses
                ── qa_status ────────→ unreviewed | approved | flagged | needs_revision | ai_generated
```

Live data summary:

- **Fields**: 5 public (`status_id = 3`) — Number & Algebra,
  Measurement, Geometry & Spatial Reasoning, Statistics & Probability,
  Word Problems & Applications. ~26 historical/draft fields exist with
  `status_id = 1`.
- **Tracks**: many, with `level_id` and `field_id`. Filter
  `status_id = 3` for public.
- **Skills**: many; live in tracks via `skill_track` many-to-many.
- **Questions**: ~32k rows; ~27.5k answered; spans 2018-2026.

### `qa_status` on questions

The `questions.qa_status` enum: `unreviewed`, `approved`, `flagged`,
`needs_revision`, `ai_generated`. Not currently used as a selection
filter — `status_id = 3` is the production gate. `qa_status` is
advisory for the admin review workflow.

---

## 7. `config/partners.php`

File-based config for per-partner overrides of lives and kudos
behavior. Read via `LiveService::getConfig($user)`.

```php
return [
    'default' => [
        'lives' => ['enabled' => true, 'max_lives' => 5],
        'features' => [
            'show_correct_answers' => true,
            'unlimited_retakes' => false,
        ],
        'kudos' => [
            'correct_base' => 1,
            'correct_difficulty_multiplier' => 1,
            'incorrect_consolation' => 0,
            'streak_bonus_enabled' => false,
            'streak_bonus_multiplier' => 0.1,
            'time_bonus_enabled' => false,
            'time_bonus_multiplier' => 0.2,
            'time_bonus_threshold' => 60,
        ],
    ],
    'telco' => [
        'default' => ['lives' => ['enabled' => true, 'max_lives' => 5]],
    ],
    'schools' => [
        'default' => ['lives' => ['enabled' => false]],
    ],
];
```

- **`default`** is the baseline. Every user inherits this unless their
  partner overrides.
- **`telco`** is the SIMBA partnership (mobile network bundle). Lives
  enabled; same as default.
- **`schools`** is for B2B school accounts. Lives disabled — these
  users have unlimited attempts.

The partner key is determined by the user's `partner_type` (or similar
column — check `LiveService::getConfig` for the exact resolution).

### Kudos config consequence — DEAD CODE on every live path

> **Verified 2026-05-21**: the `kudos.*` block in `partners.php`
> points at unreachable code. `KudosService::calculateKudos` is
> referenced only by the unrouted legacy
> `app/Http/Controllers/AnswerController.php:165`. Every live grading
> path (track practice, Kiasu Path, Phase 1B, diagnostic) computes
> kudos as `(difficulty_id ?? 0) + 1` via
> `AnswerValidationService::checkAnswer` (`AnswerValidationService.php:64-66`)
> or re-implements the same formula inline
> (`AnswerGradingService.php:127-131`).
>
> Flipping `streak_bonus_enabled`, `time_bonus_enabled`, or changing
> the multipliers has **no runtime effect** today. The formulas below
> are documented for reference and for if/when the legacy controller
> gets re-routed.

The (unused) `KudosService::calculateKudos` formula:

```text
kudos = correct_base + (difficulty_id * correct_difficulty_multiplier)
if streak_bonus_enabled and streak > 1:
    kudos += kudos * (streak - 1) * streak_bonus_multiplier
if time_bonus_enabled and time < time_bonus_threshold:
    kudos += kudos * time_bonus_multiplier
```

The **live** kudos formula is `(difficulty_id ?? 0) + 1` on correct,
`1` on wrong — flat across partners. See [[ANSWER_GRADING.md#kudos]].

---

## 8. `.env`

Standard Laravel env file. The keys that matter:

| Group | Keys |
|---|---|
| DB | `DB_CONNECTION`, `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` |
| App | `APP_KEY`, `APP_ENV`, `APP_DEBUG`, `APP_URL` |
| Mail | `MAIL_MAILER`, `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_ENCRYPTION`, `MAIL_FROM_ADDRESS` |
| Stripe | `STRIPE_KEY`, `STRIPE_SECRET`, `STRIPE_WEBHOOK_SECRET` |
| Sentry | `SENTRY_LARAVEL_DSN`, `SENTRY_TRACES_SAMPLE_RATE` |

**Mail config is `.env`-only.** The `configs` table has columns for
mail but they must stay NULL — see CLAUDE.md and [[STREAKS.md]] etc.
A non-NULL mail row silently overrides `.env` at runtime.

`.env` is gitignored. Per CLAUDE.md, never `echo` or `cat` secret
values; never commit any `*.env*` file.

---

## 9. Caches

| Cache key | Owner | TTL | Cleared by |
|---|---|---|---|
| `app.config` | `Config::current()` | 1 hour | `Config::saved` event, `Config::updateSettings` |
| `site_config` | `Config::booted` | (older key, same purpose) | Saved/deleted events |
| `questions_per_test` | `Config::getQuestionsPerTest()` | 1 hour | manually |
| `$cached` (`MaxileService::$maxDifficultyCache`, `$lookbackWindowCache`) | static | Per-request | New request |
| `$cached` (`Config::$cached`) | static | Per-request | New request |

If you change a `configs` value via direct SQL (not the admin UI), the
caches won't notice for up to an hour. Use the UI, or call
`Cache::forget('app.config')` and `Cache::forget('questions_per_test')`
manually.

---

## Where each piece of config is read from

A quick reverse-index for navigating "I changed X, what's affected?":

| Setting | Read in | Affects |
|---|---|---|
| `configs.no_rights_to_pass` | `Config::passThreshold()` | Skill upgrade threshold ([[MASTERY.md]], [[STREAKS.md]]) |
| `configs.no_wrongs_to_fail` | `Config::failThreshold()` | Skill downgrade threshold ([[MASTERY.md]], [[STREAKS.md]]) |
| `configs.maxile_lookback_window` | `MaxileService::getLookbackWindow()` | System B skill maxile lookback ([[MAXILE.md#system-b]]) |
| `configs.questions_per_test` | `Config::getQuestionsPerTest()`, `KiasuPathService` | Track practice batch size, Kiasu Path test cap |
| `configs.kiasu_path_*` | `KiasuPathService::firstOrCreateKiasuPath` | Kiasu Path behavior ([[QUESTION_ASSIGNMENT.md#2-kiasu-path]]) |
| `difficulties.difficulty` max | `Difficulty::tierCount()` | Mastery flag, skill maxile interpolation ([[MAXILE.md]], [[MASTERY.md]]) |
| `levels.status_id` | All selection paths, `AdaptiveLevelService` | Which levels are visible to learners |
| `statuses.id = 3` filter | Everywhere | Public-content gate |
| `partners.php` `kudos.*` | `KudosService::calculateKudos` | **DEAD on every live path** — see §7 |
| `partners.php` `lives.*` | `LiveService::getConfig` | Per-partner lives behavior |

---

## Gotchas

1. **The `configs` row's runtime values differ from documented
   defaults.** Defaults in `Config::current()` and `Config::passThreshold()`
   are only used when the row is missing or the column is NULL — both
   rare in practice. Always read the live row to know current behavior.

2. **`questions_per_test` is read three ways with different defaults.**
   Laravel config `Config::get('questions_per_test', 20)` (track
   practice), DB column `configs.questions_per_test` (Kiasu Path,
   default 10), and `Config::getQuestionsPerTest()` (DB column with
   fallback 20). Pick the right one for your context.

3. **`Difficulty::tierCount()` change breaks every user.** Adding or
   removing public difficulty tiers invalidates `skill_passed` for
   every user. Coordinate a backfill.

4. **Secondary/pre-U levels exist but aren't public.** They're in the
   `levels` table with `status_id = 4`. Don't accidentally serve them
   by removing a `status_id = 3` filter — multiple inline comments
   warn about diagnostic overshoot if you do.

5. **`partners.php` is file-based, not DB-driven.** Changing partner
   config requires a code change and deploy. No admin UI.

6. **Cache invalidation lag.** Changing `configs` via direct SQL won't
   take effect until cache TTL expires (1 hour). Use the admin UI or
   explicit `Cache::forget`.

7. **Mail config in DB silently overrides `.env`.** Per CLAUDE.md:
   `configs.mail_*` columns must stay NULL on every environment. A
   stale or leaked mail row caused multi-hour debugging in May 2026.

8. **No partner config validation.** A typo in `partners.php` (e.g.
   `kudos.strek_bonus_enabled`) silently uses defaults — no error.
   Test partner-specific behavior after changes.

See [[STREAKS.md]] for streak counter semantics, [[MAXILE.md]] for
maxile formulas, [[MASTERY.md]] for upgrade/downgrade rules,
[[QUESTION_ASSIGNMENT.md]] for selection, [[ANSWER_GRADING.md]] for
grading flow.
