Living Word Classrooms — Phase D
Canonical spec. Unblocks UGC Phase 1B (school-pool visibility) for
living-word-ugc-phase1.md. Built against three founder-locked decisions (D1/D2/D3 below). Default state: a teacher on the educator early-list creates a classroom, generates a 6-character join code, students enter the code + their first name to join — no email, no password, no PII from minors. The classroom is the ownership boundary for school-pool quizzes (built in UGC Phase 1B).
§1. Founder-locked decisions (verbatim, 2026-05-24)
| # | Decision | Answer | Rationale |
|---|---|---|---|
| D1 | How are classroom rosters populated? | Teacher-invite via 6-char join code. Teacher creates classroom, system generates code, students enter code in-game. | Industry standard (Kahoot, Quizizz, Pear Deck); zero account-creation friction for kids; no SSO contract needed for Phase 1. |
| D2 | Student account model | Anonymous nickname only. Kid enters code + first name. No email, no password, no last name. Progress is per-device (cookie-based). | COPPA-safest (zero PII from minors). Industry standard for K-12 game UX. |
| D3 | Teacher pricing during Phase 1A/1B | Free for lw_educator_early_list members. Cap 5 classrooms × 150 students per teacher. Phase 3 introduces paid school tiers ($79 / $299 / $999 per year). | Pre-monetization validation; word-of-mouth among teachers; the funnel data tells us when to gate. |
Engineering MUST NOT re-litigate any of the three.
§2. Scope
This spec ships:
lw_classroomstable with classroom ownership, join code, status, caps, AI Mode default (see §6).lw_classroom_studentstable with anonymous nicknames, device-id binding, per-device progress link.lw_classroom_invites(logical view or materialized table) for join-code lookup with rate-limited validation.- Educator UX at
/living-word/educator/classrooms/*(list, new, manage, archive). - Student join UX inside the in-game
AuthBar(or a sibling component) — code entry + nickname. - Teacher API at
/api/living-word/classrooms/*(RBAC: only educator early-list owner can mutate their classroom). - Student API at
/api/living-word/classrooms/join(rate-limited; no auth; validates code + nickname). - Free-tier cap enforcement in API (5 classrooms / 150 students per teacher).
Out of scope (build later):
- Google Classroom / Canvas SSO — Phase D-2 if demand emerges. Not for Phase 1.
- CSV roster upload — Phase D-2 if demand emerges.
- Student cross-device progress — explicitly out (D2 anonymous model). Progress is per-device, per-cookie.
- Phase 3 paid tiers + Stripe — separate spec, gated on Phase 1 funnel data.
- Classroom-scoped Scribe AI toggle — defined in
living-word-ai-mode-toggle.md; this spec exposes the column, the toggle UX lives there.
§3. lw_classrooms table
CREATE TABLE lw_classrooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
grade_band TEXT, -- free text: "Grade 5", "Middle School", "Adult Sunday School"
tradition_lens_id TEXT, -- nullable; if set, restricts in-classroom quiz vocabulary checks
join_code TEXT NOT NULL UNIQUE, -- 6 chars, see §4
ai_mode_default TEXT NOT NULL DEFAULT 'off', -- 'off' | 'on'; see living-word-ai-mode-toggle.md
ai_mode_teacher_override BOOLEAN NOT NULL DEFAULT false, -- if true, students cannot flip AI on regardless of their personal setting
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'archived'
student_cap INT NOT NULL DEFAULT 150,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
archived_at TIMESTAMPTZ
);
CREATE INDEX lw_classrooms_owner_user_id_idx ON lw_classrooms(owner_user_id) WHERE status = 'active';
CREATE UNIQUE INDEX lw_classrooms_join_code_active_uidx ON lw_classrooms(join_code) WHERE status = 'active';
RLS:
- Service role only for inserts/updates from teacher API.
- Educator can SELECT/UPDATE their own (
owner_user_id = auth.uid()). - Anon: NO direct read; classroom resolved server-side only via join code → student session.
Free-tier cap (D3): API enforces COUNT(active classrooms WHERE owner = me) <= 5. Returns 402 with { error: 'classroom_limit_reached', limit: 5, current: <n>, upgrade_path: 'phase-3' }.
§4. Join code — format + lifecycle
- Format: 6 characters, uppercase alphanumeric, excluding ambiguous glyphs (
0,O,1,I,L). Charset =23456789ABCDEFGHJKMNPQRSTUVWXYZ(32 chars). 32^6 ≈ 1.07 billion combinations. - Generation: server-side. Random selection from charset. Retry up to 5 times if collision with existing active classroom code (UNIQUE constraint). After 5 retries → 500 with telemetry (means we're saturating; bump to 7 chars).
- Lifecycle: code is set on classroom creation, never auto-rotates. Teacher can manually rotate via
POST /api/living-word/classrooms/[id]/rotate-code— invalidates old code immediately, generates new one. Existing student sessions are preserved (they keep theirlw_classroom_studentsrows; new students must use the new code). - Reusable: code is valid for the classroom's entire active life. Multiple students can join with the same code (this is the point — it's a classroom).
- Archived classroom: join code is freed (UNIQUE-active index ignores archived). Students retain access to their progress, but new students cannot join.
§5. lw_classroom_students table
CREATE TABLE lw_classroom_students (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
classroom_id UUID NOT NULL REFERENCES lw_classrooms(id) ON DELETE CASCADE,
nickname TEXT NOT NULL, -- first name OR nickname. NO last name. NO email.
device_id TEXT NOT NULL, -- opaque cookie value, set on first join
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_played_at TIMESTAMPTZ,
ai_mode_choice TEXT DEFAULT NULL -- 'off' | 'on' | NULL. NULL = inherit classroom default.
);
CREATE INDEX lw_classroom_students_classroom_idx ON lw_classroom_students(classroom_id);
CREATE UNIQUE INDEX lw_classroom_students_device_classroom_uidx ON lw_classroom_students(device_id, classroom_id);
Constraints:
nicknamelength 1..30 chars. Profanity filter via existingscripts/living-word/audit-ai-bridge.mjspatterns. Reject; return error.device_idis set via HttpOnly cookielw_device_id(UUID v4, 1-year expiry, set on first join, never overwritten). If cookie is missing, server generates one in response.- One device can join multiple classrooms (per-device, per-classroom row).
- NO PII fields. No
parent_email, noage, nolast_name. Schema enforces this by absence.
§6. AI Mode default + teacher override
ai_mode_default('off' default) — when a NEW student joins, theirai_mode_choicestarts as NULL and the effective mode isai_mode_default.ai_mode_teacher_override(defaultfalse) — whentrue, the student CANNOT flip AI on in their personal settings even ifai_mode_default = 'off'. Used by teachers in anti-AI school contexts who want to ensure no student turns AI on regardless of their family's stance.- Reverse case (
ai_mode_default = 'on'+ai_mode_teacher_override = true) is technically valid but actively discouraged in UX copy ("Most teachers leave AI off by default and let students opt in"). The teacher form should show a warning when this combination is selected. - Full spec for what AI Mode controls lives in
acceptance/living-word-ai-mode-toggle.md.
§7. Educator UX — /living-word/educator/classrooms/*
/educator/classrooms (list)
Gated to authenticated users present on lw_educator_early_list (resolved via auth.users.email membership). Non-members see a CTA → lw_educator_early_list form.
- Heading: "Your Classrooms" + button "+ New Classroom" (disabled with tooltip "Free during beta — limit 5; you have 5/5" when at cap).
- List rows: classroom name, grade band, student count
<n>/150, join code (read-only, copyable), AI Mode badge ("AI off (default)" / "AI on" / "AI off — students locked"), Manage / Archive buttons. - Empty state: "No classrooms yet. Create one to get started." + Create button.
/educator/classrooms/new
Form:
- Name (required, 3..60 chars)
- Grade band (optional dropdown: "Grade 1–3", "Grade 4–6", "Middle School", "High School", "Adult Sunday School", "Other / mixed")
- Tradition lens (optional dropdown of 17 traditions; defaults to "(any tradition — students pick)")
- AI Mode default (radio: "Off (recommended for K-8)" / "On") — default Off
- Lock AI Mode (checkbox: "Prevent students from changing this setting") — default unchecked
- Submit → POST
/api/living-word/classrooms→ on success redirect to/educator/classrooms/[id]
/educator/classrooms/[id] (manage)
Sections:
- Join code: big, copyable. Button "Rotate code" with confirmation dialog ("Old code stops working immediately. New code is shown right after.").
- Student list: nickname, joined date, last played, AI Mode (inherited / explicit), remove button (removes their row + their per-classroom progress only; their device-based personal progress in non-classroom scenes is untouched).
- Settings (re-shows the new-classroom form, prefilled): name, grade band, tradition lens, AI Mode default, AI Mode lock. Update button.
- Archive button (bottom, separate section, danger-styled): "Archive classroom. Students lose access to school-pool quizzes; their personal progress is kept. You can restore from your archived list within 90 days; after that the classroom is permanently deleted."
§8. Student join UX — inside AuthBar (or sibling)
Append a "Join a classroom" affordance to the existing AuthBar.tsx (or build sibling ClassroomJoinBar.tsx):
- Player clicks "Join classroom" → inline form opens
- Two fields: join code (6 chars, auto-uppercase, charset-validated client-side), first name (1..30 chars)
- Submit → POST
/api/living-word/classrooms/joinwith{ code, nickname }+ thelw_device_idcookie (auto-included) - Server validates code (active classroom), validates nickname (profanity), checks
lw_classroom_studentsrow doesn't already exist for this(device_id, classroom_id)(idempotent), inserts row, returns{ classroom: { id, name, ai_mode_default, ai_mode_teacher_override }, student: { id, nickname } } - Game UI shows "Joined [Classroom Name]. Mrs. Smith's quizzes are now in your pool." (or similar)
- On error (invalid code, profanity, cap reached, rate-limited) → inline error message; field stays filled.
Rate limiting on /join:
- Per
lw_device_id: 5 failed attempts per 10-min window → 429 withretry_after. Successful attempts don't count. - Per IP: 30 attempts per 10-min window across all device_ids → 429.
§9. Telemetry
Use existing lw_lead_gen_events (Phase 1A) PLUS one new lightweight event table for classroom flows:
CREATE TABLE lw_classroom_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
classroom_id UUID REFERENCES lw_classrooms(id) ON DELETE SET NULL,
device_id TEXT,
event_type TEXT NOT NULL CHECK (event_type IN (
'classroom_created',
'classroom_archived',
'join_code_rotated',
'student_joined',
'student_removed_by_teacher',
'join_attempt_failed_invalid_code',
'join_attempt_failed_profanity',
'join_attempt_failed_rate_limit',
'join_attempt_failed_cap_reached'
)),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX lw_classroom_events_classroom_idx ON lw_classroom_events(classroom_id, event_type, created_at DESC);
CREATE INDEX lw_classroom_events_type_idx ON lw_classroom_events(event_type, created_at DESC);
No PII in metadata — never store nickname, never store IP.
§10. AI Bridge + safety rails
- Educator UX never NPC-voices an AI recommendation. Marketing copy is authored-by-the-builders ("We built this so…").
- Student join UX never collects email, age, last name, or any other PII. The COPPA safety property is structural.
- Profanity filter on nicknames uses the existing
audit-ai-bridge.mjspattern catalog. Tests ate2e/living-word/classroom-join-profanity.spec.tscover the 20 most-likely school-context attempts. - Teacher cannot see student IP, student device fingerprint, student real identity. Only nickname + last-played + classroom progress.
§11. Definition of done
- Migration
migrations/2026-05-24-lw-classrooms.sqlapplied to prod Supabase via MCP. Verified withSELECT column_name FROM information_schema.columns WHERE table_name IN ('lw_classrooms', 'lw_classroom_students', 'lw_classroom_events'). - All API routes ship with rate-limit middleware + RBAC enforcement + cap checks.
- Educator UX gated to
lw_educator_early_listmembership; non-members redirected to early-list signup. - Student join UX inside
AuthBar(or sibling); device cookie set on first join; nickname profanity rejection works. - Playwright spec at
e2e/living-word/classrooms.spec.ts:- Teacher (early-list member) creates classroom → join code shown
- Cap enforcement: 6th classroom returns 402 with
classroom_limit_reached - Student joins with valid code →
lw_classroom_studentsrow appears → game UI confirms - Invalid code → inline error, no row written,
lw_classroom_events.join_attempt_failed_invalid_coderow appears - Profanity nickname → inline error, no row, profanity-failed event row
- Rate limit: 5 invalid attempts → 429
- Teacher rotates code → old code 404s, new code works
- Teacher archives classroom → students retain progress but new joins fail
- AI Bridge audit (
scripts/living-word/audit-ai-bridge.mjs) passes on all new copy. - Knowledge doc updated:
knowledge/products/living-word/classrooms.md(extends Phase 1A docs). - PR opens against
main: titlefeat(living-word): Phase D — classrooms + join codes, includes Playwright artifact + migration verification.
§12. Sequencing
- This spec ships BEFORE UGC Phase 1B (school-pool visibility) — it's the schema + ownership boundary 1B depends on.
- This spec ships INDEPENDENTLY of
living-word-ai-mode-toggle.md(Phase F). The two columnsai_mode_defaultandai_mode_teacher_overrideexist onlw_classroomseven if the toggle UX hasn't shipped yet — Phase F just adds the player-facing setting and the runtime switch. - This spec assumes Phase 1A UGC (educator-authored quizzes, private visibility) has shipped or is shipping in parallel. The
educatorroute group already exists by then.