Skip to main content

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)

#DecisionAnswerRationale
D1How 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.
D2Student account modelAnonymous 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.
D3Teacher pricing during Phase 1A/1BFree 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_classrooms table with classroom ownership, join code, status, caps, AI Mode default (see §6).
  • lw_classroom_students table 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 their lw_classroom_students rows; 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:

  • nickname length 1..30 chars. Profanity filter via existing scripts/living-word/audit-ai-bridge.mjs patterns. Reject; return error.
  • device_id is set via HttpOnly cookie lw_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, no age, no last_name. Schema enforces this by absence.

§6. AI Mode default + teacher override

  • ai_mode_default ('off' default) — when a NEW student joins, their ai_mode_choice starts as NULL and the effective mode is ai_mode_default.
  • ai_mode_teacher_override (default false) — when true, the student CANNOT flip AI on in their personal settings even if ai_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):

  1. Player clicks "Join classroom" → inline form opens
  2. Two fields: join code (6 chars, auto-uppercase, charset-validated client-side), first name (1..30 chars)
  3. Submit → POST /api/living-word/classrooms/join with { code, nickname } + the lw_device_id cookie (auto-included)
  4. Server validates code (active classroom), validates nickname (profanity), checks lw_classroom_students row 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 } }
  5. Game UI shows "Joined [Classroom Name]. Mrs. Smith's quizzes are now in your pool." (or similar)
  6. 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 with retry_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.mjs pattern catalog. Tests at e2e/living-word/classroom-join-profanity.spec.ts cover 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

  1. Migration migrations/2026-05-24-lw-classrooms.sql applied to prod Supabase via MCP. Verified with SELECT column_name FROM information_schema.columns WHERE table_name IN ('lw_classrooms', 'lw_classroom_students', 'lw_classroom_events').
  2. All API routes ship with rate-limit middleware + RBAC enforcement + cap checks.
  3. Educator UX gated to lw_educator_early_list membership; non-members redirected to early-list signup.
  4. Student join UX inside AuthBar (or sibling); device cookie set on first join; nickname profanity rejection works.
  5. 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_students row appears → game UI confirms
    • Invalid code → inline error, no row written, lw_classroom_events.join_attempt_failed_invalid_code row 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
  6. AI Bridge audit (scripts/living-word/audit-ai-bridge.mjs) passes on all new copy.
  7. Knowledge doc updated: knowledge/products/living-word/classrooms.md (extends Phase 1A docs).
  8. PR opens against main: title feat(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 columns ai_mode_default and ai_mode_teacher_override exist on lw_classrooms even 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 educator route group already exists by then.