From 71af8b05e1731f3ce336720787a72fe18e7752c0 Mon Sep 17 00:00:00 2001 From: abhisek Date: Sat, 17 Oct 2026 14:39:33 +0530 Subject: [PATCH] docs: Update specs --- .claude/skills/glossary/SKILL.md | 71 ++ .tool-versions | 2 +- CLAUDE.md | 15 +- Dockerfile | 2 +- README.md | 2 +- ent/creditentry.go | 2 +- ent/llmrequestevent.go | 2 +- ent/schema/credit_entry.go | 2 +- ent/schema/llm_request_event.go | 2 +- go.mod | 2 +- internal/skillgraph/seed.go | 2 +- internal/store/ownerguard.go | 5 +- specs/02-persistence.md | 24 +- specs/03-skill-graph.md | 4 + specs/04-llm-integration.md | 22 +- specs/11-rewards.md | 4 +- specs/12-saas.md | 15 +- specs/14-monetisation.md | 3 +- specs/17-auth-providers.md | 293 +++++++++ specs/18-self-hosted-postgres.md | 181 +++++ specs/19-question-latency.md | 283 ++++++++ specs/glossary.md | 1059 ++++++++++++++++++++++++++++++ 22 files changed, 1959 insertions(+), 38 deletions(-) create mode 100644 .claude/skills/glossary/SKILL.md create mode 100644 specs/17-auth-providers.md create mode 100644 specs/18-self-hosted-postgres.md create mode 100644 specs/19-question-latency.md create mode 100644 specs/glossary.md diff --git a/.claude/skills/glossary/SKILL.md b/.claude/skills/glossary/SKILL.md new file mode 100644 index 0000000..dabd1e3 --- /dev/null +++ b/.claude/skills/glossary/SKILL.md @@ -0,0 +1,71 @@ +--- +name: glossary +description: Look up or update specs/glossary.md, the registry of Mathiz's first-class concepts. Use before naming a new type, table, event, API resource or UI term, and whenever a PR adds, renames, splits or removes a concept. +--- + +# Updating the glossary + +`specs/glossary.md` exists so that each concept has **one name and one +implementation**. Look things up there before writing code; edit it when a +concept is added, renamed, split or retired. + +## Is it a first-class concept? + +Add an entry only if at least one is true: +- It has its own ent schema, event type, or public Go type that other + packages use. +- It appears in an API path or response, a spec, or `docs/personas.md`. +- Parents or kids see it as a named thing in the UI. + +Not glossary material: private helpers, generic programming terms, env +vars (CLAUDE.md), one-off config knobs. + +## Do + +- **Search first**, including synonyms and the "Homonyms & look-alikes" + section. `grep -in "" specs/glossary.md` plus a code grep for the + idea, not just the word. +- **Reuse the existing name** in new code, API fields and specs, even if + you'd have picked a different word. +- **Use the exact entry format** in the file: `### Term`, a 1–3 sentence + definition (what it is plus its key invariant), `**Code:**` (1–3 + authoritative paths), `**Spec:**` link, and an optional `**Aka / don't call + it:**` line. +- **Put it in the right domain section, alphabetically.** +- **Verify every `Code:` path exists** (`grep`/`ls`) before committing. Fix + stale paths when you move code. +- **Update the glossary in the same PR** as the code or spec that changes the + concept. +- **Add a Homonyms line** when your term means something else elsewhere + (e.g. billing *plan* vs session *plan*). +- **Map kid/parent-facing copy** to its internal concept on the `Aka` line + (e.g. "clue" → Question) instead of renaming internals to match the UI. +- **Mark draft-spec concepts** `(proposed, specs/NN)` and drop the marker + when they ship. + +## Don't + +- **Don't add a near-duplicate.** If your idea is a variant of an existing + concept, extend that entry (and the code) or explain the difference in + both entries. Never create two entries for one idea. +- **Don't write essays.** Design rationale belongs in the spec; the glossary + links to it. +- **Don't invent identifiers.** Every name in `Code:` must exist in the + repo. +- **Don't delete an entry silently** when a concept is retired. Remove it + in the PR that removes the code, and note the replacement on any entry + that referenced it. +- **Don't rename a concept in the glossary alone.** A rename touches code, + specs, API and docs together, or not at all. +- **Don't record volatile details** (counts, defaults, timeouts) unless + they're an invariant. They go stale; point to the code instead. + +## Checklist for a PR that adds a concept + +1. The grep found no existing concept that already covers it. +2. An entry is added in the right section, alphabetically, in the house + format. +3. The Homonyms section is updated if the word is overloaded. +4. The `Code:` paths and `Spec:` link resolve. +5. The spec and `docs/personas.md` (for a user-facing concept) use the same + name. diff --git a/.tool-versions b/.tool-versions index f645bcd..c45bfe0 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -golang 1.25.6 +golang 1.27.1 diff --git a/CLAUDE.md b/CLAUDE.md index 8eb263f..17b713c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,8 @@ needed), `frontend` (web/ patterns — read it BEFORE touching web/), tracking/telemetry change), `add-event-type`, `add-math-skill`, `add-billing-provider`, `verify`, `db-backup` (pg_dump backups via `scripts/db-backup.sh`), -`user-feedback` (intake protocol when the owner pastes user reports). +`user-feedback` (intake protocol when the owner pastes user reports), +`glossary` (dos and don'ts for editing `specs/glossary.md`). If a PR changes a frontend pattern, update `.claude/skills/frontend/SKILL.md` in the same PR (same rule as `docs/personas.md` for user-facing flows). @@ -83,6 +84,18 @@ significant feature. `docs/personas.md` is the source of truth for user-facing flows — **update it when you add or change one**. `docs/development.md` is the human dev-setup guide. +### Glossary — check before you name anything + +`specs/glossary.md` lists every first-class concept (skill, tier, +expedition, family space, credit grant…) with its code location and spec. +**Before introducing a new type, table, event, API resource, or UI +term, look it up there.** If the concept already exists, reuse its name +and code, even when another word feels more natural. A second name for the +same idea is a bug. If it is genuinely new, add an entry in the same PR, +following the `glossary` skill. Kid-facing copy ("clue", "dig spot") is +mapped to internal names there too, so don't rename internals to match UI +copy. + ## Invariants (do not break) **Tenant isolation** diff --git a/Dockerfile b/Dockerfile index a56607b..b0937ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ COPY web/ ./ RUN mkdir -p /src/internal/saas/webui && npm run build # --- Stage 2: build the Go binary with the SPA embedded --- -FROM golang:1.25-alpine AS build +FROM golang:1.27-alpine AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download diff --git a/README.md b/README.md index b60d4b2..d3af58f 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ But the system is designed to support multiple standards and curriculum. ## Build from source -Requires Go 1.25+ (and Node 20+ for the hosted-mode web app). +Requires Go 1.27+ (and Node 20+ for the hosted-mode web app). ```sh git clone https://github.com/abhisek/mathiz.git diff --git a/ent/creditentry.go b/ent/creditentry.go index 318c11b..fb85fd2 100644 --- a/ent/creditentry.go +++ b/ent/creditentry.go @@ -27,7 +27,7 @@ type CreditEntry struct { Amount int `json:"amount,omitempty"` // Unconsumed credits on grant entries; 0 for debits Remaining int `json:"remaining,omitempty"` - // Idempotency key, e.g. starter:, sub:, expedition: + // Idempotency key, e.g. starter:, sub:, topup:, session: Source string `json:"source,omitempty"` // Grant expiry; nil = never expires ExpiresAt *time.Time `json:"expires_at,omitempty"` diff --git a/ent/llmrequestevent.go b/ent/llmrequestevent.go index 8c63c95..6609144 100644 --- a/ent/llmrequestevent.go +++ b/ent/llmrequestevent.go @@ -27,7 +27,7 @@ type LLMRequestEvent struct { Provider string `json:"provider,omitempty"` // Actual model ID used Model string `json:"model,omitempty"` - // Consumer-provided label: question-gen, hint, lesson, diagnosis + // Consumer-provided label (llm.Purpose*): question-gen, quest-gen, lesson, error-diagnosis, session-compress, profile Purpose string `json:"purpose,omitempty"` // Tokens in the request InputTokens int `json:"input_tokens,omitempty"` diff --git a/ent/schema/credit_entry.go b/ent/schema/credit_entry.go index 18f211d..00912e0 100644 --- a/ent/schema/credit_entry.go +++ b/ent/schema/credit_entry.go @@ -37,7 +37,7 @@ func (CreditEntry) Fields() []ent.Field { field.String("source"). Unique(). Immutable(). - Comment("Idempotency key, e.g. starter:, sub:, expedition:"), + Comment("Idempotency key, e.g. starter:, sub:, topup:, session:"), field.Time("expires_at"). Optional(). Nillable(). diff --git a/ent/schema/llm_request_event.go b/ent/schema/llm_request_event.go index b219e4e..1b1e2e0 100644 --- a/ent/schema/llm_request_event.go +++ b/ent/schema/llm_request_event.go @@ -22,7 +22,7 @@ func (LLMRequestEvent) Fields() []ent.Field { field.String("model"). Comment("Actual model ID used"), field.String("purpose"). - Comment("Consumer-provided label: question-gen, hint, lesson, diagnosis"), + Comment("Consumer-provided label (llm.Purpose*): question-gen, quest-gen, lesson, error-diagnosis, session-compress, profile"), field.Int("input_tokens"). Default(0). Comment("Tokens in the request"), diff --git a/go.mod b/go.mod index bd5d89d..aa8035c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/abhisek/mathiz -go 1.25.6 +go 1.27.1 require ( charm.land/bubbles/v2 v2.0.0-rc.1 diff --git a/internal/skillgraph/seed.go b/internal/skillgraph/seed.go index 68ab347..4534b0e 100644 --- a/internal/skillgraph/seed.go +++ b/internal/skillgraph/seed.go @@ -2,7 +2,7 @@ package skillgraph import "fmt" -// skills contains all 54 skill definitions for the MVP. +// skills contains all 56 skill definitions for the MVP. var skills = []Skill{ // ── Number & Place Value (8 nodes) ──────────────────────── diff --git a/internal/store/ownerguard.go b/internal/store/ownerguard.go index 7ef33f5..dd191e9 100644 --- a/internal/store/ownerguard.go +++ b/internal/store/ownerguard.go @@ -31,8 +31,9 @@ const ownerIDColumn = "owner_id" // ownerScopedTypes lists every ent type whose rows belong to a single // learner: all event schemas embedding EventMixin, plus Snapshot. -// Family-scoped control-plane types (Account, FamilySpace, ChildProfile, -// Invite, DeviceToken, CreditEntry, BillingState) are intentionally NOT +// Family-scoped control-plane types (Account, FamilySpace, FamilyMember, +// ChildProfile, Invite, ParentInvite, DeviceToken, CreditEntry, +// BillingState, Quest, QuestQuestion, QuestProgress) are intentionally NOT // here — they have no owner_id column and are scoped by the authz layer. var ownerScopedTypes = map[string]bool{ ent.TypeAnswerEvent: true, diff --git a/specs/02-persistence.md b/specs/02-persistence.md index c4ac055..2b6f6c4 100644 --- a/specs/02-persistence.md +++ b/specs/02-persistence.md @@ -99,17 +99,23 @@ The `sequence` field is a global counter across all event types, providing a tot Each event type is a **separate ent schema** with its own table. This gives each type its own typed fields and indexes while keeping the base fields consistent via the mixin. -Initial event types (fields beyond the base are illustrative — domain specs finalize them): +Event types as shipped (`ent/schema/*_event.go`; the schema files are +authoritative for fields). The names in the original design +(`AnswerAttemptEvent`, `HintRequestEvent`, `LessonViewEvent`, +`SkillStateChangeEvent`, `SessionStart/EndEvent`, `GemAwardEvent`) were +superseded by these: | Event Type | Key Fields | Defined By | |------------|-----------|------------| -| `AnswerAttemptEvent` | skill_id, question_hash, given_answer, correct, latency_ms | Session spec | -| `HintRequestEvent` | skill_id, question_hash, hint_level | AI Lessons spec | -| `LessonViewEvent` | skill_id, lesson_id | AI Lessons spec | -| `SkillStateChangeEvent` | skill_id, from_state, to_state | Mastery spec | -| `SessionStartEvent` | session_id, target_skill_id | Session spec | -| `SessionEndEvent` | session_id, questions_answered, accuracy | Session spec | -| `GemAwardEvent` | gem_type, rarity, reason_skill_id | Rewards spec | +| `AnswerEvent` | session_id, skill_id, tier, category, question_text, correct_answer, learner_answer, correct, time_ms, answer_format | Session spec | +| `SessionEvent` | session_id, action (start/end), questions_served, correct_answers, duration_secs, plan_summary, quest_uid, quest_name | Session spec | +| `MasteryEvent` | skill_id, from_state, to_state, trigger, fluency_score, session_id | Mastery spec | +| `DiagnosisEvent` | session_id, skill_id, category, misconception_id, confidence, classifier_name, reasoning | Diagnosis spec | +| `HintEvent` | session_id, skill_id, question_text, hint_text | AI Lessons spec | +| `LessonEvent` | session_id, skill_id, lesson_title, practice_* | AI Lessons spec | +| `LearnerProfileEvent` | summary, strengths, weaknesses, patterns, generated_at | AI Lessons spec | +| `GemEvent` | gem_type, rarity, skill_id, skill_name, session_id, reason | Rewards spec | +| `LLMRequestEvent` | provider, model, purpose, input/output_tokens, success, error_message, request/response body | LLM spec | ### Extensibility @@ -129,7 +135,7 @@ Every event table has: - Index on `sequence` (for ordered replay) - Index on `timestamp` (for time-range queries) -Type-specific indexes (e.g., `skill_id` on `AnswerAttemptEvent`) are defined by the owning spec. +Type-specific indexes (e.g., `skill_id` on `AnswerEvent`) are defined by the owning spec. ## 5. Snapshots diff --git a/specs/03-skill-graph.md b/specs/03-skill-graph.md index f5c8985..9667789 100644 --- a/specs/03-skill-graph.md +++ b/specs/03-skill-graph.md @@ -152,6 +152,10 @@ func Validate() error ## Diagnostic Placement +> **Not implemented.** Only the `skillgraph.DiagnosticResult` stub exists +> (`internal/skillgraph/diagnostic.go`). New learners start from the root +> skill. The design below is kept for reference. + On first launch (no learner data exists), Mathiz runs a **top-down probing** diagnostic quiz to skip the learner past already-known skills. ### Algorithm diff --git a/specs/04-llm-integration.md b/specs/04-llm-integration.md index df75431..42149c0 100644 --- a/specs/04-llm-integration.md +++ b/specs/04-llm-integration.md @@ -520,7 +520,7 @@ type LLMRequestEvent struct { Provider string // "anthropic", "openai", "gemini" Model string // Actual model ID used - Purpose string // Consumer-provided label: "question-gen", "hint", "lesson", "diagnosis" + Purpose string // Consumer-provided label, e.g. "question-gen", "lesson", "error-diagnosis" (see Purpose Labels) InputTokens int // Tokens in the request OutputTokens int // Tokens in the response LatencyMs int64 // Wall-clock time for the request @@ -556,14 +556,18 @@ func PurposeFrom(ctx context.Context) string { Standard purpose labels: -| Label | Consumer | -|-------|----------| -| `"question-gen"` | Problem Generation | -| `"hint"` | AI Lessons | -| `"lesson"` | AI Lessons | -| `"diagnosis"` | Error Diagnosis | -| `"compression"` | Context Compression | -| `"diagnostic-placement"` | Diagnostic Placement | +| Label | Constant | Consumer | +|-------|----------|----------| +| `"question-gen"` | `llm.PurposeQuestionGen` | Problem Generation (hints are part of the question, so there is no separate hint purpose) | +| `"quest-gen"` | `llm.PurposeQuestGen` | Quest AI authoring (specs/15) | +| `"lesson"` | `llm.PurposeLesson` | AI Lessons | +| `"error-diagnosis"` | `llm.PurposeDiagnosis` | Error Diagnosis (LLM fallback) | +| `"session-compress"` | `llm.PurposeSessionCompress` | Context Compression | +| `"profile"` | `llm.PurposeProfile` | Learner profile generation | + +The constants live in `internal/llm/timeout.go`, which also sets a +per-attempt timeout for each purpose. Diagnostic placement was never built, +so it has no label. --- diff --git a/specs/11-rewards.md b/specs/11-rewards.md index b46a629..db82692 100644 --- a/specs/11-rewards.md +++ b/specs/11-rewards.md @@ -37,7 +37,7 @@ Five gem types, each tied to a distinct learning achievement: | **Mastery** | `mastery` | Skill transitions Learning → Mastered (trigger: `prove-complete`) | Awarded when a skill is mastered for the first time | | **Recovery** | `recovery` | Skill transitions Rusty → Mastered (trigger: `recovery-complete`) | Awarded when a rusty skill is recovered | | **Retention** | `retention` | Spaced-rep graduation (`ConsecutiveHits >= 6`) | Awarded when a skill graduates from the review schedule | -| **Streak** | `streak` | 5 consecutive correct answers within a session | Awarded each time a 5-answer streak is achieved | +| **Streak** | `streak` | 5 consecutive correct answers within a session | Awarded at streak lengths 5, 10, 15, 20, then every further 5 (25, 30, …) | | **Session** | `session` | Session timer expires naturally (no early quit) | Awarded for completing a full session | ```go @@ -247,7 +247,7 @@ Streak gems are awarded each time the learner achieves a consecutive correct-ans | 15 | Epic | | 20+ | Legendary | -Gems are awarded **at each threshold**, not retroactively. Reaching 10 earns two gems: a Common at 5 and a Rare at 10. +Gems are awarded **at each threshold**, not retroactively. Reaching 10 earns two gems: a Common at 5 and a Rare at 10. Past 20, a Legendary is awarded at every further multiple of 5 (`gems.NextStreakThreshold`). ```go // StreakThresholds defines the streak lengths that award gems. diff --git a/specs/12-saas.md b/specs/12-saas.md index b40b98e..4f86983 100644 --- a/specs/12-saas.md +++ b/specs/12-saas.md @@ -156,14 +156,19 @@ type Principal struct { } ``` -Policy (v1): -- A parent may manage (read/write) a family space iff they are its `owner_account_id`. -- A parent may manage children, invites, devices, and read stats of spaces they own. +Policy (current: the membership model; see "Co-parents" below): +- A parent may manage (read/write) a family space iff they are a member of it + (`family_member` role `owner` or `parent`). The v1 rule "iff + `owner_account_id`" was replaced when co-parents shipped. +- Members may manage children, invites, devices, quests, and read stats of + their space. Billing and parent management are owner-only + (`CanManageBilling`, `CanManageParents`). - A child may access exactly one thing: **their own** learning session and profile info. - Children can never read other profiles, stats, or control-plane objects. -Every check returns `authz.ErrDenied` which the API maps to 403 (404 for existence -probes on objects the caller can't see). +Every check returns `authz.ErrDenied`, which the API **always** maps to 404 +(`writeServiceError` in `internal/saas/server/middleware.go`). It never +returns 403, so a denial can't confirm that an object exists. ## 6. Terminal Bridge — `internal/saas/termbridge` diff --git a/specs/14-monetisation.md b/specs/14-monetisation.md index d972280..c2cf0d7 100644 --- a/specs/14-monetisation.md +++ b/specs/14-monetisation.md @@ -45,7 +45,8 @@ Event-sourced entries on the **FamilySpace** (`credit_entries`): - Debits (`debit`): negative `amount`, consumed FIFO from unexpired grants ordered by soonest expiry (use-it-or-lose-it credits burn first). - Every entry has a unique `source` (e.g. `starter:`, - `sub:`, `expedition:`) — **idempotency is + `sub:`, `topup:`, `session:` + for an expedition debit) — **idempotency is structural**: replayed webhooks and retried requests cannot double-grant or double-charge. diff --git a/specs/17-auth-providers.md b/specs/17-auth-providers.md new file mode 100644 index 0000000..2ec8f28 --- /dev/null +++ b/specs/17-auth-providers.md @@ -0,0 +1,293 @@ +# Pluggable Parent Auth — Supabase out of the core + +## 1. Why + +Parent auth is Supabase-shaped all the way down: `auth.SupabaseVerifier`, +`Account.supabase_user_id`, `MATHIZ_SUPABASE_*`, `/api/v1/config` returning +`supabaseUrl`/`supabaseAnonKey`, and supabase-js in the SPA. Production data +also lives on a Supabase Postgres cluster. We want: + +- **Core storage and logic with no Supabase in it.** Data lives on our own + PostgreSQL; nothing outside an auth adapter names a vendor. +- **Credentials stay with a hosted provider.** We never store passwords, + send OTP/magic-link email, or run sign-in security ourselves. The provider + is an identity pipe, the same way billing is a money pipe (specs/14). +- **Supabase is one adapter among several.** Clerk is the first new one; + Auth0 / WorkOS / Firebase should be config presets, not new code paths. + +Non-goals: building first-party auth (password storage, SMTP), OAuth/social +login, MFA, account deletion, and child auth. Kids keep join codes + device +tokens (specs/12 §4.2) untouched; **no child ever has a provider account** +(also required by Clerk's terms on under-16 data). + +## 2. Decisions + +- **One auth provider per deployment.** No account linking across + providers, no "same email = same parent" merging (that is an + account-takeover vector). Switching providers is an explicit, offline + remap (§6), never implicit. +- **One generic JWT verifier, presets per provider.** Every supported + provider issues asymmetric-signed JWTs with a JWKS. Vendor knowledge is + limited to a preset: issuer/JWKS URL shape, audience vs authorized-party + check, claim names. No vendor SDK in the Go core (no `clerk-sdk-go`). +- **Bearer tokens stay.** No cookies → no CSRF surface and CORS stays as is. + The SPA fetches a fresh token per request (§5). +- **Verified email is enforced by us**, not assumed from the provider. + Co-parent invite acceptance matches on `Account.Email` + (`family/members.go` AcceptInvite), so an unverified email is an invite + takeover. Today nothing checks `email_verified`; this spec fixes that. + +## 3. Backend + +### 3.1 Verifier + +```go +// internal/saas/auth +type Identity struct { + Provider string // preset name: "supabase" | "clerk" | "test" + Subject string // provider user id (JWT sub), opaque + Email string + EmailVerified bool + DisplayName string +} + +type Verifier interface { + Verify(ctx context.Context, token string) (*Identity, error) + Provider() string +} +``` + +`server.Deps.Verifier` takes the interface (today it is the concrete +`*auth.SupabaseVerifier`). `withParent` is otherwise unchanged: +verify → `EnsureAccount(identity)` → `authz.Principal{AccountID: acct.UID}`. +Every failure, including an unverified email or a wrong-provider token, +is a generic 401 (`ErrUnauthenticated`). + +`auth.JWTVerifier` is the single implementation, configured by a preset: + +| Field | Meaning | +|---|---| +| `Issuer` | exact `iss` match (required, except `test`) | +| `JWKSURL` | key set; fetched lazily, cached, refetched on unknown `kid` (existing keyfunc logic) | +| `HS256Secret` | symmetric key; only `test` and legacy Supabase | +| `Audience` | if set, `aud` must contain it | +| `AuthorizedParties` | if set, `azp` must be one of them | +| `Algs` | allow-list; never `none`, HS* only when a secret is set | +| `Claims` | `email`, `email_verified`, `display_name` claim paths | +| `Leeway` | 30s (unchanged) | + +At least one of `Audience` / `AuthorizedParties` must be set, so a token +minted for another app at the same issuer is rejected. + +### 3.2 Presets + +| | `supabase` | `clerk` | `test` | +|---|---|---|---| +| Issuer | `{URL}/auth/v1` | Clerk Frontend API URL | `mathiz-test` | +| Keys | JWKS `{URL}/auth/v1/.well-known/jwks.json`, + optional legacy HS256 secret | JWKS `{issuer}/.well-known/jwks.json` (RS256) | HS256 secret | +| Audience check | `aud = authenticated` | none (Clerk sets no `aud`) | `aud = authenticated` | +| Authorized parties | — | SPA origin(s), default `MATHIZ_PUBLIC_BASE_URL` | — | +| Email | `email` | custom session claim `email` | `email` | +| Email verified | `email_confirmed_at`/`user_metadata.email_verified` (see §8) | custom session claim `email_verified` (see §8) | `email_verified`, default true | +| Display name | `user_metadata.full_name\|name\|display_name` | custom session claim `name` | `user_metadata.full_name` | + +The Clerk instance must have a custom session-claims template that adds +`email`, `email_verified` and `name`. Without it every token fails +`email_verified` and the startup self-check (§3.4) logs it. Clerk session +tokens live 60s, so `Leeway` covers clock skew only. Long requests are fine +because verification happens at request start. + +`test` refuses to start unless `MATHIZ_AUTH_ALLOW_TEST=1`. It exists for +unit tests, docker-compose and the `saas-e2e` skill, and must never be +reachable in production by default. + +### 3.3 Data model + +`Account` gains provider-neutral identity columns and loses the Supabase +one. ent auto-migrate (`store.Open` → `Schema.Create`) adds columns but never +renames or drops them, so this is staged: + +1. **Add** `auth_provider` (string, Optional, Nillable) and + `auth_subject` (string, Optional, Nillable), with a unique index on + (`auth_provider`, `auth_subject`). They are nullable so the index can be + created before backfill: NULLs don't collide in either PostgreSQL or + SQLite. +2. **Relax** `supabase_user_id` to Optional + Nillable and stop writing it. +3. **Backfill** in `store.Open` after `Schema.Create`, idempotently: + `UPDATE accounts SET auth_provider='supabase', auth_subject=supabase_user_id + WHERE auth_subject IS NULL AND supabase_user_id IS NOT NULL`. This is + portable SQL with no placeholders. Accounts aren't owner-scoped, so the + owner guard doesn't apply. +4. A later release removes the `supabase_user_id` field from the schema and + drops the column with a one-line manual SQL step in the release notes. + +`EnsureAccount(ctx, Identity)` looks up by (`Provider`, `Subject`) and +otherwise keeps today's behaviour: create with a fresh UUID, refresh +email/name, and tolerate the unique-constraint race. It is never keyed on +email. Everything downstream already uses `Account.UID`, so nothing else +changes. + +### 3.4 Startup checks (fail fast) + +- The preset resolves and the config validates (replaces the current + `config.go` "Supabase URL or JWT secret" rule). +- **Provider pin:** if any account has `auth_provider` set to something + other than the configured preset, refuse to start: + `auth: N accounts belong to provider "supabase", configured "clerk" — run + "mathiz auth remap" (specs/17 §6)`. This stops a config typo from + silently orphaning every family. +- Best-effort JWKS fetch at boot, logged via slog on failure but not fatal + (the provider may be briefly unreachable, and verification retries + lazily). + +### 3.5 Config + +| Var | Notes | +|---|---| +| `MATHIZ_AUTH_PROVIDER` | `supabase` \| `clerk` \| `test` | +| `MATHIZ_AUTH_ISSUER` | Clerk Frontend API URL; Supabase derives it from the URL | +| `MATHIZ_AUTH_AUDIENCE`, `MATHIZ_AUTH_AUTHORIZED_PARTIES` | override preset defaults (comma-separated) | +| `MATHIZ_AUTH_PUBLIC_KEY` | public, sent to the SPA: Supabase anon key / Clerk publishable key | +| `MATHIZ_AUTH_HS256_SECRET` | `test`, or legacy Supabase | +| `MATHIZ_AUTH_ALLOW_TEST` | required for `test` | + +**Back-compat:** when `MATHIZ_AUTH_PROVIDER` is unset and `MATHIZ_SUPABASE_*` +are set, the server behaves as `supabase` and logs a deprecation warning. +The old names are removed one release after this ships. + +### 3.6 Boot config + +`GET /api/v1/config` returns: + +```json +{ "auth": { "provider": "clerk", "publicKey": "pk_live_…", "issuer": "https://clerk.example.com" }, + "posthogKey": "…", "posthogHost": "…" } +``` + +`supabaseUrl` / `supabaseAnonKey` are kept alongside it for one release, so +a cached old SPA keeps working during deploy, then removed. + +## 4. What does not change + +Authz (`internal/saas/authz`, Principal-only), 404-not-403 on cross-tenant +denial, tenant isolation of learning data, child join/device-token auth +(parent tokens are still rejected on child routes and vice versa), the +billing/credits model, canonical log lines (UIDs only, never emails), and +the PostHog relay cookie strip. + +## 5. Frontend + +`web/src/auth/` holds a small adapter. Each provider is one file, loaded +lazily by `auth.provider` so a Supabase deployment never downloads Clerk and +vice versa: + +```ts +interface AuthAdapter { + init(cfg: AuthBootConfig): Promise + getToken(): Promise // fresh per call; the adapter caches/refreshes + user(): { email: string } | null + onChange(cb: () => void): () => void // returns unsubscribe + signOut(): Promise + SignIn: React.ComponentType // rendered by /login +} +``` + +- **Supabase adapter:** today's `supa.ts` + `Login.tsx` moved behind the + interface, with identical behaviour: email OTP first, magic link to `/login`, + password fallback. `getToken()` returns `getSession()`'s access token. +- **Clerk adapter:** `@clerk/react` (Core 3), embedded `` themed + with `appearance` to match Mathiz. Email code + password configured in the + Clerk dashboard. `getToken()` = Clerk's `getToken()`. +- **`api.ts`:** parent calls take a token *getter* instead of a string. + `request()` awaits it per call. On a 401 it calls `getToken()` once more + and retries once; a second 401 calls `signOut()`, which routes to `/login`. + The token stops travelling through `DashboardContext`. +- **`App.tsx` / `Layout.tsx`:** use `onChange` / `user()` instead of the + supabase-js `Session`. The guards are unchanged. +- **Kid surfaces never load an auth adapter.** `/join`, `/play` and the + public pages stay provider-free. +- **Copy:** the processors named in the Legal privacy text follow the + configured provider. + +Update `.claude/skills/frontend/SKILL.md` with the adapter pattern in the +same PR. + +## 6. Switching a deployment's provider + +`mathiz auth remap --from supabase --to clerk --map map.csv [--dry-run]` + +- `map.csv` is `old_subject,new_subject`. For Clerk it comes from the + import result: each imported user has `external_id` = old Supabase id and + `id` = new Clerk id. +- It runs in one transaction. It rewrites `auth_provider`/`auth_subject` only + for accounts listed in the map. Any account not in the map makes the + remap fail (`--allow-unmapped` leaves those accounts unmapped; their + owners lose access until mapped). +- It is idempotent: re-running with the same map is a no-op. + +Runbook (Supabase → Clerk): +1. Export Supabase `auth.users` with Clerk's `migration-tool` + (`supabase` transformer). bcrypt hashes import as-is, so parents keep + their passwords and OTP users simply sign in by code. +2. Import into the Clerk production instance with `external_id` set. +3. Stop the server, run `remap`, set `MATHIZ_AUTH_PROVIDER=clerk`, and start. + Parents' Supabase sessions end, and they sign in once via Clerk. + +## 7. Data plane: off the Supabase cluster + +Moved to [18-self-hosted-postgres.md](18-self-hosted-postgres.md). It is +independent of this spec and can ship first. Supabase's own `auth` schema +stays with Supabase while it serves as the auth provider (§6). + +## 8. Open questions + +- **Clerk `email_verified` claim:** confirm the session-claim template can + express primary-email verification status. If it can't, the Clerk preset + falls back to a Backend API lookup by `sub` when an account is first + created or its email changes, cached on the account row. That would be the + only server→provider call, and its result must not be used for anything + other than verification. +- **Supabase verified signal:** current Supabase access tokens carry no + `email_verified` claim; check whether `user_metadata.email_verified` + is reliably present, or whether we rely on "Supabase issues a session only + after confirmation" (true for OTP; for password signup only when + "Confirm email" is on). If it isn't reliable, the preset treats sessions + as verified and the deployment runbook requires "Confirm email" on. +- **Clerk data residency:** US-only storage. Acceptable for current users? +- **Email change:** both providers let a parent change their email. We + refresh `Account.Email` from the token on each request, so pending invites + for the old email stop matching, which is the intended behaviour. + +## 9. Testing + +- `auth`: table-driven tests per preset with self-signed RS256 keys behind + an httptest JWKS: wrong `iss`/`aud`/`azp`, expired, unverified email, alg + `none`, HS256 without a secret, unknown `kid` refetch. Keep the existing + Supabase HS256/ES256 cases. +- `server`: `parentToken` helper mints via the `test` preset. Add tests for + the wrong-provider token → 401, and that a parent token is rejected on + child routes (existing). +- `store`: backfill is idempotent on SQLite and PostgreSQL + (`MATHIZ_TEST_DATABASE_URL`); two accounts with NULL subjects coexist. +- `family`: `EnsureAccount` is keyed by (provider, subject), never email. +- `cmd`: provider-pin refusal at startup; `remap` dry-run, unmapped-account + failure, idempotent rerun. +- E2E (`saas-e2e`): switch to the `test` preset; `mint-jwt.mjs` keeps + working. Add a `test` SPA adapter (enabled only with `ALLOW_TEST`) that + takes a pasted token, so the browser parent dashboard becomes + E2E-testable without Supabase or Clerk, which isn't possible today. + +## 10. Rollout + +1. Data move off the Supabase cluster (specs/18). +2. §3 + §5 with the Supabase preset only, shipped with no visible change. + Back-compat env and boot-config fields stay in place. +3. Clerk preset + adapter, tried on a staging deployment with an imported + copy of users. +4. Production cutover via §6 if we choose Clerk. +5. Remove the deprecated env names, boot fields and `supabase_user_id` + column. + +Docs to update alongside: specs/12-saas.md §4.1 (point here), +docs/personas.md (operator config), docs/saas.md, docs/development.md, +`.env.example`, docker-compose, CLAUDE.md env-var list and the `saas-e2e` skill. diff --git a/specs/18-self-hosted-postgres.md b/specs/18-self-hosted-postgres.md new file mode 100644 index 0000000..fe77d75 --- /dev/null +++ b/specs/18-self-hosted-postgres.md @@ -0,0 +1,181 @@ +# Self-hosted PostgreSQL — core storage off Supabase + +## 1. Why + +Production data (every learner event, snapshot, family, ledger entry and +quest) lives in the `mathiz` database on the Supabase Postgres cluster +(`.claude/skills/db-backup/SKILL.md`). We want all core storage on a +PostgreSQL we run and control, with Supabase reduced to, at most, an auth +provider (specs/17). After this spec, no Mathiz data lives on Supabase. + +Non-goals: changing the schema, the event-sourcing model, or the SQLite +local mode; moving Supabase's own `auth` schema (that belongs to the auth +provider and goes away with specs/17 §6, if ever). + +## 2. Current state (verified in code) + +- **Storage is already vendor-neutral.** `store.Open` (`internal/store/store.go`) + opens any `postgres://` DSN with pgx's `database/sql` driver and runs + ent auto-migrate (`Schema.Create`). There are no extensions, no + `jsonb`/`uuid` server functions, no RLS policies and no Supabase roles in + our schema. Timestamps and UIDs are generated in Go. +- **Raw SQL is limited and portable:** the `global_sequence` table + (`internal/store/event.go`) and owner-parameterized event queries. Both + already run on SQLite and stock PostgreSQL (CI uses `postgres` service + containers; docker-compose uses `postgres:16-alpine`). +- **Isolation from Supabase's API surface:** our tables live in a separate + `mathiz` database, so PostgREST can't see them. A plain server has no + PostgREST, so this concern disappears. +- **Gap: the PostgreSQL connection pool is unbounded.** Only SQLite sets + `SetMaxOpenConns`. Supabase's pooler absorbed this; a self-run server + with `max_connections = 100` will not. + +So the migration is an **ops move plus a small hardening change**, not a +storage rewrite. + +## 3. What Supabase gives us today that we must replace + +| Capability | Supabase today | Self-hosted replacement (required) | +|---|---|---| +| Backups | Managed daily backups | Nightly `scripts/db-backup.sh` to off-host storage, plus WAL-based point-in-time recovery (pgBackRest or WAL-G). Retention ≥ 14 days. | +| Restore confidence | Implicit | A **restore drill** before cutover and monthly: restore the latest backup into a scratch database, then run §5.3 verification against it. | +| TLS | Enforced | Server TLS cert; DSN uses `sslmode=verify-full` whenever the database isn't on the same host or private network. | +| Connection pooling | Supavisor/PgBouncer | App-side pool limits (§4). PgBouncer is optional and must use **session** mode: pgx's prepared statements break in transaction mode. | +| Monitoring | Dashboard | Disk, connections, replication lag (if any), slow queries (`log_min_duration_statement`), backup job success alerts. | +| Upgrades | Managed | Minor upgrades monthly; major upgrades through dump/restore or `pg_upgrade` following the same runbook as §5. | +| HA | Managed | Not required for v1: a single primary plus PITR. Revisit if the recovery-time target demands a standby. | + +Hosting location (VM, Kubernetes, or a managed-but-not-Supabase service) +is an operator choice. Everything in this spec assumes only "a PostgreSQL +≥ the source major version that we hold superuser on". + +## 4. Code changes + +Small, and shipped before cutover: + +1. **Pool limits for PostgreSQL** in `store.open`: `SetMaxOpenConns`, + `SetMaxIdleConns`, `SetConnMaxLifetime`, `SetConnMaxIdleTime`. Defaults + are 20 / 10 / 30m / 5m, overridable with `MATHIZ_DB_MAX_OPEN_CONNS` and + `MATHIZ_DB_MAX_IDLE_CONNS`. SQLite keeps `SetMaxOpenConns(1)` unchanged + (see CLAUDE.md). +2. **Startup check:** `store.Open` logs the server version and the active + `sslmode` at INFO (no credentials), and fails fast if PostgreSQL is + older than the minimum supported major (§7). +3. **Docs and config:** remove "Prod: Supabase's connection string" from + `.env.example`. Update the db-backup skill's "production database is + on the Supabase cluster" section, `docs/saas.md`, + `docs/development.md`, `docs/personas.md` (operator row) and the + README's hosted-mode section. + +No schema change and no new ent types. Owner-scoped tables, the owner +guard and the `global_sequence` semantics stay exactly as they are. + +## 5. Migration runbook + +### 5.1 Prepare (no downtime) + +1. Provision the target PostgreSQL (§3), with the same or a newer major + version than Supabase's (`SELECT version();` on the source). Create a role + `mathiz` (no superuser) and a database `mathiz` owned by it. +2. Use `pg_dump` / `pg_restore` binaries at least as new as the **newer** of + source and target. +3. Connect to the source **directly** (port 5432 host), not through the + transaction-mode pooler (port 6543). `pg_dump` needs a session. +4. Rehearse: dump production, restore into a scratch target database, run + §5.3, and time the whole thing. The measured time sets the maintenance + window. + +### 5.2 Cutover (short maintenance window) + +Expected to be minutes at current data size; confirm with the rehearsal. + +1. Stop `mathiz serve` (or scale to zero). No writes may happen after the + dump starts. Live expeditions end; their in-memory progress since the + last snapshot save is lost, the same as a deploy restart. +2. `scripts/db-backup.sh --env-file .env ` against Supabase (custom + format). Keep this file as the rollback artifact. +3. `pg_restore --no-owner --no-privileges --role=mathiz -d "$TARGET_DSN" .dump` +4. Run §5.3 verification. **Any failure aborts the cutover** (start the server + again on the old DSN). +5. Set `MATHIZ_DATABASE_URL` to the target DSN and start `mathiz serve`. + Auto-migrate runs as a no-op. +6. Smoke test: a parent loads the dashboard, a child joins and finishes + an expedition, and the credit debit and the events appear in the target. + +### 5.3 Verification + +- Exact row count for every table, source vs target (script it: list + tables from `information_schema.tables` in schema `public`). +- `global_sequence.next_val` > `MAX(sequence)` over every event table and + snapshots on the target. Otherwise new events would reuse sequence numbers + and break cross-table ordering. +- `credit_entries`: the sum of `remaining` per family space is equal on both + sides, so balances are unchanged. +- Uniqueness intact: `pg_restore` reported no constraint or index + errors. Any error output counts as a failure. +- Start the server against the target with the startup check (§4.2) green. + +### 5.4 Rollback + +- Before any new writes on the target: repoint `MATHIZ_DATABASE_URL` to + Supabase and restart. Nothing is lost. +- After writes on the target: rolling back loses those writes unless they + are copied back. Prefer fixing forward; the Supabase database is kept + as-is for 14 days and then deleted (§6). + +### 5.5 If the window is too long + +If the rehearsal shows the dump/restore takes too long, use logical +replication instead: a publication on the Supabase `mathiz` database and a +subscription on the target. Sync, stop writes, wait for lag to reach 0, +rerun §5.3 (logical replication doesn't copy sequence state, so +`global_sequence` must be checked), and switch over. This only +becomes worth the extra complexity if downtime exceeds a few minutes. + +## 6. Decommission + +1. 14 days after a clean cutover: take one final archival dump of the + Supabase `mathiz` database to off-host storage, then **drop the + `mathiz` database on Supabase**. No core data then remains there. +2. Remove Supabase database credentials from every secret store and + `.env`. +3. If the deployment still uses the `supabase` auth preset (specs/17), the + Supabase project stays alive **for auth only**. Keep the project from + being paused for inactivity on the free tier (a paused project means no + parent can sign in). Clerk's Supabase migration tool reads `auth.users` + from Supabase Postgres, so keep that access until specs/17 §6 is done or + ruled out. + +## 7. Open questions + +- **Hosting target** (VM + systemd, Kubernetes operator such as + CloudNativePG, or a managed PostgreSQL elsewhere). This decides how §3 + backups and PITR are implemented, but not the rest of this spec. +- **Supabase's current major version**, which sets the target minimum and + the `store.Open` minimum check. docker-compose and CI use 16, so the + floor is 16 unless production is newer. +- **Recovery targets:** acceptable data loss (RPO) and downtime (RTO). PITR + gives an RPO of minutes; a single primary gives an RTO of + "restore time". +- **Data residency** of the new host. This is a privacy-policy statement + for parents; update the processor list in `web/src/pages/Legal.tsx`. + +## 8. Testing + +- `store`: a unit test that the PostgreSQL pool options are applied, and + that SQLite still forces one connection. +- Existing owner-isolation suite against the target's PostgreSQL version + (`MATHIZ_TEST_DATABASE_URL`, scratch database only, never production). +- Set CI's PostgreSQL service image to the target major version. +- The rehearsal (§5.1.4) is the migration test. Its verification output is + attached to the cutover PR or ticket. + +## 9. Rollout + +1. §4 code changes (pool limits, startup check, docs). +2. Provision the target with backups, PITR and monitoring; run a restore drill. +3. Rehearse §5 against a scratch database. +4. Cutover (§5.2), then decommission (§6) after 14 days. + +This spec is independent of specs/17. It can ship before, after, or +alongside the auth work. diff --git a/specs/19-question-latency.md b/specs/19-question-latency.md new file mode 100644 index 0000000..802f77a --- /dev/null +++ b/specs/19-question-latency.md @@ -0,0 +1,283 @@ +# Question Latency — no kid waits on the LLM + +## 1. Why + +Every question is one blocking LLM call that starts only when the kid asks +for it: + +``` +answer graded → feedback screen → kid taps "Next clue →" → LLM call → 🧭 spins → question + (nothing running) (seconds; up to 30s) +``` + +- `POST /api/v1/game/expeditions/{id}/question` calls + `Generator.Generate` synchronously inside `game.Manager.Question` + (`internal/saas/game/manager.go`), with a 30s budget + (`questionGenBudget`) and up to 3 provider attempts of 20s each + (`internal/llm/timeout.go`, `retry.go`). Nothing is prefetched; the TUI + behaves the same way (`internal/screens/session/session.go` + `generateNextQuestion`). +- The time the kid spends reading feedback is idle. By the time + `Answer` returns, every input to the next question is already known. +- Failure handling makes it worse: + - A question rejected by a validator is never retried, although + specs/05 §"retry" says callers retry up to 3 times. + - The SPA drops the expedition on the first `503` + (`web/src/pages/Play.tsx` `nextQuestion`). Tapping the spot again + finishes the old expedition and charges a **new credit** + (`Start` reuses only when `questionsAsked == 0`). + - `exp.mu` is held across the LLM call, so `End` ("Sail home"), + `Hint`, `Answer` and a replacing `Start` block for up to 30s. +- There is no measurement of how long kids actually wait. + +This spec supersedes specs/05 §1's "on-demand, no pre-caching" decision. +**Every served question stays personalised.** Nothing here introduces a +static bank on the normal path. + +## 2. Goals and non-goals + +Goals, measured with §4 telemetry: +- p50 wait after "Next clue →" ≤ 300 ms; p95 ≤ 3 s. +- Fewer than 1 in 200 question requests surfaces an error to a kid. +- A generation failure never costs a credit and never drops a kid back to + the map mid-expedition unless the circuit breaker trips. + +Non-goals: +- Changing the prompt, the validators or the question content. +- Pre-generated shared pools (they defeat personalisation). +- Streaming question text: the validators need the whole question before + anything is shown. + +## 3. Phase 1a — failure fixes + +These ship first because they're bugs whatever else we do. + +### 3.1 Retry rejected questions + +`problemgen.LLMGenerator.Generate` re-asks up to **2 more times** when a +validator returns a `ValidationError` with `Retryable: true`, within the +caller's context deadline. The rejected question's text goes in the +"already asked" list for the retry, so it isn't regenerated. Each attempt +is a normal LLM call and is logged as one. The game and TUI callers +already treat any error as a failure; they need no change. + +### 3.2 Don't hold `exp.mu` across generation + +`Manager.Question` becomes lock → read → unlock → generate → lock → +install: + +1. Under `exp.mu`: the existing checks (finished, idempotent current + question, `questionsAsked < total`); build the `GenerateInput`; note + `asked := exp.questionsAsked`. +2. Release `exp.mu` and generate through the prefetcher (§5), which merges + concurrent identical requests into one call. +3. Re-take `exp.mu`. If `exp.finished`, return `ErrExpeditionOver`. If + `exp.questionsAsked != asked`, a concurrent request (a double tap) + already installed this question, so return the current one. Otherwise + install the question as today. Both taps share one generator call + through the prefetcher's de-duplication. + +`Hint`, `Answer`, `End` and a replacing `Start` no longer wait on the +LLM. `Answer` during generation returns `ErrNoQuestion`, as it does today +when no question exists. The idle reaper keeps using `lastActivity` and +never takes `exp.mu`. + +### 3.3 Keep the expedition alive on failure (client) + +On `ErrGeneration` (503) the SPA keeps the expedition and shows a retry +state on the same screen: "The map is foggy… 🌫️" with a **Try again** +button that calls `/question` again. Kid copy only: no error codes, and +nothing about credits. The server's existing +`maxGenFailures = 3` circuit breaker still ends the expedition; the +client then gets `ErrExpeditionOver` and returns to the map with the +normal summary. No new `Start`, so no second debit. + +Also: after 6 s of loading, the loader copy changes to "Still digging… +the treasure is deep today ⛏️", so a long wait reads as progress, not a +hang. + +### 3.4 TUI + +- Cache the learner profile on the session screen at start instead of + reading `snapRepo.Latest` on every question. +- Prefetch as in §5 (same component). + +## 4. Phase 1b — telemetry (baseline before and after) + +- **Canonical log line** on `/question` (request path, so `logctx` fields + in `internal/saas/server`, per CLAUDE.md): + - `q_wait_ms`: handler time spent waiting for the question. + - `q_source`: one of `prefetch_hit` (ready on arrival), `prefetch_join` + (in flight, awaited), `fresh` (no prefetch), `quest`. + - `q_attempts`: LLM attempts, retries included. +- **Background slog** for prefetch outcomes (non-request path): + `slog.Info("game: prefetch", "child_uid", uid, "outcome", + "used|discarded|failed|cancelled", "reason", …, "ms", …)`. + Discard reasons: `input_changed`, `expedition_over`. +- **PostHog** (follow the `analytics` skill: anonymous kid usage inside the + family group only, no child identity): add `track.questionShown({index, + waitMs})` and `track.questionGenerationFailed({index})` in + `web/src/analytics.ts`. `waitMs` is measured client-side from tap to + render, so it includes network time. + +Ship 1b alongside or before 1a, and collect at least a few days of +baseline before Phase 2 lands. + +## 5. Phase 2 — prefetch (A + B) + +### 5.1 `problemgen.Prefetcher` + +A per-session component that wraps any `Generator`: + +```go +// internal/problemgen/prefetch.go +type Prefetcher struct { /* gen Generator; inflight/ready keyed by fingerprint */ } + +func NewPrefetcher(gen Generator, budget time.Duration) *Prefetcher +// Warm starts generating input in the background (detached context, budget +// deadline) unless an identical request is already ready or in flight. +func (p *Prefetcher) Warm(input GenerateInput) +// Get returns the warmed question for an identical input — waiting for an +// in-flight one — and otherwise generates fresh. Any warmed result for a +// different input is discarded. +func (p *Prefetcher) Get(ctx context.Context, input GenerateInput) (*Question, Source, error) +// Close cancels in-flight work and drops results. Idempotent. +func (p *Prefetcher) Close() +``` + +- **Correctness rests on the fingerprint:** a hash of every + `GenerateInput` field (skill ID, tier, prior questions, recent errors, + learner profile). If anything the next question depends on changed + between `Warm` and `Get`, the warmed question is simply not used. No + caller needs to reason about staleness. +- **At most one warmed question.** A new `Warm` with a different + fingerprint cancels the old one. +- A failed warm is dropped silently and `Get` generates fresh. It does not + count toward `genFailures`; only kid-visible failures do. +- Background work uses `context.WithoutCancel` of the session context plus + the budget, so it outlives the `/answer` request that started it, and + `Close` cancels it. +- LLM usage logging is unchanged: the provider's `LoggingProvider` records + each attempt into the child's event stream as today. The owner is bound + by the repo, not the request context. + +### 5.2 Game wiring + +- `expedition` gains `prefetch *problemgen.Prefetcher`, built in `Start` + around `exp.tools.Generator`. +- **B (Q1 during "Setting sail…"):** at the end of `Start`, call + `Warm(firstInput)`. The client's following `/question` gets it as a hit or + a join. +- **A (after grading):** at the end of `Answer`, if `questionsAsked < + total` and the expedition isn't finished, build the next + `GenerateInput` from the post-`HandleAnswer` state (live tier, updated + prior questions and recent errors) and `Warm` it. The lesson flow is + unaffected; if a lesson changes inputs, the fingerprint discards the + warmed question. +- `Question` uses `prefetch.Get` (§3.2 step 2). +- `finishLocked` calls `prefetch.Close()` **before** `Diagnosis.Close()` + and before releasing the play slot, so no background generation outlives + the expedition's resources. +- **Quests are excluded.** `questGenerator` advances a cursor on every + `Generate`, so a discarded warm would skip a parent's question. Quest + expeditions use the generator directly, and their questions are + instant anyway. +- Async error compression can rewrite `RecentErrors` after `Warm`. That + causes a fingerprint miss (logged `input_changed`), which is correct + and expected to be rare. + +### 5.3 TUI wiring + +The session screen owns one `Prefetcher` per session (closed on quit or +session end): `Warm` when feedback is shown, `Get` in +`generateNextQuestion`. Both surfaces get the component through the +toolset. `tutor.Toolset` gains nothing; the prefetcher is per session, +not per provider. + +### 5.4 Cost + +Phase 2 adds no LLM calls in the common case. The calls it wastes: +- one per expedition ended early with a warm in flight, +- one per fingerprint miss. + +Both show up in the prefetch log. Credits are per expedition, so kids and +parents are unaffected. + +## 6. Phase 3 — decided by measurement + +Each phase ships only if Phase 2 telemetry shows the goal in §2 isn't met. + +- **C. Speculative warm.** When a question is installed, also `Warm` the + input that a *correct* answer would produce (prior questions + this text, + same tier, same errors). On a correct answer with no tier change it's a + hit with near-zero wait; otherwise the fingerprint discards it and A + starts. Costs one wasted call per wrong answer. Gate it behind + `MATHIZ_QUESTION_SPECULATE=1` and compare cost against the wait gained. +- **D. Two-stage generation.** Stage 1 returns question, format, choices, + answer and answer type: the parts needed to show and grade the question. + Stage 2 (hint and explanation) starts as soon as stage 1 is validated + and must finish before grading returns (`AnswerResult.Explanation`). If + it isn't ready, the result goes out without an explanation rather than + waiting. This needs a schema split and its own validators; spec it + separately if chosen. +- **E. Procedural fallback.** A `problemgen.ProceduralGenerator` for + pure-computation skills, using per-skill numeric ranges and templates + and verified by the existing exact-math code (`mathcheck.go`). It is + wrapped as `FallbackGenerator{Primary, Fallback}`, used **only** when + the primary fails or exceeds a shorter deadline (for example 10 s), so a + provider outage becomes a plain question instead of fog. Needs new + per-skill metadata in the skill seed and an `AnswerEvent` source marker + so the dashboard and analytics can tell fallback questions apart. Spec it + separately if chosen. +- **H. LLM layer.** Per-purpose model routing (a faster model for + `question-gen`), a fallback provider, and one provider per process + instead of one per expedition. Small, independent PRs. + +Rejected: a batch of all 5 questions per expedition (same latency as A+B +but loses mid-expedition adaptation) and shared question pools (not +personal). + +## 7. Invariants to preserve + +- **One live session per child:** the play slot is released only after the + final snapshot save; `Close` runs before it (§5.2). +- **Map reads stay side-effect-free:** prefetch never runs from `Map`. +- **Enforcement only at `Start`:** prefetching and retries never debit + credits. +- **`CheckAnswer` stays the only answer comparator.** Prefetched and + retried questions go through the same validators. +- **Kid surfaces show no meter, prices or error codes;** retry copy is + in-world. +- **No child identity in analytics** (§4). + +## 8. Testing + +- `problemgen`: + - prefetcher hit, join, fingerprint miss, failed warm falls back to + fresh, `Close` cancels in-flight work (fake generator with a + controllable delay); + - validator retry, including that the rejected text reaches the "already + asked" list. +- `game` (`-race`): + - `End` returns while generation is blocked; + - a double-tap `/question` makes one generator call; + - `Answer` warms the next question, and Q1 is warmed by `Start`; + - quest expeditions never warm; + - `finish` cancels the warm before releasing the slot; + - three failures trip the breaker without a second debit. +- `web`: retry state on 503 keeps the expedition; the "still digging" copy + appears after 6 s. Covered in the `saas-e2e` flow with the LLM stub + delayed (`llmstub.mjs` gains a configurable delay and failure mode). +- TUI: the prefetch path, and the learner-profile cache read once per + session. + +## 9. Rollout + +1. Phase 1b telemetry, then 1a fixes. Record the baseline. +2. Phase 2 (A + B), with `MATHIZ_QUESTION_PREFETCH=0` as an off switch for + the first release, then remove the switch. +3. Review the numbers against §2, then decide on C/D/E/H. + +Docs to update alongside: specs/05 §1 (point here), specs/13 (question +flow), `docs/personas.md` (kid retry flow), and `specs/glossary.md`: +"Prefetcher" and "question source", following the `glossary` skill. diff --git a/specs/glossary.md b/specs/glossary.md new file mode 100644 index 0000000..ea563e1 --- /dev/null +++ b/specs/glossary.md @@ -0,0 +1,1059 @@ +# Mathiz Glossary — first-class concepts + +This is the registry of every named concept in Mathiz: what it is, where it +lives in code, and which spec designed it. It exists so that new work reuses +an existing concept (and its name) instead of inventing a near-duplicate. +Code is the truth: where a spec and the code disagree, the entry says so and +describes the code. Entries marked **(proposed, specs/17)** describe a +design that is not yet in the code. + +**How to use.** Before naming a new type, table, event, API resource, UI +term or ledger `source`, search this file (and `grep` the **Code** paths) +for the idea. If it exists, reuse its name verbatim — including the +kid-facing word if the surface is a child surface. If you add, rename, +split or remove a concept, update its entry in the same PR. If two sections +seem to describe your idea, read **Homonyms & look-alikes** at the bottom +first: several common words (plan, session, invite, tier, provider, token, +profile, slot, owner, captain, explorer) already mean different things in +different packages. + +Sections: Learning engine · Content generation (LLM) · Persistence · +Family & identity · Game (treasure map) · Quests · Money · Platform/ops · +Homonyms & look-alikes. Entries are alphabetical within a section. + +--- + +## Learning engine + +The pure Go domain: `internal/{skillgraph,mastery,spacedrep,session,gems}`. +Driven identically by the TUI session screen and the game manager. + +### Accuracy +Correct ÷ attempted for one skill; 60 % of the fluency score and the gate for tier completion (`SkillMastery.Accuracy`). Historical accuracy over the whole event stream (`EventRepo.SkillAccuracy`) feeds the careless classifier. +- **Code:** `mastery.SkillMastery.Accuracy` · `store.EventRepo.SkillAccuracy` +- **Spec:** [07-mastery.md §2.3](07-mastery.md) + +### Booster +See **Plan category**. A mastered skill scheduled for a confidence-building slot (10 % of a TUI plan; a non-due mastered dig spot on the map). Dashboard copy: "⭐ Confidence builder". +- **Code:** `session.CategoryBooster` +- **Spec:** [06-session.md §2.4](06-session.md) + +### Consistency score +Fluency component (20 %): current correct-answer streak ÷ streak cap (default 8), clamped to 1. +- **Code:** `mastery.ConsistencyScore` · `mastery.DefaultStreakCap` +- **Spec:** [07-mastery.md §2.5](07-mastery.md) + +### Decay check +Session-start scan that marks overdue mastered skills **rusty** (past the review date plus a 50 % grace of the interval). Persists a mastery event only when the scheduler has a non-nil event repo — map reads run it with nil repos so they stay side-effect-free. +- **Code:** `spacedrep.Scheduler.RunDecayCheck` · `spacedrep.ReviewState.IsRustyThreshold` +- **Spec:** [08-spaced-rep.md §4.1](08-spaced-rep.md) +- **Aka / don't call it:** "time-decay" (the mastery trigger string). Not "expiry". + +### Dependents +Skills that list a given skill as a prerequisite; the set that may unlock when it is mastered ("map reveal"). +- **Code:** `skillgraph.Dependents` +- **Spec:** [03-skill-graph.md "Graph Traversal API"](03-skill-graph.md) + +### Diagnostic placement +Designed top-down probing quiz to place a learner on the graph. **Not implemented** — only a stub result type exists. +- **Code:** `skillgraph.DiagnosticResult` (stub) +- **Spec:** [03-skill-graph.md "Diagnostic Placement"](03-skill-graph.md) + +### Error context (recent errors) +Per-skill list of natural-language strings describing recent wrong answers ("Answered 623 for '345 + 289', correct was 634 [careless]"), capped at 5, fed to the LLM on the next generation. Enriched with the diagnosis category/misconception when available; compressed by the Compressor once it exceeds 800 chars. +- **Code:** `session.BuildErrorContext` · `session.MaxRecentErrors` · `session.SessionState.RecentErrors` +- **Spec:** [05-problem-gen.md §13](05-problem-gen.md) · [09-diagnosis.md §8.2](09-diagnosis.md) + +### Fluency score +0–1 per-skill score = 0.6·accuracy + 0.2·speed + 0.2·consistency. Recorded on every mastery event; shown in the TUI summary and parent stats. +- **Code:** `mastery.FluencyScore` · `mastery.FluencyMetrics` +- **Spec:** [07-mastery.md §2](07-mastery.md) + +### Frontier +Skills that are unlocked but not yet mastered — where learning happens. 60 % of a TUI plan; the default category of a map dig on a `new`/`learning` spot. Dashboard copy: "🌱 New skill". +- **Code:** `skillgraph.FrontierSkills` · `session.CategoryFrontier` +- **Spec:** [06-session.md §2.2](06-session.md) + +### Gem +A reward earned for a real learning milestone — never for login streaks or time spent. Five types × four rarities; persisted as gem events, counted in the snapshot, shown in the Gem Vault (TUI) and the kid's 💎 vault (web). +- **Code:** `gems.GemAward` · `gems.Service` · `ent/schema/gem_event.go` +- **Spec:** [11-rewards.md](11-rewards.md) + +### Gem rarity +`common | rare | epic | legendary`. Mastery/recovery/retention gems take rarity from the skill's DAG depth quartile; streak gems from streak length (5/10/15/20); session gems from session accuracy (50/75/90 %). +- **Code:** `gems.Rarity` · `gems.ComputeDepthMap` · `gems.StreakRarity` · `gems.SessionRarity` +- **Spec:** [11-rewards.md §3](11-rewards.md) + +### Gem type +`mastery` (first time mastered), `recovery` (rusty → mastered), `retention` (spaced-rep graduation), `streak` (in-session correct streak at 5/10/15/20, then every 5), `session` (a fully completed session/expedition). The kid web surface renames them — see **Gem (kid labels)** in Game. +- **Code:** `gems.GemType` · `gems.AllGemTypes` +- **Spec:** [11-rewards.md §2](11-rewards.md) +- **Aka / don't call it:** Go icons (💎🔥🛡️⚡🏆) differ from the web's (🏆🔥⛵💪🛡️); the IDs are canonical, the icons are per-surface. + +### Grade level +US school grade 2–5 on a skill (`GradeLevel`) and on a child profile (`grade`). Skills are laid out in grade columns on the map and grouped by grade in the curriculum. +- **Code:** `skillgraph.Skill.GradeLevel` · `ent/schema/child_profile.go` (`grade`) +- **Spec:** [03-skill-graph.md "Skill Node Schema"](03-skill-graph.md) + +### Graduation (spaced rep) +A mastered skill that has passed all six review stages (6 consecutive hits) graduates: 90-day interval, retention gem awarded. Lost again on recovery from rusty. +- **Code:** `spacedrep.GraduationStage` · `spacedrep.GraduatedIntervalDays` · `spacedrep.ReviewState.Graduated` +- **Spec:** [08-spaced-rep.md §2.2](08-spaced-rep.md) + +### HandleAnswer +The single grading entry point. Checks the answer, records mastery and fluency, updates tier progress, awards streak/mastery/recovery gems, runs diagnosis, accumulates error context, computes hint availability, records spaced-rep reviews, triggers the lesson request, and appends answer/diagnosis events. Both surfaces call it; nothing else grades. +- **Code:** `session.HandleAnswer` +- **Spec:** [06-session.md §4.2](06-session.md) · [10-ai-lessons.md §8.1](10-ai-lessons.md) + +### Interval schedule (review stages) +Expanding review intervals in days: stage 0..5 = 1, 3, 7, 14, 30, 60; graduated = 90. A correct review advances the stage; a wrong one resets consecutive hits but keeps the stage. +- **Code:** `spacedrep.BaseIntervals` · `spacedrep.MaxStage` +- **Spec:** [08-spaced-rep.md §2.1](08-spaced-rep.md) + +### Learner +The person whose events/snapshot a repo is scoped to: the local CLI user, or one child profile in serve mode. Domain packages say "learner"; SaaS code says "child". The same person. +- **Code:** `store.EventRepoFor(owner)` (owner = learner) +- **Spec:** [12-saas.md §3.1](12-saas.md) + +### Mastery state +Per-skill lifecycle: `new → learning → mastered ⇄ rusty`. Mastered means the Prove tier was passed; rusty means decayed or poor recent review performance (last 4 reviews < 50 %). Stored in the snapshot; every change is a mastery event. +- **Code:** `mastery.MasteryState` · `mastery.Service.RecordAnswer` · `mastery.Service.MarkRusty` +- **Spec:** [07-mastery.md §3](07-mastery.md) +- **Aka / don't call it:** distinct from **Skill state (display)** — don't add a state here for UI reasons. + +### Mastery transition +A recorded state change with a `Trigger` string: `first-attempt`, `tier-complete`, `prove-complete`, `time-decay`, `review-performance`, `recovery-complete`. Drives gems, map reveals and the dashboard "Milestones" kind (only to-`mastered`/`rusty` are shown to parents). +- **Code:** `mastery.StateTransition` · `store.MasteryEventData` +- **Spec:** [07-mastery.md §3.3](07-mastery.md) + +### Misconception penalty +Each wrong answer diagnosed as a misconception adds +1 to the correct answers required to complete the current tier of that skill. Reset on tier advancement. +- **Code:** `mastery.SkillMastery.MisconceptionPenalty` · `mastery.SkillMastery.IsTierComplete` +- **Spec:** [09-diagnosis.md §7](09-diagnosis.md) + +### Plan (session plan) +The ordered list of plan slots a session will cycle through. TUI: five slots (3 frontier / 1 review / 1 booster), 15 minutes. Game: exactly one slot for the tapped skill. Serialized onto the session "start" event as `plan_summary`. +- **Code:** `session.Plan` · `session.DefaultTotalSlots` · `store.PlanSlotSummaryData` +- **Spec:** [06-session.md §2.6](06-session.md) +- **Aka / don't call it:** NOT a billing plan — see Homonyms. + +### Plan category +Why a skill is in the plan: `frontier` (learning it), `review` (spaced-rep due or rusty), `booster` (mastered, confidence). Recorded on every answer event and surfaced to parents as the expedition "why" chip (🌱 New skill / 🔄 Review / ⭐ Confidence builder). +- **Code:** `session.PlanCategory` · `game.Manager.Start` (category derivation) +- **Spec:** [06-session.md §2.1](06-session.md) · [12-saas.md "Activity timeline"](12-saas.md) + +### Plan slot +One (skill, tier, category) triple in a plan. The TUI serves 3 questions per slot ("mini-block") then cycles; a completed tier retires its slot. +- **Code:** `session.PlanSlot` · `session.QuestionsPerSlot` · `session.AdvanceSlot` +- **Spec:** [06-session.md §10](06-session.md) +- **Aka / don't call it:** NOT a play slot — see Homonyms. + +### Planner +Builds the TUI's 60/30/10 plan from mastered set, tier progress and the scheduler's due skills. The game does not use it (single-slot plan built inline). +- **Code:** `session.Planner` · `session.DefaultPlanner.BuildPlan` +- **Spec:** [06-session.md §2](06-session.md) + +### Prerequisite +Edge in the skill DAG. Every prerequisite must be `mastered` for a skill to unlock. Drawn as paths/trails on the map. +- **Code:** `skillgraph.Skill.Prerequisites` · `skillgraph.Prerequisites` +- **Spec:** [03-skill-graph.md "Prerequisite Model"](03-skill-graph.md) + +### Recovery +Returning a rusty skill to mastered: 4 Learn-tier-difficulty questions at ≥ 75 % (hints allowed). Awards a recovery gem and re-initializes the review schedule at stage 0. +- **Code:** `mastery.RecoveryTierConfig` · `spacedrep.Scheduler.ReInitSkill` +- **Spec:** [07-mastery.md §3.3](07-mastery.md) · [08-spaced-rep.md §4.5](08-spaced-rep.md) + +### Review (spaced-rep) +A practice answer on a mastered/rusty skill that feeds the review schedule (`RecordReview`) and the review-performance check. Also the plan category for such a slot. Kid-facing: a "sinking" spot. +- **Code:** `spacedrep.Scheduler.RecordReview` · `session.CategoryReview` · `store.EventRepo.RecentReviewAccuracy` +- **Spec:** [08-spaced-rep.md §4.3](08-spaced-rep.md) + +### Review state +Per-skill spaced-rep record: stage, next/last review date, consecutive hits, graduated. Lives in the snapshot's `spaced_rep` block; bootstrapped from mastery data when absent. +- **Code:** `spacedrep.ReviewState` · `store.ReviewStateData` · `spacedrep.BootstrapFromMastery` +- **Spec:** [08-spaced-rep.md §3.1](08-spaced-rep.md) + +### Review status +UI-only classification of a review state at a moment: `not_due | due | overdue | graduated`. Due skills sort most-overdue first for the planner. +- **Code:** `spacedrep.ReviewStatus` · `spacedrep.Scheduler.DueSkills` +- **Spec:** [08-spaced-rep.md §3.2](08-spaced-rep.md) + +### Root skill +A skill with no prerequisites. The seed has exactly one (`pv-hundreds`); validation panics otherwise. Tests must use `AllSkills()`, not `RootSkills()[:N]`. +- **Code:** `skillgraph.RootSkills` · `skillgraph.Validate` +- **Spec:** [03-skill-graph.md "Graph Validation"](03-skill-graph.md) + +### Rusty +Mastery state for a previously mastered skill that decayed (overdue past grace) or whose recent reviews were poor. Excluded from `DueSkills`; recovered via **Recovery**. Kid-facing: "sinking" spot; dashboard pill "🌧️ Rusty". +- **Code:** `mastery.StateRusty` · `spacedrep.Scheduler.RunDecayCheck` +- **Spec:** [07-mastery.md §3.3](07-mastery.md) + +### Session +One run of the engine for one learner, identified by a UUID: a "start" event, N answers, an "end" event, then a snapshot save with async profile refresh. TUI = 15-minute mixed plan; game = one expedition (5 questions, one skill). The ledger charges per session ID. +- **Code:** `session.SessionState.SessionID` · `store.SessionEventData` · `session.SaveSnapshotWithProfile` +- **Spec:** [06-session.md §4](06-session.md) · [13-treasure-map.md §2](13-treasure-map.md) +- **Aka / don't call it:** "expedition" is the kid-facing name for a game session; "practice session" in TUI copy. Not a login session. + +### Session phase +TUI session lifecycle: `loading → active → feedback → ending → summary`. Not used by the game manager (HTTP calls are the phases). +- **Code:** `session.SessionPhase` +- **Spec:** [06-session.md §5](06-session.md) + +### Session state +The in-memory bag the engine mutates: plan, current question, per-skill results, tier progress, mastered set, recent errors, streak, pending lesson/gem, and the injected services (mastery, scheduler, diagnosis, lessons, compressor, gems). Built per session from the latest snapshot. +- **Code:** `session.SessionState` · `session.NewSessionState` +- **Spec:** [06-session.md §5](06-session.md) + +### Session summary +End-of-session roll-up: duration, totals, per-skill results with tier before/after and fluency, gems earned. TUI "Session Summary" screen; game `SummaryView`. +- **Code:** `session.SessionSummary` · `session.BuildSummary` · `game.SummaryView` +- **Spec:** [06-session.md §8](06-session.md) + +### Skill +A node of the curriculum DAG: ID (kebab-case, e.g. `add-3digit`), name, description, strand, grade, Common Core ID, keywords, prerequisites, two tier configs. 56 skills in the seed (the comment in `seed.go` still says 54 — stale). Kid-facing: a **dig spot**. +- **Code:** `skillgraph.Skill` · `internal/skillgraph/seed.go` · `skillgraph.GetSkill` +- **Spec:** [03-skill-graph.md "Skill Node Schema"](03-skill-graph.md) + +### Skill graph +The package-level singleton DAG of 56 skills across 5 strands, one root, validated in `init()` (cycles, dangling prereqs, duplicate IDs, bad tier configs panic). Also the data behind the TUI Skill Map, the kid's treasure map and the public curriculum API. +- **Code:** `internal/skillgraph/graph.go` · `skillgraph.TopologicalOrder` +- **Spec:** [03-skill-graph.md](03-skill-graph.md) +- **Aka / don't call it:** "curriculum" (public API / dashboard), "map" (kid). + +### Skill state (display) +UI projection combining mastery state, prerequisites and tier: `locked 🔒 | available 🔓 | learning 📖 | proving 📝 | mastered ✅ | rusty 🔄`. The game translates these again into spot states. +- **Code:** `skillgraph.SkillState` · `mastery.ResolveDisplayState` +- **Spec:** [03-skill-graph.md "Skill States"](03-skill-graph.md) + +### Speed score +Fluency component (20 %): response time vs the tier's time limit (1.0 at ≤ 50 % of limit, 0.5 at the limit, decaying after). Learn tier is untimed → neutral 0.5. Rolling window of the last 10. +- **Code:** `mastery.SpeedScore` · `mastery.RecordSpeed` · `mastery.DefaultSpeedWindow` +- **Spec:** [07-mastery.md §2.4](07-mastery.md) + +### Strand +One of five content areas: `number-and-place-value`, `addition-and-subtraction`, `multiplication-and-division`, `fractions`, `measurement`. Kid-facing: an **island**. Misconceptions are indexed by strand. +- **Code:** `skillgraph.Strand` · `skillgraph.AllStrands` · `skillgraph.StrandDisplayName` +- **Spec:** [03-skill-graph.md "Strands"](03-skill-graph.md) + +### Streak (in-session) +Consecutive correct answers within one session; streak gems at 5, 10, 15, 20 then every 5. Distinct from the per-skill fluency streak (consistency) which persists across sessions. +- **Code:** `session.SessionState.ConsecutiveCorrect` · `gems.NextStreakThreshold` · `gems.BaseStreakThreshold` +- **Spec:** [11-rewards.md §6](11-rewards.md) + +### Tier (learn / prove) +Each skill has two difficulty tiers. **Learn**: 8 problems, 75 %, untimed, hints allowed. **Prove**: 6 problems, 85 %, 30 s/problem, no hints. Passing Learn unlocks Prove; passing Prove = mastered. Serialized as `"learn"`/`"prove"`. +- **Code:** `skillgraph.Tier` · `skillgraph.TierConfig` · `skillgraph.DefaultTiers` · `session.TierString` +- **Spec:** [03-skill-graph.md "Difficulty Tiers"](03-skill-graph.md) +- **Aka / don't call it:** NOT a billing tier/plan, NOT gem rarity — see Homonyms. + +### Tier progress / tier advancement +Cumulative attempts and correct count toward the current tier. The authoritative copy is `SkillMastery` (mastery service); `session.TierProgress` is the pre-mastery legacy shape still used to seed the session and for the TUI fallback path. `TierAdvancement` is the display record of Learn→Prove or →Mastered ("You found the vault — now prove it!"). +- **Code:** `mastery.SkillMastery.IsTierComplete` · `session.TierProgress` · `session.TierAdvancement` +- **Spec:** [06-session.md §3](06-session.md) · [07-mastery.md §5.2](07-mastery.md) + +### Unlocked +A skill whose prerequisites are all mastered (`IsUnlocked`). `AvailableSkills` = unlocked and not mastered; `BlockedSkills` = the rest. Kid-facing: fog lifts. +- **Code:** `skillgraph.IsUnlocked` · `skillgraph.AvailableSkills` · `skillgraph.BlockedSkills` +- **Spec:** [03-skill-graph.md "Graph Traversal API"](03-skill-graph.md) + +--- + +## Content generation (LLM) + +`internal/{llm,problemgen,diagnosis,lessons,tutor}`. The LLM generates; +deterministic Go validates, grades and classifies. + +### Answer format +How the learner answers: `numeric` (types a value) or `multiple_choice` (picks one of exactly 4 choices). Stored on answer events as `answer_format`. +- **Code:** `problemgen.AnswerFormat` · `problemgen.FormatNumeric` / `FormatMultipleChoice` +- **Spec:** [05-problem-gen.md §2 "Answer Format"](05-problem-gen.md) + +### Answer type +Numeric representation of the canonical answer: `integer | decimal | fraction | text` (`text` only with multiple choice, for conceptual questions). Drives normalization in `CheckAnswer` and the math-check recompute. +- **Code:** `problemgen.AnswerType` +- **Spec:** [05-problem-gen.md §2 "Answer Types"](05-problem-gen.md) + +### CheckAnswer +The **only** answer comparator in the codebase. Trims, case-folds, reduces fractions, drops trailing/leading zeros, accepts MC by text or 1–4 index. Lesson practice (`Lesson.GradePractice`), quest questions and every session go through it. Never write another. +- **Code:** `problemgen.CheckAnswer` +- **Spec:** [05-problem-gen.md §10](05-problem-gen.md) + +### Choice shuffling +Every generated MC question has its choices shuffled after validation (LLMs put the right answer first). Safe because grading is by text. Parent-authored quest questions are deliberately **not** shuffled. +- **Code:** `problemgen.Question.ShuffleChoices` +- **Spec:** [05-problem-gen.md §7](05-problem-gen.md) (shuffle rule is code-only) + +### Classifier (rule-based) +Synchronous, deterministic wrong-answer rules run in priority order: speed-rush (< 2000 ms) then careless (historical accuracy > 80 %). First match wins; no match → async LLM diagnosis. +- **Code:** `diagnosis.Classifier` · `diagnosis.DefaultClassifiers` · `diagnosis.SpeedRushThresholdMs` · `diagnosis.CarelessAccuracyThreshold` +- **Spec:** [09-diagnosis.md §3](09-diagnosis.md) + +### Compressor (context compression) +LLM service with two jobs: **session compression** — collapse a skill's error context into one summary once it passes 800 chars (purpose `session-compress`); **profile generation** — produce the learner profile after a session (purpose `profile`). +- **Code:** `lessons.Compressor` · `lessons.Compressor.CompressErrors` · `lessons.Compressor.GenerateProfile` · `lessons.SessionCompressionThreshold` +- **Spec:** [10-ai-lessons.md §4–5](10-ai-lessons.md) + +### Diagnosis +Classification of a wrong answer into an error category (plus misconception ID, confidence, classifier name, reasoning). Rules run synchronously inside `HandleAnswer`; the LLM diagnoser runs async and its result is appended as a diagnosis event. Feeds error context, the misconception penalty and lesson input. +- **Code:** `diagnosis.Service.Diagnose` · `diagnosis.DiagnosisResult` · `ent/schema/diagnosis_event.go` +- **Spec:** [09-diagnosis.md §6](09-diagnosis.md) + +### Diagnoser (LLM) +The async LLM fallback: given the question, answer and the strand's known misconceptions, returns a misconception ID (validated against the taxonomy) or null. Purpose label `error-diagnosis`. +- **Code:** `diagnosis.Diagnoser` · `diagnosis.DiagnosisRequest` · `llm.PurposeDiagnosis` +- **Spec:** [09-diagnosis.md §5](09-diagnosis.md) + +### Difficulty (self-assessed) +The LLM's 1–5 difficulty rating on a question. Analytics only — never gates anything. Not to be confused with tier. +- **Code:** `problemgen.Question.Difficulty` +- **Spec:** [05-problem-gen.md §2](05-problem-gen.md) + +### Error category +`careless | speed-rush | misconception | unclassified`. Persisted on diagnosis events and prefixed into error context. +- **Code:** `diagnosis.ErrorCategory` +- **Spec:** [09-diagnosis.md §2](09-diagnosis.md) + +### Explanation +Short worked solution attached to every question, shown after grading (kid: after a wrong map dig; after a correct quest answer — see **Sealed answer**). Also on lesson practice questions. +- **Code:** `problemgen.Question.Explanation` · `game.AnswerResultView.Explanation` +- **Spec:** [05-problem-gen.md §2](05-problem-gen.md) + +### Generator +Interface that produces one validated `Question` from a `GenerateInput` (skill, tier, prior questions, recent errors, learner profile). `LLMGenerator` is production; tests inject fakes. Purpose label `question-gen`. +- **Code:** `problemgen.Generator` · `problemgen.LLMGenerator` · `problemgen.GenerateInput` +- **Spec:** [05-problem-gen.md §5](05-problem-gen.md) + +### Hint +Optional short nudge generated with Learn-tier questions only. Becomes available after one wrong answer on the current question, once; viewing it appends a hint event. Kid-facing: "🗺️ Show me a clue". +- **Code:** `problemgen.Question.Hint` · `session.SessionState.HintAvailable` · `ent/schema/hint_event.go` +- **Spec:** [10-ai-lessons.md §2](10-ai-lessons.md) +- **Aka / don't call it:** there is no `hint` LLM purpose — hints ride along with question generation. + +### Learner profile +LLM-written holistic summary of the learner (summary, strengths, weaknesses, patterns, generated-at), regenerated asynchronously after each session, stored on the latest snapshot and — when changed — appended as a learner-profile event. Fed into question generation and shown to parents as "what the tutor has learned". +- **Code:** `lessons.LearnerProfile` · `store.LearnerProfileData` · `session.SaveSnapshotWithProfile` +- **Spec:** [10-ai-lessons.md §5](10-ai-lessons.md) · [12-saas.md §3.1](12-saas.md) +- **Aka / don't call it:** NOT a child profile — see Homonyms. + +### Lesson (micro-lesson) +LLM-generated remediation for one skill: title, explanation, worked example, practice question. Requested asynchronously after **2 wrong answers on the same skill in one session**; best-effort (if slow, play continues). Full content is persisted as a lesson event so it can be revisited. Kid-facing: "🧭 The guide has a tip for you". +- **Code:** `lessons.Lesson` · `lessons.Service.RequestLesson` / `ConsumeLesson` · `ent/schema/lesson_event.go` +- **Spec:** [10-ai-lessons.md §3](10-ai-lessons.md) + +### LLM middleware +Decorator stack built by the factory: caller → retry (backoff, `Retry-After`) → timeout (per-purpose budgets, e.g. 20 s question-gen, 120 s quest-gen) → logging (LLM request events) → base provider. A timed-out attempt is still logged. +- **Code:** `llm.NewProvider` · `llm.WithRetry` · `llm.WithTimeout` · `llm.WithLogging` +- **Spec:** [04-llm-integration.md §7–9](04-llm-integration.md) + +### LLM provider +The vendor abstraction: `Generate(ctx, Request) → Response` with a JSON schema for structured output, plus `ModelID()`. Adapters: `anthropic`, `openai` (any OpenAI-compatible base URL), `gemini`, `openrouter`, `mock`. Selected by env. +- **Code:** `llm.Provider` · `llm.Request` / `llm.Response` / `llm.Schema` · `internal/llm/factory.go` +- **Spec:** [04-llm-integration.md §3–4](04-llm-integration.md) +- **Aka / don't call it:** NOT a billing provider or auth provider — see Homonyms. + +### LLM purpose +Context label identifying why the LLM is called; keyed to per-purpose timeouts and recorded on every LLM request event for auditing. Values: `question-gen`, `quest-gen`, `lesson`, `error-diagnosis`, `session-compress`, `profile`. +- **Code:** `llm.WithPurpose` / `llm.PurposeFrom` · `internal/llm/timeout.go` (constants) +- **Spec:** [04-llm-integration.md §8 "Purpose Labels"](04-llm-integration.md) + +### Math-check validator +Pure-Go recompute of the answer from the question text (arithmetic and fraction expressions, ~70 % of skills); non-computable questions pass silently. Also the quest editor's "typo warning". +- **Code:** `problemgen.MathCheckValidator` +- **Spec:** [05-problem-gen.md §8.3](05-problem-gen.md) + +### Misconception (taxonomy) +A named, strand-scoped wrong-thinking pattern (e.g. `frac-add-straight`, `add-no-carry`); 19 seeded. The LLM diagnoser may only return IDs from this registry. +- **Code:** `diagnosis.Misconception` · `internal/diagnosis/taxonomy_seed.go` · `diagnosis.MisconceptionsByStrand` +- **Spec:** [09-diagnosis.md §4](09-diagnosis.md) + +### Mock provider +Canned-response `llm.Provider` for tests (`MockResponse{Content: json}`); records calls. E2E stubs the LLM differently — via an OpenAI-compatible HTTP stub and `MATHIZ_OPENAI_BASE_URL`. +- **Code:** `llm.NewMockProvider` · `llm.MockResponse` +- **Spec:** [04-llm-integration.md §4.4](04-llm-integration.md) + +### Model cost +Embedded USD-per-million-token table used by `mathiz llm stats` to estimate spend. Unknown models cost nil. +- **Code:** `llm.ModelCost` · `llm.LookupCost` +- **Spec:** [04-llm-integration.md §13](04-llm-integration.md) + +### Practice question (lesson) +The try-it-yourself question inside a micro-lesson (text, answer, answer type, explanation). Graded through `CheckAnswer`; attempt/correct/skip recorded on the lesson event. Its reveal is always safe (different numbers from the gating question). +- **Code:** `lessons.PracticeQuestion` · `lessons.Lesson.GradePractice` +- **Spec:** [10-ai-lessons.md §3.2](10-ai-lessons.md) + +### Prior questions (dedup) +Texts of questions already asked for a skill this session, passed to the prompt (last 8) so the LLM varies. +- **Code:** `problemgen.GenerateInput.PriorQuestions` · `problemgen.Config.MaxPriorQuestions` +- **Spec:** [05-problem-gen.md §11](05-problem-gen.md) + +### Question +The generated unit of practice: text, format, canonical answer, answer type, choices, hint, difficulty, explanation, skill ID, tier. Never static — generated per learner at ask time. Kid-facing: a "clue" ("Next clue →"). +- **Code:** `problemgen.Question` · `problemgen.QuestionSchema` +- **Spec:** [05-problem-gen.md §2](05-problem-gen.md) +- **Aka / don't call it:** a **quest question** is a different, parent-authored type (see Quests). + +### Skill preview +`mathiz preview --skill X [--tier prove]` generates questions for a skill with no database — for evaluating prompts and new skills. +- **Code:** `cmd/preview.go` · `cmd/skill.go` +- **Spec:** [docs/skill-preview.md](../docs/skill-preview.md) + +### Structural / answer-format validators +First two links of the default validator chain: field presence, lengths, difficulty range, known format/type; then answer-vs-type consistency and 4 choices containing the answer. +- **Code:** `problemgen.StructuralValidator` · `problemgen.AnswerFormatValidator` +- **Spec:** [05-problem-gen.md §8.1–8.2](05-problem-gen.md) + +### Toolset (tutor) +The per-learner bundle of AI services — Generator, Diagnosis, Lessons, Compressor — built from one provider at one construction point so the TUI and the game can never drift. Everything but the Generator is nil-able (= feature off). +- **Code:** `tutor.Toolset` · `tutor.New` · `game.ToolsetFactory` +- **Spec:** [13-treasure-map.md §1](13-treasure-map.md) (code-level concept; no dedicated spec) + +### Validator (chain) +Ordered, stateless checks every generated question must pass; first failure stops the pipeline and, if `Retryable`, triggers regeneration (up to 3 failures in the game). Default chain: structural → answer-format → math-check. +- **Code:** `problemgen.Validator` · `problemgen.ValidationError` · `problemgen.DefaultConfig` +- **Spec:** [05-problem-gen.md §3, §9](05-problem-gen.md) + +--- + +## Persistence + +`internal/store` + `ent/schema`. Event sourcing with periodic snapshots, +one `store.Open(dsn)` for SQLite (local) and PostgreSQL (serve). + +### Answer event +One row per graded answer: session, skill, tier, category, question text, correct/learner answer, correctness, time, format. Backs accuracy queries, the activity session detail and the quest-attribution reader. +- **Code:** `ent/schema/answer_event.go` · `store.AnswerEventData` · `store.EventRepo.AnswersForSession` +- **Spec:** [06-session.md §6.2](06-session.md) + +### Control plane vs data plane +**Data plane** = owner-scoped learning events + snapshots (guarded by `owner_id`). **Control plane** = family-scoped ent tables with no `owner_id` (Account, FamilySpace, FamilyMember, ChildProfile, Invite, ParentInvite, DeviceToken, CreditEntry, BillingState, Quest, QuestQuestion, QuestProgress) protected by authz. `ownerguard.go`'s comment lists only the first seven — stale. +- **Code:** `internal/store/ownerguard.go` (`ownerScopedTypes`) · `ent/schema/` +- **Spec:** [12-saas.md §3](12-saas.md) + +### Diagnosis event +Persisted diagnosis result for a wrong answer (category, misconception ID, confidence, classifier, reasoning). +- **Code:** `ent/schema/diagnosis_event.go` · `store.DiagnosisEventData` +- **Spec:** [09-diagnosis.md §9](09-diagnosis.md) + +### Event (event sourcing) +An immutable, append-only fact in the learner's stream. Each event type is its own ent table embedding `EventMixin` (sequence, timestamp, owner_id). Nine types: answer, session, mastery, diagnosis, hint, lesson, learner-profile, gem, LLM-request. Spec 02's names (`AnswerAttemptEvent`, `SkillStateChangeEvent`, …) were superseded by these. +- **Code:** `ent/schema/event_mixin.go` · `internal/store/*_event.go` +- **Spec:** [02-persistence.md §4](02-persistence.md) + +### Event repo +The append/query interface over all event types, scoped to one owner. Changing it ripples to five hand-written mocks in `internal/{gems,mastery,spacedrep,session,screens/session}/*_test.go`. Server code must only use `Store.EventRepoFor(childUID)`. +- **Code:** `store.EventRepo` · `store.Store.EventRepoFor` +- **Spec:** [02-persistence.md §6](02-persistence.md) + +### Family LLM stream +Owner ID `space:` under which parent-initiated quest-generation LLM calls are logged (they belong to no child). Isolated by the owner guard like any other owner. +- **Code:** `quests.LLMOwnerID` +- **Spec:** [15-quests.md §3](15-quests.md) (code-only detail) + +### Gem event +One row per gem award (type, rarity, skill, session, reason). `GemCounts` aggregates for the vault and snapshot. +- **Code:** `ent/schema/gem_event.go` · `store.EventRepo.GemCounts` +- **Spec:** [11-rewards.md §4.2](11-rewards.md) + +### Global sequence +Single monotonic counter (`global_sequence` table, raw portable SQL) stamped on every event regardless of table, so cross-type ordering and snapshot consistency work. Global, not per owner. Activity paging cursors are sequences. +- **Code:** `internal/store/event.go` (`sequenceCounter`) +- **Spec:** [02-persistence.md §4 "Base Fields"](02-persistence.md) · [12-saas.md §3.1](12-saas.md) + +### Hint event +Records that a hint was shown (session, skill, question, hint text). Counted per session for the activity detail. +- **Code:** `ent/schema/hint_event.go` · `store.EventRepo.HintCountForSession` +- **Spec:** [10-ai-lessons.md §6.1](10-ai-lessons.md) + +### Learner profile event +Append-only history of learner-profile versions; written only when the regenerated profile differs. No consumer reads it yet except the recovery path when the snapshot read fails. +- **Code:** `ent/schema/learner_profile_event.go` · `store.EventRepo.AppendLearnerProfileEvent` +- **Spec:** [12-saas.md §3.1](12-saas.md) + +### Lesson event +Full micro-lesson content plus practice outcome, so past tips can be re-read (guide's notebook, local TUI alike). +- **Code:** `ent/schema/lesson_event.go` · `store.EventRepo.QueryLessonEvents` +- **Spec:** [10-ai-lessons.md §6.2](10-ai-lessons.md) · [13-treasure-map.md §4](13-treasure-map.md) + +### LLM request event +Audit row for every LLM call: provider, model, purpose, tokens, latency, success, full request/response bodies. Per learner in serve mode; browsed by `mathiz llm` locally. The schema comment's purpose list is stale (see **LLM purpose**). +- **Code:** `ent/schema/llm_request_event.go` · `store.LLMRequestEventData` · `llm.LoggingProvider` +- **Spec:** [04-llm-integration.md §8](04-llm-integration.md) · [docs/llm-usage-auditing.md](../docs/llm-usage-auditing.md) + +### Local owner +Owner ID `""` — the single-learner CLI's view, predating multi-tenancy. `Store.EventRepo()`/`SnapshotRepo()` are shorthands for it and must not be used server-side. +- **Code:** `store.LocalOwner` · `store.Store.EventRepo` +- **Spec:** [12-saas.md §3.1](12-saas.md) + +### Mastery event +Persisted mastery transition (skill, from, to, trigger, fluency, session). Parents see to-`mastered`/`rusty` ones as "Milestones". +- **Code:** `ent/schema/mastery_event.go` · `store.EventRepo.QueryMasteryEvents` +- **Spec:** [07-mastery.md §7.4](07-mastery.md) + +### Owner / owner_id +Tenant key on every data-plane row: the child profile UID in serve mode, `""` locally, `space:` for family LLM streams. Repos stamp it on append and filter on query. +- **Code:** `ent/schema/event_mixin.go` (`owner_id`) · `ent/schema/snapshot.go` +- **Spec:** [12-saas.md §3.1](12-saas.md) +- **Aka / don't call it:** NOT the family **owner** role — see Homonyms. + +### Owner guard +Fail-closed enforcement registered in `store.Open`: an ent query interceptor adds the `owner_id` predicate from the context; a mutation hook stamps/validates it. A query on an owner-scoped type with no owner in context **errors** instead of leaking. Raw SQL bypasses it — keep parameterizing the owner there. +- **Code:** `internal/store/ownerguard.go` · `internal/store/ownerctx.go` · `internal/store/owner_scope_test.go` +- **Spec:** [12-saas.md §11](12-saas.md) + +### Query opts +Common filter/paging struct for event queries: limit, after/before sequence, from/to time. +- **Code:** `store.QueryOpts` +- **Spec:** [02-persistence.md §6 "QueryOpts"](02-persistence.md) + +### Session event +`action = start | end` rows sharing a session ID. Start carries the serialized plan and (for quest expeditions) `quest_uid` + as-of-play `quest_name`; end carries totals and duration. `QuerySessionSummaries` joins the pair for history/activity. +- **Code:** `ent/schema/session_event.go` · `store.SessionEventData` · `store.SessionSummaryRecord` +- **Spec:** [06-session.md §6.1](06-session.md) · [15-quests.md "Completion visibility"](15-quests.md) + +### Snapshot +Point-in-time JSON of full learner state (mastery, spaced-rep, learner profile, gem counts; legacy `tier_progress`/`mastered_set` kept for migration) saved at session end; the last 10 are kept. Sessions load from the latest snapshot — which is why two concurrent sessions clobber each other. +- **Code:** `store.Snapshot` · `store.SnapshotData` · `ent/schema/snapshot.go` · `mastery.MigrateSnapshot` +- **Spec:** [02-persistence.md §5](02-persistence.md) · [07-mastery.md §7](07-mastery.md) + +### Snapshot repo +Save / Latest / UpdateLearnerProfile (in-place profile refresh, no new row) / Prune, scoped to one owner. +- **Code:** `store.SnapshotRepo` · `store.Store.SnapshotRepoFor` +- **Spec:** [02-persistence.md §6](02-persistence.md) + +### Store +The one database handle: `store.Open(dsn)` picks SQLite (file path; `SetMaxOpenConns(1)`, WAL pragmas) or PostgreSQL (`postgres://`), runs ent auto-migrate, registers the owner guard and the sequence counter. Tests use `file::memory:?cache=shared`. +- **Code:** `store.Store` · `store.Open` · `store.IsPostgresDSN` +- **Spec:** [02-persistence.md §2–3](02-persistence.md) + +--- + +## Family & identity + +`internal/saas/{family,auth,authz,activity,server}`. Parents are accounts; +children are profiles with device tokens; every permission decision is in +`authz`. + +### Account +A parent identity, auto-provisioned on the first authenticated request from the JWT `sub` (no signup endpoint). Today keyed by `supabase_user_id`; **(proposed, specs/17)** provider-neutral `auth_provider` + `auth_subject`. +- **Code:** `ent/schema/account.go` · `family.Service.EnsureAccount` +- **Spec:** [12-saas.md §4.1](12-saas.md) · [17-auth-providers.md §3.3](17-auth-providers.md) + +### Activity timeline +Parent-facing read model over a child's event streams: expedition items (with plan category "why", quest attribution), mastery milestones, lessons; cursor-paged by global sequence; filterable by kind, date, quest. Writes nothing. +- **Code:** `activity.Reader.Timeline` · `activity.TimelineItem` · `GET /api/v1/children/{id}/activity` +- **Spec:** [12-saas.md "Activity timeline"](12-saas.md) + +### Archive (child) +Soft-delete of a child profile; revokes all its device tokens and hides it from quest targeting. Data stays owner-scoped under the UID. +- **Code:** `family.Service.UpdateChild` (`UpdateChildOpts.Archived`) · `family.ErrArchived` +- **Spec:** [docs/personas.md §1](../docs/personas.md) + +### Auth adapter **(proposed, specs/17)** +Frontend interface (`init / getToken / user / onChange / signOut / SignIn`) with one lazily-loaded file per provider (supabase, clerk, test); `api.ts` would take a token getter. Kid surfaces never load one. +- **Code:** `web/src/supa.ts` (today's Supabase client, to be wrapped) +- **Spec:** [17-auth-providers.md §5](17-auth-providers.md) + +### Auth preset / provider **(proposed, specs/17)** +One generic JWKS JWT verifier configured by a per-vendor preset (`supabase | clerk | test`): issuer, JWKS URL, audience or authorized parties, claim paths. One provider per deployment; switching is an explicit offline `mathiz auth remap`. Verified email becomes enforced. +- **Code:** `auth.SupabaseVerifier` (today's concrete verifier) +- **Spec:** [17-auth-providers.md §3](17-auth-providers.md) + +### Authz (Checker) +All permission decisions. Parent checks resolve the membership role in the space (`CanManageSpace/Child/Invite/Device/Quest`; owner-only `CanManageBilling/Parents/ParentInvite`); child checks (`CanLearnAs`, `CanPlayQuest`) pin a device to its own profile. Every denial is `ErrDenied`, which the API maps to **404**, never 403. +- **Code:** `authz.Checker` · `authz.ErrDenied` · `internal/saas/server/middleware.go` (`writeServiceError`) +- **Spec:** [12-saas.md §5, "Co-parents"](12-saas.md) + +### Boot config +Public `GET /api/v1/config` the SPA fetches at startup: Supabase URL + anon key, and PostHog key + `/relay` host only when analytics is configured. **(proposed, specs/17)** gains an `auth{provider,publicKey,issuer}` block. +- **Code:** `web/src/api.ts` (`BootConfig`) · `internal/saas/server/api.go` +- **Spec:** [12-saas.md §7](12-saas.md) · [16-analytics.md](16-analytics.md) · [17-auth-providers.md §3.6](17-auth-providers.md) + +### Child profile +A learner inside a family space: name, grade 2–5, optional bcrypt PIN hash, archived flag. Its UID **is** the learning-data `owner_id`. No email, no Supabase account, ever. +- **Code:** `ent/schema/child_profile.go` · `family.Service.AddChild` +- **Spec:** [12-saas.md §3.2](12-saas.md) +- **Aka / don't call it:** "child", "kid", "learner", "explorer" (dashboard copy). NOT a learner profile. + +### Child stats +Parent-facing per-child progress: mastery tally (mastered/learning/rusty from the latest snapshot), per-skill states, strand bars, recent sessions, gems, learner profile. Compact `summary` on child cards; full `stats` endpoint. +- **Code:** `internal/saas/server/stats.go` · `GET /api/v1/children/{id}/stats` +- **Spec:** [12-saas.md §7](12-saas.md) + +### Co-parent +A second parent account in the family with role `parent`: everything except billing and parent management. Joins by accepting a parent invite. Kid surfaces are role-agnostic. +- **Code:** `family.RoleParent` · `family.Service.AcceptInvite` +- **Spec:** [12-saas.md "Co-parents"](12-saas.md) + +### Cross-tenant denial +Any request touching an object outside the caller's family returns **404 "not found"** — never 403 — so existence isn't confirmed. Applies to quests on the map too (`ErrQuestUnavailable`). +- **Code:** `internal/saas/server/middleware.go` (`writeServiceError`) · `internal/saas/server/game_api.go` (`writeGameError`) +- **Spec:** [12-saas.md §5](12-saas.md) + +### Curriculum (public) +Unauthenticated, cached skill catalog — islands (strands) → skills (id, name, grade, prereqs, keywords) — for the marketing pages, the dashboard curriculum browser and quest skill tagging. +- **Code:** `internal/saas/server/curriculum_api.go` · `GET /api/v1/curriculum` · `web/src/api.ts` (`CurriculumInfo`) +- **Spec:** [12-saas.md §7](12-saas.md) + +### Device token +The child's bearer credential: `mzd_` + 32 random bytes, shown once, stored client-side (`localStorage` key `mathiz.deviceToken`), only its SHA-256 kept server-side; revocable, `last_used_at` tracked. Minted by redeeming a join code. +- **Code:** `ent/schema/device_token.go` · `family.TokenPrefix` · `family.Service.ResolveDeviceToken` · `web/src/api.ts` (`deviceToken`) +- **Spec:** [12-saas.md §3.2, §4.2](12-saas.md) + +### Family member / role +Membership row linking an account to one family space with role `owner | parent`. One family per account (v1). Owners get a member row lazily on `/me`. +- **Code:** `ent/schema/family_member.go` · `family.RoleOwner` / `RoleParent` · `family.Service.MemberRole` +- **Spec:** [12-saas.md "Co-parents"](12-saas.md) + +### Family space +The tenant: one per owning account, holds child profiles, join codes, quests, the credit ledger and billing state. Created free with 30 starter credits. +- **Code:** `ent/schema/family_space.go` · `family.Service.CreateSpace` +- **Spec:** [12-saas.md §3.2](12-saas.md) · [14-monetisation.md §1](14-monetisation.md) +- **Aka / don't call it:** "family", "space", `familyId` in JSON. + +### Identity (verified) +What the JWT verifier returns for a parent: Supabase user ID (`sub`), email, display name. Becomes an Account via `EnsureAccount`. **(proposed, specs/17)** gains `Provider`, `EmailVerified`. +- **Code:** `auth.Identity` · `auth.SupabaseVerifier.Verify` +- **Spec:** [12-saas.md §4.1](12-saas.md) · [17-auth-providers.md §3.1](17-auth-providers.md) + +### Join code (Invite) +Family-scoped, human-friendly code (`TIGER-4207`) a child enters to get a device token. Stored plaintext (low privilege), expires (default 7 days, parent-chosen up to 90), revocable. The ent type is `Invite`; the UI says "join code". +- **Code:** `ent/schema/invite.go` · `family.Service.CreateInvite` · `family.DefaultInviteTTL` / `MaxInviteTTL` +- **Spec:** [12-saas.md §3.2](12-saas.md) +- **Aka / don't call it:** NOT a parent invite — see Homonyms. + +### Join flow +Two unauthenticated calls: **preview** (code → space name + profiles with `has_pin`) then **redeem** (code + profile + PIN + device label → device token). Rate-limited per IP. +- **Code:** `family.Service.PreviewJoin` / `RedeemInvite` · `POST /api/v1/join/preview` · `POST /api/v1/join/redeem` +- **Spec:** [12-saas.md §4.2](12-saas.md) + +### Owner (family role) +The account that created the space (`owner_account_id`, role `owner`): the only member who can buy credits, manage billing and invite/remove parents; can never be removed. +- **Code:** `family.RoleOwner` · `authz.Checker.CanManageBilling` / `CanManageParents` +- **Spec:** [12-saas.md "Co-parents"](12-saas.md) + +### Parent (persona) +Hosted-mode adult user: authenticates via Supabase, runs the family space, reads progress, authors quests, buys credits (owner). The buyer, not the user, of credits. +- **Code:** `authz.KindParent` · `internal/saas/server/middleware.go` (`withParent`) +- **Spec:** [docs/personas.md §1](../docs/personas.md) + +### Parent invite +Owner-recorded email invitation for a co-parent (`pending | accepted | revoked`). No email is sent; the invitee signs in normally and `/me` surfaces a matching pending invite to accept. +- **Code:** `ent/schema/parent_invite.go` · `family.Service.InviteParent` · `family.ParentInvitePending` +- **Spec:** [12-saas.md "Co-parents"](12-saas.md) + +### PIN +Optional 4–6 digit code on a child profile, bcrypt-hashed, checked only at join-code redemption so siblings can't pick each other's profile. Not meant to resist adults; nudged (never forced) when a family has 2+ kids. +- **Code:** `ent/schema/child_profile.go` (`pin_hash`) · `family.ErrBadPIN` / `ErrPINMismatch` +- **Spec:** [12-saas.md §3.2](12-saas.md) + +### Principal +The verified caller handed to authz: `Kind` parent (with `AccountID`) or child (with `ChildProfileID` + `FamilySpaceID` from the device token). Built only by the auth middlewares. +- **Code:** `authz.Principal` · `authz.ChildPrincipal` +- **Spec:** [12-saas.md §5](12-saas.md) + +### Session detail +Per-expedition drill-down for parents: every answer in question order plus hint count. Not a timeline item — behind an expand. +- **Code:** `activity.Reader.SessionDetail` · `GET /api/v1/children/{id}/activity/sessions/{sessionId}` +- **Spec:** [12-saas.md "Activity timeline"](12-saas.md) + +### Supabase verifier +Local JWT verification for parent tokens: HS256 with the project JWT secret and/or RS/ES via the project JWKS (cached, refetched on unknown `kid`); checks exp/nbf/iat, issuer, audience `authenticated`; rejects `none`. The only vendor-specific auth code today. +- **Code:** `auth.SupabaseVerifier` · `auth.SupabaseConfig` · `auth.BearerToken` +- **Spec:** [12-saas.md §4.1](12-saas.md) + +--- + +## Game (treasure map) + +`internal/saas/game` + `web/src/{game.ts,pages/Play.tsx}`. The skill graph +rendered as islands; the engine's vocabulary is translated to the kid's in +`game/views.go` and nowhere else. + +### Captain +Kid-facing word with **two** meanings in `Play.tsx`: the child ("Captain {name}" in the header) **and** the quest-authoring parent ("⭐ The Captain left you a quest", "the Captain keeps the answer sealed"). Pick one deliberately when writing copy. +- **Code:** `web/src/pages/Play.tsx` +- **Spec:** [15-quests.md §5](15-quests.md) + +### Chest / treasure +A mastered skill: open chest, spot state `treasure`, label "Treasure secured!". Mastery mid-expedition = "Treasure chest opened!" plus fog lifting on dependents. +- **Code:** `game.SpotView.State` (`"treasure"`) · `web/src/pages/Play.tsx` (`SPOT_LABEL`) +- **Spec:** [13-treasure-map.md §2](13-treasure-map.md) + +### Clue +Kid-facing word for a **question** ("Next clue →") and, as "🗺️ Show me a clue", for a **hint**. Internally these are `Question` and `Hint`. +- **Code:** `web/src/pages/Play.tsx` +- **Spec:** [13-treasure-map.md §4](13-treasure-map.md) + +### Dig spot +A skill on the map. Tapping an unlocked spot starts an expedition. Carries name, description, grade, prerequisites, state, tier progress, review-due flag. +- **Code:** `game.SpotView` · `web/src/game.ts` (`Spot`) +- **Spec:** [13-treasure-map.md §2](13-treasure-map.md) + +### Expedition +The game's session: 5 AI-generated questions on one tapped skill (or up to 5 unsolved quest questions), one live per child, idle-expired after 30 min, replaced on a new start (an untouched same-skill start is reused, not re-charged). Costs 1 credit at start. Ends on 5 answers, mastery, or early exit. +- **Code:** `game.Manager.Start` · `game.QuestionsPerExpedition` · `game.ExpeditionView` +- **Spec:** [13-treasure-map.md §2 "Expeditions"](13-treasure-map.md) · [14-monetisation.md §3](14-monetisation.md) + +### Fog (locked) +A spot whose prerequisites aren't mastered: `locked`, label "Hidden in the fog". Starting one returns `ErrLocked` (409). +- **Code:** `game.ErrLocked` · `skillgraph.IsUnlocked` +- **Spec:** [13-treasure-map.md §2](13-treasure-map.md) + +### Game manager +Server-side owner of all live expeditions; wires the engine per child (toolset, mastery, scheduler, gems), enforces the play slot and the charge hook at `Start`, and translates results into kid-safe views and errors. +- **Code:** `game.Manager` · `game.Config` · `internal/saas/server/game_api.go` +- **Spec:** [13-treasure-map.md §3](13-treasure-map.md) + +### Gem (kid labels) +The web renames gem types: `mastery` → "Mastery gems 🏆", `streak` → "Streak gems 🔥", `session` → "Expedition gems ⛵", `recovery` → "Comeback gems 💪", `retention` → "Keeper gems 🛡️". IDs unchanged; use these labels on child surfaces. +- **Code:** `web/src/pages/Play.tsx` (`GEM_META`) · `game.GemsView` +- **Spec:** [13-treasure-map.md §4](13-treasure-map.md) + +### Guide 🧭 +The kid-facing persona of the micro-lesson ("The guide has a tip for you", "The guide is drawing a picture for you…"). Internally `lessons.Service`. +- **Code:** `web/src/pages/Play.tsx` · `game.Manager.Lesson` +- **Spec:** [13-treasure-map.md §4](13-treasure-map.md) + +### Guide's notebook +Every past micro-lesson, read back from lesson events (last 50), grouped by island client-side. +- **Code:** `game.Manager.Notebook` · `game.NotebookTipView` · `GET /api/v1/game/notebook` +- **Spec:** [13-treasure-map.md §3–4](13-treasure-map.md) + +### Island +A strand rendered on the map; ID = strand key, name = `StrandDisplayName`. Also the grouping word in the curriculum API and dashboard. +- **Code:** `game.IslandView` · `web/src/game.ts` (`Island`) +- **Spec:** [13-treasure-map.md §2](13-treasure-map.md) + +### Kid surface +`/join` and `/play` (and the game API): role-agnostic, device-token authed, **never** shows prices/balances/plans, never identifies the child in analytics (memory persistence, family group key only), never loads an auth adapter. +- **Code:** `internal/saas/server/middleware.go` (`withChild`) · `web/src/analytics.ts` (`AnalyticsSurface = 'child'`) +- **Spec:** [14-monetisation.md §1](14-monetisation.md) · [16-analytics.md](16-analytics.md) + +### Map (MapView) +Full map state for a child: islands → spots, gem counts, active quests. **Side-effect-free**: services are built with nil event repos so the decay check cannot write from a render. +- **Code:** `game.Manager.Map` · `game.MapView` · `GET /api/v1/game/map` +- **Spec:** [13-treasure-map.md §3](13-treasure-map.md) + +### Map reveal +After a mastery transition, the newly unlocked dependents returned on the answer (`unlockedSkillIds`) so the client can lift fog ("new paths revealed!"). +- **Code:** `game.AnswerResultView` (`UnlockedSkillIDs`) · `skillgraph.Dependents` +- **Spec:** [13-treasure-map.md §2](13-treasure-map.md) + +### Play slot +Cross-surface guarantee of ONE live session per child, enforced by a shared registry keyed by child UID; a second start returns `ErrElsewhere` ("you're already playing on another screen"). Released only after the final snapshot save. Any future play surface must acquire from the same registry. +- **Code:** `playslot.Registry.Acquire` · `playslot.ErrBusy` · `game.ErrElsewhere` +- **Spec:** [13-treasure-map.md §2](13-treasure-map.md) (CLAUDE.md invariant) +- **Aka / don't call it:** NOT a plan slot — see Homonyms. + +### Prove-tier countdown +Advisory shrinking bar on Prove-tier questions (`timeLimitSecs`); answers are always accepted — speed already feeds fluency server-side. +- **Code:** `game.QuestionView.TimeLimitSecs` · `web/src/pages/Play.tsx` +- **Spec:** [13-treasure-map.md §4](13-treasure-map.md) + +### Ship needs to rest +The only thing a kid sees at zero balance: "The ship needs to rest! ⛵ Ask your grown-up". Triggered by HTTP 402 `out_of_credits` from expedition start. No prices, plans or upgrade prompts on any child surface, ever. +- **Code:** `game.ErrNoCredits` · `internal/saas/server/game_api.go` (`writeGameError`) · `web/src/pages/Play.tsx` +- **Spec:** [14-monetisation.md §1 "Buyer ≠ user"](14-monetisation.md) + +### Sinking +Spot state for a mastered skill that is review-due or rusty: "Treasure sinking — rescue it!" (`reviewDue` flag). Pulls the kid back for spaced-rep review. +- **Code:** `game.SpotView` (`"sinking"`, `ReviewDue`) · `spacedrep.Scheduler.DueSkills` +- **Spec:** [13-treasure-map.md §2](13-treasure-map.md) + +### Spot state +Kid projection of skill state: `locked | ready | digging | proving | treasure | sinking` (fog / glowing X / learn ring / prove ring / open chest / sparkle). Mapped from mastery state + unlock + due in `map.go`; the SPA type mirrors it. +- **Code:** `game.SpotView.State` · `web/src/game.ts` (`SpotState`) +- **Spec:** [13-treasure-map.md §2](13-treasure-map.md) + +### Treasure map +The kid game as a whole (`/play`): hand-rolled SVG + CSS, no game engine. "The map is the pedagogy, visualized" — it replaced the browser terminal (termbridge) on 2026-07-19. +- **Code:** `web/src/pages/Play.tsx` · `internal/saas/game` +- **Spec:** [13-treasure-map.md](13-treasure-map.md) + +### Views (game) +JSON view types that are the single translation layer from engine vocabulary (tier, mastery state, category) to the game's (digging, treasure, sinking). Kid-facing views never carry monetisation data. +- **Code:** `internal/saas/game/views.go` +- **Spec:** [13-treasure-map.md §3](13-treasure-map.md) + +--- + +## Quests + +`internal/saas/quests` + `ent/schema/quest*.go` + `web/src/pages/dashboard/Quest*.tsx`. +Parent-authored one-off question sets played through the normal expedition. + +### Math-recompute warning +On manual save the answer is checked with `MathCheckValidator`; a mismatch returns a warning the parent must acknowledge (a wrong answer key poisons kid trust). +- **Code:** `quests.QuestionResult` · `quests.Service.AddQuestion` +- **Spec:** [15-quests.md §3](15-quests.md) + +### Quest +A family-scoped, parent-authored set of questions with name, optional emoji, optional skill tag, target child (`""` = all), status, `created_by`. A set, not a graph — no branching (v1 hard rule). Kid-facing: "a message in a bottle" / "⭐ The Captain left you a quest". +- **Code:** `ent/schema/quest.go` · `quests.Service` · `quests.QuestInput` +- **Spec:** [15-quests.md §2](15-quests.md) + +### Quest attribution +Quest expeditions denormalize `quest_uid` + as-of-play `quest_name` onto the session start event so the activity feed attributes them even after quest rename/deletion. Legacy fallback: untagged quests plan the synthetic skill ID `quest:`. +- **Code:** `store.SessionEventData.QuestUID/QuestName` · `internal/saas/activity/activity.go` (`questSkillPrefix`) +- **Spec:** [15-quests.md "Completion visibility & attribution"](15-quests.md) + +### Quest expedition +An expedition over up to 5 not-yet-solved quest questions in authored order, chunked until the quest is exhausted; same gems/streaks/hints/lesson/1-credit charge/play slot. Tagged quests advance mastery; untagged run with nil mastery/spaced-rep repos. +- **Code:** `game.Manager.StartQuest` · `game.QuestPlay` · `POST /api/v1/game/quests/{id}/expeditions` +- **Spec:** [15-quests.md §2 "Play"](15-quests.md) + +### Quest generation (AI) +Parent brief + count (≤ 20) → LLM batch through problemgen-style validation → **draft** questions for review. Idempotent via a client key (retry returns the saved batch); debits ⌈count/5⌉ credits with source `questgen::` only after success; 402 on empty wallet. Purpose `quest-gen`, logged to the family LLM stream. +- **Code:** `quests.Service.Generate` · `quests.MaxGenerateCount` · `quests.GenerateCostDivisor` · `llm.PurposeQuestGen` +- **Spec:** [15-quests.md §3](15-quests.md) + +### Quest map item +The kid's quest card: id, name, emoji, total, correct, done. Completed quests collapse client-side into one "🏆 N quests completed" trophy row; archiving removes them from the payload. +- **Code:** `game.QuestMapItem` · `quests.Service.ActiveQuests` · `web/src/pages/Play.tsx` (`QuestTrophies`) +- **Spec:** [15-quests.md §5](15-quests.md) + +### Quest progress +Control-plane rows (quest, child, question, correct) — deliberately **not** event-sourced to avoid widening `EventRepo`. Backs per-child completion chips (`✓ done / 2-of-5 / not started`) and which questions remain. +- **Code:** `ent/schema/quest_progress.go` · `quests.Service.RecordAnswer` / `ProgressFor` +- **Spec:** [15-quests.md §2 "Progress"](15-quests.md) + +### Quest question +A fixed, parent-authored (or AI-drafted) question: position, text, answer, answer type, format, choices, hint, explanation, `client_key`. Not shuffled. Repeats until answered correctly. +- **Code:** `ent/schema/quest_question.go` · `quests.QuestionInput` · `game.QuestPlayQuestion` +- **Spec:** [15-quests.md §2](15-quests.md) + +### Quest status +`draft | active | archived`. Publish (needs ≥ 1 question) flips draft → active. Dashboard labels: Draft / **"On the map"** / Archived. +- **Code:** `quests.StatusDraft` / `StatusActive` / `StatusArchived` · `web/src/pages/dashboard/questStatus.ts` +- **Spec:** [15-quests.md §2](15-quests.md) + +### Sealed answer +On a **wrong** quest answer the response omits `correctAnswer`/`explanation` (the question will gate again); the client shows "the Captain keeps the answer sealed". Hints, diagnosis and the lesson still fire. Correct answers reveal the explanation. Map digs are unaffected. +- **Code:** `game.Manager.Answer` · `web/src/game.ts` (`AnswerResult` comment) +- **Spec:** [15-quests.md "Sealed answers"](15-quests.md) + +### Tagged / untagged quest +`skill_id` set → answers feed mastery and spaced-rep for that skill (pushes the main map). Empty → engine runs with side-effect-free mastery/spaced-rep services; answer/session events still persist. Dashboard: "Untagged" = standalone practice. +- **Code:** `ent/schema/quest.go` (`skill_id`) · `game.QuestPlay.SkillID` +- **Spec:** [15-quests.md §2 "Mastery feed"](15-quests.md) + +--- + +## Money + +`internal/saas/{credits,billing}`. Our ledger is the entitlement source of +truth; the payment provider is a money pipe. + +### Balance +Σ `remaining` of unexpired grants for a family space. Expiry is lazy (expired remainders are ignored, no background job). +- **Code:** `credits.Service.Balance` +- **Spec:** [14-monetisation.md §2](14-monetisation.md) + +### Billing event (normalized) +The entire provider→ledger surface: `subscription_activated`, `subscription_renewed`, `subscription_canceled`, `topup_purchased`, each with a stable `EventID` that becomes the ledger source. Applied by `billing.Service.Apply`. +- **Code:** `billing.EventType` · `billing.Event` · `billing.Service.Apply` +- **Spec:** [14-monetisation.md §4](14-monetisation.md) + +### Billing provider +Three-method payment adapter: `CreateCheckout`, `PortalURL`, `ParseWebhook` (signature-verified → normalized events). Shipped: `fake`, `stripe`. Do not widen; no metered billing, no Stripe credit grants. +- **Code:** `billing.Provider` · `billing.FakeProvider` · `billing.StripeProvider` +- **Spec:** [14-monetisation.md §4](14-monetisation.md) +- **Aka / don't call it:** NOT an LLM provider or auth provider — see Homonyms. + +### Billing state +One row per family space mirroring the provider: provider, customer ID, subscription ID, plan ID, status (`none | active | past_due | canceled`), current period end. Written only from webhook events; read by the dashboard. +- **Code:** `ent/schema/billing_state.go` · `billing.Service.State` +- **Spec:** [14-monetisation.md §4](14-monetisation.md) + +### Charge hook +Nil-able function the game manager calls at `Start` with the session ID; nil = free (local CLI, billing off, tests). `mathiz serve` wires it to `credits.Debit(…, 1, "session:"+sessionID)` only when a billing provider is configured. The **only** enforcement point. +- **Code:** `game.Config.Charge` · `cmd/serve.go` +- **Spec:** [14-monetisation.md §3](14-monetisation.md) + +### Checkout +Provider-hosted purchase page for a plan (subscription) or top-up (one-time); success returns to `/dashboard/billing?billing=success`. Owner-only. +- **Code:** `billing.CheckoutParams` · `POST /api/v1/family/{id}/billing/checkout` +- **Spec:** [14-monetisation.md §4–5](14-monetisation.md) + +### Credit +The unit parents buy and kids spend: **1 credit = 1 expedition** (5 questions, hints and lesson included). Quest AI generation costs ⌈count/5⌉. Parent UI calls them "expeditions". +- **Code:** `internal/saas/credits` · `billing.Plans` (doc comment) +- **Spec:** [14-monetisation.md §1](14-monetisation.md) + +### Credit entry (ledger) +Event-sourced row on the family space: `kind` (`starter | plan | topup | debit`), signed `amount`, `remaining` (grants), unique `source`, optional `expires_at`. The ledger, not the provider, is the entitlement truth. +- **Code:** `ent/schema/credit_entry.go` · `credits.Service.Grant` +- **Spec:** [14-monetisation.md §2](14-monetisation.md) + +### Debit +Negative entry that consumes grants FIFO by soonest expiry inside one transaction; insufficient balance writes nothing (`ErrInsufficient` → 402). +- **Code:** `credits.Service.Debit` · `credits.ErrInsufficient` +- **Spec:** [14-monetisation.md §2](14-monetisation.md) + +### Expedition wallet +Parent-dashboard name for the balance and plan tiles ("⛵ 42 expeditions left"); shows a beta card when billing is off. Owner-only route. +- **Code:** `web/src/pages/dashboard/Billing.tsx` · `GET /api/v1/family/{id}/billing` +- **Spec:** [14-monetisation.md §5](14-monetisation.md) + +### Fake provider +Dev/test billing provider: checkout "succeeds" immediately via `GET /api/v1/billing/fake/complete`, so the purchase→grant→play loop is clickable with no external service. +- **Code:** `billing.FakeProvider` +- **Spec:** [14-monetisation.md §4](14-monetisation.md) + +### Grant +Positive ledger entry of kind `starter`, `plan` or `topup`. Plan grants are expired and re-granted on renewal (`RenewPlanCredits`); top-ups never expire. +- **Code:** `credits.KindStarter` / `KindPlan` / `KindTopup` · `credits.Service.RenewPlanCredits` +- **Spec:** [14-monetisation.md §1–2](14-monetisation.md) + +### Out of credits +HTTP **402** with body `out_of_credits` from expedition start or quest generation. Kid surface renders "the ship needs to rest"; parent surface renders a wallet prompt. +- **Code:** `game.ErrNoCredits` · `internal/saas/server/game_api.go` · `internal/saas/server/quests_api.go` +- **Spec:** [14-monetisation.md §3](14-monetisation.md) + +### Plan (billing) +A catalog item: monthly subscription (`explorer` 150, `voyager` 400, `armada` 1000 credits/mo) or one-time top-up (`topup-100`). Catalog lives in code; provider price IDs come from env. +- **Code:** `billing.Plan` · `billing.Plans` · `billing.PlanByID` +- **Spec:** [14-monetisation.md §1 "Default catalog"](14-monetisation.md) +- **Aka / don't call it:** NOT a session plan — see Homonyms. + +### Portal +Provider-hosted self-service page (cancel, change plan, update card). Owner-only. +- **Code:** `billing.Provider.PortalURL` · `POST /api/v1/family/{id}/billing/portal` +- **Spec:** [14-monetisation.md §4](14-monetisation.md) + +### Pricing (public) +Unauthenticated catalog endpoint served even with billing off: `billingEnabled`, starter credits, plans. Drives all "public beta — everything is free" messaging in the SPA. +- **Code:** `GET /api/v1/pricing` · `web/src/pages/Pricing.tsx` +- **Spec:** [14-monetisation.md §5 "Public pricing page"](14-monetisation.md) + +### Source (idempotency key) +Unique string on every ledger entry making replays structural no-ops: `starter:`, `sub:`, `topup:`, `session:`, `questgen::`. Never a fresh UUID. The `ent/schema/credit_entry.go` comment still says `expedition:`; code uses `session:`. +- **Code:** `ent/schema/credit_entry.go` (`source`) · `cmd/serve.go` · `internal/saas/quests/generate.go` +- **Spec:** [14-monetisation.md §2](14-monetisation.md) + +### Starter credits +30 free credits, expiring in 30 days, granted exactly once per family space (`starter:`). +- **Code:** `credits.StarterCredits` · `credits.StarterExpiry` · `credits.Service.EnsureStarterGrant` +- **Spec:** [14-monetisation.md §1](14-monetisation.md) + +### Stripe provider +Real payments: Checkout Sessions (`subscription` / `payment`), hosted Customer Portal, signature-verified webhooks. Subscription credits flow only from `invoice.paid`; `checkout.session.completed` maps only top-ups. +- **Code:** `billing.StripeProvider` · `internal/saas/billing/stripe.go` +- **Spec:** [14-monetisation.md §4 "Provider decision"](14-monetisation.md) + +### Top-up +One-time credit pack (`topup-100`); credits never expire. Realizes the "usage beyond plan" leg as prepaid, never metered. +- **Code:** `billing.Plan.TopupCredits` · `billing.EventTopupPurchased` +- **Spec:** [14-monetisation.md §1, §4](14-monetisation.md) + +### Webhook +`POST /api/v1/billing/webhook`: provider → `ParseWebhook` → normalized events → ledger + billing state. Replays are no-ops via the source column. +- **Code:** `internal/saas/server/billing_api.go` · `billing.Service.Apply` +- **Spec:** [14-monetisation.md §5](14-monetisation.md) + +--- + +## Platform/ops + +Wiring, surfaces, logging, analytics, commands. + +### Analytics (PostHog) +Operator opt-in product analytics. Parents are identified by account UID; **children are never identified** (memory persistence, family group key only). The browser talks only to the same-origin `/relay` reverse proxy. `web/src/analytics.ts` is the only file that may import `posthog-js` or name an event. +- **Code:** `web/src/analytics.ts` · `internal/saas/server/posthog_proxy.go` +- **Spec:** [16-analytics.md](16-analytics.md) + +### Analytics surface +`public | parent | child` — picks PostHog options and identity rules per route group. Child = no identify, no pageviews, memory persistence. +- **Code:** `web/src/analytics.ts` (`AnalyticsSurface`) +- **Spec:** [16-analytics.md "Surface configs"](16-analytics.md) + +### App options (CLI wiring) +The TUI's dependency graph: provider, repos, generator, diagnosis, lessons, compressor, gems, update channel. A new session dependency must reach both this and `game.Config`. +- **Code:** `app.Options` · `internal/app/app.go` · `cmd/run.go` +- **Spec:** [01-skeleton.md §4](01-skeleton.md) + +### Canonical log line +ONE structured slog line per HTTP request (method, route pattern, status, dur_ms, bytes, ip, plus everything added to the request's `logctx` bag: principal/account/child UIDs, `err`, `err_detail`, panic). 5xx→Error, 4xx→Warn, API→Info, assets→Debug. Standalone slog records are for background goroutines only. +- **Code:** `internal/saas/server/reqlog.go` · `logctx.Add` / `logctx.Attrs` +- **Spec:** [12-saas.md §8 "Canonical request logging"](12-saas.md) + +### Deployment modes +**Local CLI** (`mathiz`, `mathiz play`): one learner, SQLite, Bubble Tea TUI, owner `""`. **Hosted / serve** (`mathiz serve`): multi-tenant PostgreSQL, Supabase parents, device-token kids, embedded SPA. Same engine, different presentation. +- **Code:** `cmd/run.go` · `cmd/serve.go` +- **Spec:** [docs/personas.md](../docs/personas.md) · [12-saas.md §1](12-saas.md) + +### LLM usage auditing +`mathiz llm list|view|stats` over LLM request events: per-purpose/model token usage and estimated cost. Serve mode logs per child (and per family for quest-gen). +- **Code:** `cmd/llm.go` · `store.EventRepo.LLMUsageByPurpose` / `LLMUsageByModel` +- **Spec:** [docs/llm-usage-auditing.md](../docs/llm-usage-auditing.md) + +### Operator (persona) +Whoever runs `mathiz serve`: configures DB, auth, LLM, billing, analytics, logging via `MATHIZ_*` env. Billing off = everything free. +- **Code:** `server.Config` · `server.ConfigFromEnv` +- **Spec:** [docs/personas.md §4](../docs/personas.md) + +### Persona +The four documented user types — Parent, Child, Solo learner (CLI), Operator — and their supported flows. `docs/personas.md` is the source of truth for user-facing flows and must be updated with them. +- **Spec:** [docs/personas.md](../docs/personas.md) + +### Router / Screen (TUI) +Bubble Tea v2 screen stack: `Screen` (Init/Update/View(width,height)/Title) pushed/popped/replaced via router messages. Screens: Welcome, Home, Session, Session Summary, Skill Map (+ detail), Gem Vault, History. Measure-then-render layout. +- **Code:** `screen.Screen` · `router.Router` · `internal/screens/` +- **Spec:** [01-skeleton.md §5](01-skeleton.md) + +### Self-update +`mathiz update` and the async version check surfaced on the Home screen. +- **Code:** `internal/selfupdate` · `cmd/update.go` · `app.Options.UpdateCh` +- **Spec:** [docs/personas.md §3](../docs/personas.md) + +### Serve (server) +`mathiz serve` wiring: `server.Deps` (store, family, verifier, game manager, credits, billing, quests, activity, web UI) → `server.Server` → `/api/v1/*` routes with `withParent` / `withChild` middlewares. Fails fast on missing DB/auth config. +- **Code:** `server.Deps` · `internal/saas/server/server.go` · `cmd/serve.go` +- **Spec:** [12-saas.md §2, §7](12-saas.md) + +### Skill Map (TUI) +Terminal screen listing skills by strand with skill-state icons and review status; the CLI counterpart of the treasure map. +- **Code:** `internal/screens/skillmap` +- **Spec:** [03-skill-graph.md "Skill Map Screen"](03-skill-graph.md) + +### Solo learner (persona) +Local CLI user with an LLM key: `mathiz`, `play`, `stats`, `llm`, `preview`, `skill`, `reset`, `update`; multiple learners via `--db`. +- **Code:** `cmd/` +- **Spec:** [docs/personas.md §3](../docs/personas.md) + +### SSH server / termbridge (historical) +Two superseded kid transports: the Wish/SSH design (`ssh-server.md`, never the SaaS path) and the in-process browser terminal (`termbridge`, removed 2026-07-19). Design record only; do not revive. +- **Spec:** [ssh-server.md](ssh-server.md) · [12-saas.md §6](12-saas.md) + +### Web UI (embedded SPA) +Vite + React in `web/`, built into `internal/saas/webui/dist` and served by the Go binary via `embed.FS`; fetches boot config at runtime so one build runs anywhere. Routes: `/`, `/pricing`, `/how-it-works`, `/demo`, `/legal`, `/login`, `/dashboard/*`, `/join`, `/play`. +- **Code:** `webui.Handler` · `web/src/App.tsx` +- **Spec:** [12-saas.md §9](12-saas.md) + +--- + +## Homonyms & look-alikes + +- **Plan** — *session plan* (`session.Plan`, the slots a session cycles) vs *billing plan* (`billing.Plan`, a purchasable subscription/top-up) vs *plan grant* (`credits.KindPlan`, credits from a subscription). Say "session plan" or "billing plan". +- **Session** — a *learning session* (`session.*`, one UUID, start/end events; the game calls it an expedition) vs a *Supabase/browser auth session* (web only) vs *session gem* (`gems.GemSession`, awarded for completing one). +- **Slot** — *plan slot* (`session.PlanSlot`, a skill+tier in a plan) vs *play slot* (`playslot.Registry`, the one-live-session-per-child lock). +- **Invite** — the ent `Invite` is a kid **join code**; a `ParentInvite` is a co-parent email invitation. UI never says "invite" for join codes. +- **Tier** — *difficulty tier* (`skillgraph.Tier`: learn/prove) vs *gem rarity* (`gems.Rarity`) vs *billing plan* (never called a tier in code). +- **Provider** — *LLM provider* (`llm.Provider`) vs *billing provider* (`billing.Provider`) vs *auth provider* (proposed, specs/17) vs `quests.ProviderFactory` (an LLM provider per family). +- **Token** — *device token* (`mzd_…`, kid credential) vs *Supabase JWT / access token* (parent) vs *LLM tokens* (`llm.Usage`). +- **Profile** — *child profile* (`ent.ChildProfile`, the learner identity) vs *learner profile* (`lessons.LearnerProfile`, the LLM's summary of the learner) vs LLM purpose `profile`. +- **Owner** — *owner_id* (data-plane tenant key = child UID / `""` / `space:`) vs *family owner* (`family.RoleOwner`, the paying parent) vs `FamilySpace.owner_account_id`. +- **Streak** — *in-session streak* (`SessionState.ConsecutiveCorrect`, streak gems) vs *per-skill fluency streak* (`FluencyMetrics.Streak`, consistency score). +- **State** — *mastery state* (`mastery.MasteryState`: new/learning/mastered/rusty) vs *skill display state* (`skillgraph.SkillState`: locked/available/learning/proving/mastered/rusty) vs *spot state* (`"digging"`, `"treasure"`, …) vs *billing state* (`ent.BillingState`) vs *session state* (`session.SessionState`). +- **Category** — *plan category* (`session.PlanCategory`: frontier/review/booster) vs *error category* (`diagnosis.ErrorCategory`: careless/speed-rush/misconception/unclassified). +- **Question** — *generated question* (`problemgen.Question`, never static) vs *quest question* (`ent.QuestQuestion`, fixed, parent-authored, not shuffled) vs *practice question* (`lessons.PracticeQuestion`, inside a lesson). Kid word for all three: "clue". +- **Review** — *spaced-rep review* (an answer feeding `RecordReview`; plan category `review`) vs *parent review of AI-drafted quest questions* (draft → publish). +- **Explorer** — the `explorer` billing plan vs dashboard copy calling a child an "explorer" ("more than one explorer aboard"). Avoid the second use in new copy. +- **Captain** — the child (header) *and* the quest-authoring parent (quest card, sealed-answer copy). See Game → Captain. +- **Guide** — the micro-lesson persona (🧭) only; not the hint, not the AI tutor generally. +- **Expedition** — a game session (1 credit) vs the parent-UI unit of credits ("42 expeditions left") vs `session` gem's kid label "Expedition gems". +- **Diagnosis vs diagnostic** — *diagnosis* classifies a wrong answer (`internal/diagnosis`); *diagnostic placement* is the unbuilt entry quiz (`skillgraph.DiagnosticResult`). +- **Compression** — *session compression* (error context → summary) vs *profile generation*; both live on `lessons.Compressor` with different purposes (`session-compress`, `profile`). +- **Snapshot** — the learner-state `Snapshot` row vs "snapshot" used loosely for the pure-Go `SnapshotData` struct; there is no other snapshot concept (no DB snapshot). +- **Family** — the `FamilySpace` entity; `familyId` in JSON; the PostHog `family` group. One concept, three spellings. +- **Curriculum / skill graph / map / islands** — the same 56-skill DAG seen from the public API, the Go package, the kid, and the dashboard respectively.