Skip to content

Plan 80 R10

github-actions[bot] edited this page Oct 14, 2026 · 1 revision

R10 — Learn (v0.17.0)

The release that adds learning between tests. Teachers get Vocabulary (word lists, words with pronunciation, flashcard assignments to classes) and Courses (units, lessons of five kinds, release rules, progress). Students get Learn, the Course and Lesson pages, a Flashcards session with spaced repetition, and the Practice card on Home. Word lists and courses join R7's sharing, and a course share carries read and assign access to what the course uses.

R10 runs after R7 (77-r7.md: content_shares, the share dialog, Shared with me, make-a-copy, the email channel and its scheduler) and R9 (79-r9.md), whose read models it narrows. Vocabulary lands before Courses inside the release, because a flashcards lesson points at a word list (70 §2 rule 7). Screens are compared with docs/design/deck/Quizzivy Teacher.dc.html (Courses, course detail, Vocabulary, word list) and Quizzivy Student.dc.html (learn, course, lesson, flash, and Home's Practice card). Branch work/redesign-r10 is cut from develop.

The work touches three high-risk areas named in AGENTS: media (a new kind and new references), attempts (test lessons start through the lesson gate) and assignments (test lessons are assignments). Every task that touches them runs the module's unit tests and the five canaries on its branch point and on its last commit:

  • server/internal/modules/tests/application/tests/publish_snapshot_test.go
  • web/tests/units/media/audio-player.test.tsx
  • server/internal/modules/attempts/application/tests/events_test.go
  • web/tests/units/api/client.refresh.test.ts
  • web/tests/integration/router-chunks.test.ts

Deliverable: word lists, words and pronunciation; flashcard assignments with a new-words allowance and a daily reminder; an SM-2 variant scheduler with streaks; video as a media kind; courses with units, reading, video, listening, flashcards and test lessons, release rules, pace and progress; test lessons delivered as assignments; sharing and transitive access for both kinds; the student's Learn, Course, Lesson and Flashcards pages and the Home Practice card; and learner payloads that carry no answer key.

Exit criteria:

  • Every new teacher and student screen matches the deck at 360, 768, 1024, 1280 and 1440, light and dark (T-R10.21). Lesson kinds the deck does not draw are listed for DG-94 sign-off.
  • The scheduler's intervals match the deck's labels for a new card and follow the formula in this file's decisions after it, pinned by unit tests.
  • A student can finish a course end to end: a reading lesson, a video lesson, a flashcards lesson and a test lesson whose attempt runs through the normal engine. A locked lesson refuses its content and its test.
  • R2's isolation suite covers every new operation, including the shared-course paths. E2E 9 walks every new /app/* operation with its key list unchanged.
  • The migrations pass goose up/down/up and are rehearsed on a Neon branch.
  • The five canaries pass untouched; every CI step of 70 §8 is green locally, including pnpm e2e:live.
  • Released to production as v0.17.0.

Schema changes (numbers assigned at merge; new tables are empty when created, so their indexes are inline; indexes on populated tables are in their own -- +goose NO TRANSACTION files):

File Task Notes
NNNNN_create_word_lists.sql T-R10.1
NNNNN_create_words.sql T-R10.1 audio composite FK to media_assets (id, kind)
NNNNN_create_flashcard_assignments.sql T-R10.1
NNNNN_create_word_reviews.sql T-R10.1
NNNNN_create_practice_days.sql T-R10.1
NNNNN_add_media_kind_video.sql T-R10.2 enum value only; Down leaves an unused value in place
NNNNN_allow_video_media_assets.sql T-R10.2 checks replaced NOT VALID, then validated
NNNNN_create_courses.sql T-R10.3
NNNNN_create_course_units.sql T-R10.3
NNNNN_create_course_lessons.sql T-R10.3
NNNNN_create_course_classes.sql T-R10.3
NNNNN_create_lesson_progress.sql T-R10.3 append-only for the app role
NNNNN_add_assignments_course_lesson.sql T-R10.3 nullable, FK NOT VALID then validated
NNNNN_index_assignments_course_lesson.sql T-R10.3 NO TRANSACTION, CONCURRENTLY
NNNNN_add_practice_reminder_event.sql T-R10.6 R7's notification_preferences_event_check widened
NNNNN_grant_maintenance_learn.sql T-R10.8a SELECT on word_lists and courses for D16's owner check
NNNNN_add_content_shares_word_list.sql T-R10.10 plain FK; R7's three CHECKs replaced (T-R7.1's recipe)
NNNNN_index_content_shares_word_list.sql T-R10.10 NO TRANSACTION, CONCURRENTLY
NNNNN_add_content_shares_course.sql T-R10.10 plain FK; R7's three CHECKs replaced (T-R7.1's recipe)
NNNNN_index_content_shares_course.sql T-R10.10 NO TRANSACTION, CONCURRENTLY

New operations, each declaring its x-permission (70 §4.1: word lists map to content.questions.write, courses and lessons to content.tests.write):

Operation x-permission Task
GET, POST /teacher/word-lists; GET, PATCH, DELETE /teacher/word-lists/{id} content.questions.write T-R10.4
POST /teacher/word-lists/import content.questions.write T-R10.4
GET, POST /teacher/word-lists/{id}/words; PATCH, DELETE /teacher/words/{id}; POST /teacher/word-lists/{id}/words/bulk content.questions.write T-R10.4
GET /teacher/word-lists/{id}/stats people.students.read T-R10.4
PUT /teacher/word-lists/{id}/classes teaching.assignments.write T-R10.5
GET /app/flashcards, GET /app/flashcards/session, POST /app/flashcards/reviews learning.take_tests T-R10.5
GET, POST /teacher/courses; GET, PATCH, DELETE /teacher/courses/{id}; PUT /teacher/courses/{id}/outline content.tests.write T-R10.8a
POST /teacher/courses/{id}/units; PATCH, DELETE /teacher/units/{id} content.tests.write T-R10.8a
POST /teacher/units/{id}/lessons; GET, PATCH, DELETE /teacher/lessons/{id} content.tests.write T-R10.8a
GET /teacher/courses/{id}/preview content.tests.write T-R10.9
PUT /teacher/courses/{id}/classes; DELETE /teacher/courses/{id}/classes/{classId} teaching.assignments.write T-R10.8b
GET /teacher/courses/{id}/progress people.students.read T-R10.9
GET /app/courses, GET /app/courses/{id}, GET /app/lessons/{id}, POST /app/lessons/{id}/complete learning.take_tests T-R10.9

No public operation is added.

Order of work. Schema first (T-R10.1–T-R10.3). Then the vocabulary backend (T-R10.4–T-R10.6) and the media work (T-R10.7), then courses (T-R10.8a, T-R10.8b, T-R10.9), then sharing (T-R10.10) and the leak suite (T-R10.11). Each screen waits for its backend; T-R10.22, a teacher screen added later, sits after T-R10.14. At most four agents at a time.

Decisions taken in this file, worth a second look.

  • The scheduler is an SM-2 variant whose first intervals are the deck's labels. A card's state is ease (starts 2.5, clamped to 1.3–3.0), interval in days, repetitions and lapses. On a card never reviewed, or on the review after an Again: Again is due in 1 minute, Hard in 1 day, Good in 3 days and Easy in 7 days. After that, with interval I and ease E: Hard gives max(1, round(I × 1.2)), Good max(3, round(I × E)) and Easy max(7, round(I × E × 1.3)), all capped at 365. Ease changes by −0.20 (Again), −0.15 (Hard), 0 (Good) and +0.15 (Easy). Again counts a lapse and the card is re-shown later in the same session. There is no random fuzz: a deterministic schedule can be tested and explained to a student.
  • The grade buttons show the card's actual next interval. For a new card they read exactly as the deck draws them ("Again < 1 min", "Hard 1 day", "Good 3 days", "Easy 1 week"). For a reviewed card they show what the button will do ("Good 8 days"), because a static label would be wrong from the second review on.
  • A word is "known" when its interval is at least seven days and its last grade was not Again. One definition serves the student's "15 of 28 known", the teacher's "Students know it" and "Mastered on average". Seven days means the student answered Easy once or Good twice. The hardest word is the one with the highest lapse rate (lapses over repetitions, summed across students), among words with at least three repetitions.
  • New words arrive at the class's daily allowance, in list order. A student in two classes with the same list gets the larger allowance, not the sum. A flashcards lesson ignores the allowance for its own list, because a lesson asks the student to meet every word in it. Due reviews are never capped.
  • Streaks count local days. A day counts when the student grades at least one card between 00:00 and 24:00 in their time zone (R4's profile setting). The streak is the run of such days ending today or yesterday. practice_days holds one row per student and day.
  • Pronunciation is an uploaded audio file per word, falling back to the browser's speech synthesis (DG-95). No server-side text-to-speech: it would be a new third-party dependency sending word lists off-site for a feature the browser already has. The Listen button is hidden when neither is available.
  • Video is a new media kind, uploaded like audio and never transcoded (DG-94). MP4 only, up to 95 MiB and 30 minutes, sniffed from the ftyp box, with the duration read by the existing MP4 probe. The size cap comes from the edge: api.quizzivy.com is proxied by Cloudflare, whose Free plan refuses a request body over 100 MB (docs/setup/dns.md), so a larger cap would answer 413 before the API saw the file. 95 MiB holds about 13 minutes at 1 Mbit/s (a 720p screen recording) and about 6.5 minutes at 2 Mbit/s, so for most files the size binds before the 30 minutes. It goes through the existing streaming upload route with its read and write deadlines raised for the upload (the server's 120 s WriteTimeout would otherwise cut it off), one video upload at a time per API process, so two teachers cannot fill the machine's disk. Presigned direct upload stays out of v1 (15-phase-5.md); anything larger is O-R10.1. WebM is refused: MP4 plays in every browser students use, and a second container would need a second probe. It plays in <video controls preload="metadata"> from a signed URL; the CSP's media-src already allows R2.
  • Lesson bodies are the existing content model, edited with the existing editor, saved explicitly (DG-94). Reading, video notes and listening notes are semantic_v1 documents (server/internal/shared/content), edited with ContentEditor and rendered with ContentView. The lesson editor saves with a button and a dirty guard rather than autosave. A lesson has no publish snapshot to protect, and explicit save avoids the two flushes AGENTS requires of every debounced autosave.
  • A course's test and flashcards lessons may point only at the course owner's tests and word lists. The command checks it under the course row lock. This keeps the whole course manageable by its owner, makes transitive access through a course share well defined, and lets R5's ownership transfer move a teacher's courses, tests and word lists together. Media is exempt: assets are immutable and may be shared by reference, as R7's copies already are, but a word or lesson may bind only an asset the actor can read (T-R7.7's readable-asset definition).
  • A test lesson is delivered as an ordinary assignment, created through the assignments Create port. There is one assignment per test lesson per assigning teacher, targeting that teacher's classes that have the course, marked by assignments.course_lesson_id. A partial unique index on (course_lesson_id, created_by) makes the sync idempotent, and the sync takes a per-course advisory lock (the registry in 20-data-model.md). Attempts, autosave, integrity, grading and the leak guarantees are reused unchanged. Timing, attempts, integrity and review rules come from the assigning teacher's assignment defaults (R4). The window opens at assignment and closes 365 days later. R7's in-API notification scheduler (T-R7.14) gains an extend-course-windows rule that extends a window by another 365 days when its course is still assigned to that teacher's classes and fewer than 30 days remain, audited as System, under the scheduler's advisory lock (73819, 42). Its next due time joins the scheduler's candidates, so it adds no wake of its own. It is not a cmd/maintenance step: R1's window commands move deadlines only when an operator runs them, and R5's nightly sweep is D16's retention job, which applies only once RETENTION_SWEEP_ENABLED is set, so an extension there could stop silently. Release rules are enforced by the lesson gate, not by the window.
  • The lesson gate runs in the attempt start path, through a port. StartOrResume asks the courses module whether the lesson is open for the student, only when the assignment has a course_lesson_id, and answers 409 LESSON_LOCKED otherwise. Resuming an attempt is never refused, following R1's maintenance rule.
  • Course-lesson assignments stay out of places that describe the class's test schedule: Home's Coming up, due-time notifications, R7's due-soon, closing and after_close result-release emails, R8's teacher calendar and the student's week, the dashboard's Today, "Closing in 24 h", taking now and submissions, the teacher summary's live assignments, Gradebook, Reports and student Grades (O-R9.2). They stay in Grading and in the teacher's Assignments list, labelled "{course} · lesson {n}", because someone has to grade them. The Settings tab shows "Managed by the course" on Test & timing and Window.
  • Release rules are evaluated per student when read, never stored and never timed. A unit is open when its rule holds (open now; every published lesson of the previous unit done; or its date reached), when the course has started for the student's class, and, with the "One unit a week" pace, when starts_on + (n − 1) weeks has passed. Inside an open unit, lessons open in order, one after the previous is done. No timer flips a flag, so nothing wakes Neon.
  • Test-lesson completion is derived; other completions are stored. Reading, video and listening lessons complete on "Mark as done", and a flashcards lesson when every word of its list has been reviewed once. These write lesson_progress. A test lesson is complete when the student has handed in an attempt on its assignment. Deriving that from attempts keeps submit.go, a high-risk path, free of a courses dependency.
  • A course share carries read and assign access to what the course uses, never edit access. With a Can use or Can edit grant on a course, a teacher can read and assign the course, preview its lessons, see the published versions of its lesson tests, and read its word lists and media. Editing a lesson test or a word list still needs a grant on that test or list. Revoking the course grant does not change classes that already have the course: students keep learning in it, and the teacher who assigned it can still unassign their own class.
  • Copying a course copies what it points at. R7's make-a-copy for a course copies the course, its units and lessons, every word list its flashcards lessons use, and every test its test lessons use (as new draft tests through R7's test copy, which copies bank questions on write). Media is referenced by id. A copied test lesson stays a draft until its copied test is published. The invariant above rules out a copy that points at someone else's tests.
  • "Reset progress" resets the actor's students only. A word list shared with another teacher may be practised in their classes too. Resetting deletes the review state of the chosen words for students in the actor's classes that have the list assigned, and nobody else's.
  • "Import words" on the Vocabulary page creates a new list named after the file. The deck's dialog has a file and a header toggle and no target list. The teacher renames the list afterwards.
  • Deleting follows what students lose. A word is deleted outright, with its review state, as the deck's confirm says ("Students lose their progress on them"). A lesson is deleted outright: its stored completions go with it, and its test lesson's assignments are closed early and detached (course_lesson_id set NULL), so attempts and grades survive as a closed assignment. Word lists and courses are soft-deleted, and refused with 409 while a class has them or a lesson uses them.
  • Deleting a class removes its flashcard and course assignments, not students' progress. flashcard_assignments.class_id and course_classes.class_id cascade: they are the class's configuration, and without the cascade R2's 409 on class delete would refuse every class that ever practised.
  • Learner payloads are their own Go types. StudentLesson, StudentCourse and FlashCard share nothing with the teacher types, so a new teacher field cannot reach a student by accident. A listening lesson has no script field; its text is body, which is never named transcript (E2E 9's forbidden key).
  • Stats that describe named students need people.students.read. The words list, the vocabulary cards and the course cards carry knownPercent, masteredPercent or averageCompletion only when the caller holds that key, and null otherwise, as R4's summary does. The stats and progress operations declare it outright.
  • The daily practice reminder needs both switches, and cards due. It is sent when the teacher kept "Daily reminder at 19:00" on for the class, the student kept "Daily practice reminder" on, cards are due and the student has not practised today. The in-app item materialises on read, like R4's due-time items. The email goes out from R7's scheduler once a day at 19:00 in APP_TIMEZONE: one wake a day, not one per student.

T-R10.1 — Vocabulary schema

Depends on: — Touches: migrations/NNNNN_create_word_lists.sql, migrations/NNNNN_create_words.sql, migrations/NNNNN_create_flashcard_assignments.sql, migrations/NNNNN_create_word_reviews.sql, migrations/NNNNN_create_practice_days.sql (all new), server/internal/platform/db/tests/{constraints,migrate,privileges}_test.go, server/internal/modules/identity/repositories/delete.go (T-R2.13's referencedBy map) and its integration test, docs/plan/20-data-model.md Size: M Done when:

  • create_word_lists: - id uuid PK DEFAULT uuidv7(), owner_id FK users RESTRICT - name text 1–120, level text in R4's level set (pre_a1 … c2, DG-62) or NULL, description text ≤ 500 - created_by, updated_by (FK users, SET NULL for updated_by), created_at, updated_at (the app.set_updated_at() trigger), deleted_at - index (owner_id, updated_at DESC) WHERE deleted_at IS NULL
  • create_words: - id, list_id FK word_lists CASCADE, ordinal integer, term text 1–120, part_of_speech text CHECK IN ('noun','verb','adjective','adverb','phrasal_verb') or NULL, meaning text ≤ 300, example text ≤ 500 - audio_asset_id uuid, audio_asset_kind app.media_kind, a composite FK to media_assets (id, kind) RESTRICT, with CHECK ((audio_asset_id IS NULL) = (audio_asset_kind IS NULL)) and CHECK (audio_asset_kind IS NULL OR audio_asset_kind = 'audio') (D-05's pattern) - created_at, updated_at - unique index (list_id, lower(term)); UNIQUE (list_id, ordinal) DEFERRABLE INITIALLY IMMEDIATE (D-13); index (audio_asset_id) WHERE audio_asset_id IS NOT NULL
  • create_flashcard_assignments: - id, word_list_id FK RESTRICT, class_id FK classes CASCADE - new_words_per_day smallint NOT NULL DEFAULT 10 CHECK IN (5,10,15,20), daily_reminder boolean NOT NULL DEFAULT true - assigned_by FK users RESTRICT, assigned_at - UNIQUE (word_list_id, class_id); index (class_id)
  • create_word_reviews: - student_id FK users CASCADE, word_id FK words CASCADE, PK (student_id, word_id) - ease numeric(3,2) NOT NULL CHECK (ease BETWEEN 1.30 AND 3.00), interval_days smallint NOT NULL CHECK (interval_days BETWEEN 0 AND 365), repetitions integer, lapses integer, both ≥ 0 - due_at timestamptz NOT NULL, last_grade smallint CHECK (last_grade BETWEEN 1 AND 4), last_review_id uuid NOT NULL, first_reviewed_at, last_reviewed_at - index (student_id, due_at)
  • create_practice_days: (student_id FK users CASCADE, day date, reviews integer NOT NULL CHECK (reviews > 0), PRIMARY KEY (student_id, day)).
  • Grants: the app role gets SELECT, INSERT, UPDATE, DELETE on the four content and review tables, and SELECT, INSERT, UPDATE on practice_days. Every Down drops its table.
  • The users FKs that raise 23503 on a user delete join T-R2.13's constraint-name map in identity/repositories/delete.go: word_lists_owner_id_fkey and word_lists_created_by_fkey as referencedBy: owned_content, and flashcard_assignments_assigned_by_fkey as referencedBy: assignments. A deleteUser integration test on a user who owns a word list answers 409 with that value, never a 500.
  • Tests: constraints_test.go refuses a video asset on a word, a duplicate term in a list (case-folded), and out-of-range ease and interval; privileges_test.go pins the grants; goose up/down/up.
  • 20-data-model.md gains the five tables, their reasons, and a deviation row for review state kept per student and word instead of a review log.

T-R10.2 — Video as a media kind

Depends on: — Touches: migrations/NNNNN_add_media_kind_video.sql, migrations/NNNNN_allow_video_media_assets.sql (both new), server/internal/modules/media/{domain/asset.go,domain/manager.go,application/command/upload.go,http/media.go}, server/internal/platform/probe/{probe.go,mp4.go}, server/internal/platform/probe/testdata/ (a short MP4, new), server/internal/core/adapters/probe.go, api/openapi.yaml (MediaKind, the upload operation's x-max-body-bytes), generated code, docs/plan/20-data-model.md Size: M Done when:

  • add_media_kind_video runs ALTER TYPE app.media_kind ADD VALUE IF NOT EXISTS 'video' and nothing else, because a value added in a transaction cannot be used before it commits. Down raises while any row uses video, and otherwise leaves the value in place: an unused enum value is harmless, and rebuilding the type would break the composite FKs to media_assets (id, kind) (00011, 00017, 00037) and the CHECKs that cast to app.media_kind (00014, 00017, 00037). IF NOT EXISTS lets the second Up of goose up/down/up pass. 20-data-model.md §12 records the deviation.

  • allow_video_media_assets replaces, each added NOT VALID and then validated: - the MIME check, adding video/mp4 - the kind/MIME check, with one arm per kind (audio/%, image/%, video/%) - the duration check: duration_ms is required for audio and video, with at most 300,000 ms for audio (spec §11.1) and 1,800,000 ms for video - R4's per-kind byte check, adding video at up to 99,614,720 bytes (95 MiB, under Cloudflare's 100 MB request-body limit) - on every table that references media_assets (id, kind) from a question or group (questions, test_version_questions and the group asset and recording tables of 00037/00039; the migration names each), a check that the kind is not video

    Down restores the previous checks and refuses while a video row exists.
    
  • Upload: a body is video when it starts with an ISO BMFF ftyp box whose major brand is isom, iso2, mp41, mp42 or avc1. The duration comes from the existing mvhd walk. The route's read and write deadlines are both raised to 15 minutes for this kind through http.ResponseController (SetReadDeadline and SetWriteDeadline), since the server's 120 s WriteTimeout also bounds a long upload's handler. A process-wide semaphore of 1 guards video uploads, and a second concurrent one answers 429 UPLOAD_BUSY with Retry-After. The bytes stream to the temp file as today, never into memory.

  • MediaKind gains video. The media library list accepts kind=video. The upload operation's x-max-body-bytes becomes 95 MiB plus the multipart allowance, still under Cloudflare's 100 MB.

  • Tests: - probe unit: the MP4 fixture's duration; WebM, a renamed MP3, a truncated box and a moov loop are refused - integration: the size and duration caps, the semaphore - constraints: a video asset on a question is refused - goose up/down/up, with the enum Down raising while a video row exists

  • The media unit tests and the five canaries run before and after (high-risk area).


T-R10.3 — Course schema

Depends on: T-R10.1, T-R10.2 Touches: migrations/NNNNN_create_courses.sql, migrations/NNNNN_create_course_units.sql, migrations/NNNNN_create_course_lessons.sql, migrations/NNNNN_create_course_classes.sql, migrations/NNNNN_create_lesson_progress.sql, migrations/NNNNN_add_assignments_course_lesson.sql, migrations/NNNNN_index_assignments_course_lesson.sql (all new), server/internal/platform/db/tests/{constraints,migrate,privileges}_test.go, server/internal/modules/identity/repositories/delete.go (T-R2.13's referencedBy map) and its integration test, docs/plan/20-data-model.md Size: M Done when:

  • create_courses: id, owner_id FK users RESTRICT, title 1–160, level (R4's set) or NULL, description ≤ 500, status text NOT NULL DEFAULT 'draft' CHECK IN ('draft','published'), created_by, updated_by, timestamps, deleted_at; index (owner_id, updated_at DESC) WHERE deleted_at IS NULL.
  • create_course_units: id, course_id FK CASCADE, ordinal smallint, title 1–160, release_rule text CHECK IN ('open','after_previous','on_date'), release_on date, CHECK ((release_rule = 'on_date') = (release_on IS NOT NULL)), timestamps; UNIQUE (course_id, ordinal) DEFERRABLE INITIALLY IMMEDIATE.
  • create_course_lessons: - id, unit_id FK CASCADE, ordinal smallint, UNIQUE (unit_id, ordinal) DEFERRABLE INITIALLY IMMEDIATE - kind text CHECK IN ('reading','video','listening','flashcards','test'), title 1–160, status text NOT NULL DEFAULT 'draft' CHECK IN ('draft','published'), minutes smallint CHECK (minutes BETWEEN 1 AND 240) - body jsonb (a semantic_v1 document, validated by the application), with a CHECK that body is NULL for flashcards and test lessons - media_asset_id, media_asset_kind (composite FK to media_assets (id, kind) RESTRICT), word_list_id FK RESTRICT, test_id FK tests RESTRICT - per-kind checks: a video lesson may hold only a video asset, a listening lesson only an audio asset, a flashcards lesson only word_list_id, a test lesson only test_id, and a reading lesson none of them; a published lesson must hold the one its kind needs - partial indexes on media_asset_id, word_list_id and test_id where not NULL
  • create_course_classes: course_id FK RESTRICT, class_id FK classes CASCADE, starts_on date NOT NULL, pace text NOT NULL CHECK IN ('self_paced','unit_per_week'), assigned_by FK users RESTRICT, assigned_at; PK (course_id, class_id); index (class_id).
  • create_lesson_progress: lesson_id FK CASCADE, student_id FK users CASCADE, completed_at timestamptz NOT NULL DEFAULT now(), PK (lesson_id, student_id), index (student_id). The app role gets SELECT, INSERT only (append-only, like attempt_events); privileges_test.go asserts no UPDATE or DELETE.
  • add_assignments_course_lesson adds course_lesson_id uuid (nullable, no default: the R9 binary's inserts leave it NULL, which is correct) and a FK to course_lessons ON DELETE SET NULL, added NOT VALID and validated in the same file. Down drops the column.
  • index_assignments_course_lesson is -- +goose NO TRANSACTION: CREATE UNIQUE INDEX CONCURRENTLY assignments_course_lesson_teacher_key ON app.assignments (course_lesson_id, created_by) WHERE course_lesson_id IS NOT NULL. Down drops it CONCURRENTLY.
  • Every other Down drops its table.
  • The users FKs that raise 23503 on a user delete join T-R2.13's constraint-name map in identity/repositories/delete.go: courses_owner_id_fkey and courses_created_by_fkey as referencedBy: owned_content, and course_classes_assigned_by_fkey as referencedBy: assignments. A deleteUser integration test on a user who owns a course answers 409 with that value, never a 500.
  • Tests: each per-kind check; a published test lesson without a test refused; the deferred ordinal swap; an assignment inserted with the R9 column list (the old-binary insert test); goose up/down/up.
  • 20-data-model.md gains the tables and deviation rows for test lessons delivered as assignments and for lesson_progress being append-only.

T-R10.4 — Word lists and words

Depends on: T-R10.1 Touches: server/internal/modules/vocabulary/ (new: domain/{list,word,commands,errors,repository}.go, application/{app.go,command/{create_list,update_list,delete_list,add_word,update_word,delete_word,bulk_words,import_words}.go,query/{lists,list,words,stats}.go}, repositories/{lists,words,stats}.go, http/{lists,words}.go), server/internal/core/wiring/vocabulary.go (new), server/internal/core/router/{modules.go,server.go}, server/internal/platform/tabular/, api/openapi.yaml (tag teacher-vocabulary), generated code Size: L Done when:

  • The package comment names the model: word lists owned by a teacher, their words, and each student's review state.
  • Lists (content.questions.write): - GET /teacher/word-lists?q=&page= → {items: [{id, name, level, wordCount, classes: [{id, name}], masteredPercent | null, updatedAt, access: owner|edit|use, owner: {id, name}}], page, total} - POST {name, level?} → 201 - GET /{id} → the list, its access, owner and the actor's classes that have it - PATCH /{id} {name?, level?, description?}: owner or edit grant - DELETE /{id}: owner only; soft delete; 409 WORD_LIST_IN_USE while a class has it or a lesson points at it
  • Words: - GET /teacher/word-lists/{id}/words?page=&limit=&band= (limit 10 by default, as the deck pages; band=mastered|learning|hard keeps the words whose knownPercent is 70 or more, 50 to 69, or under 50, and page and total follow it) → {items: [{id, term, partOfSpeech, meaning, example, audioAssetId, knownPercent | null}], page, total} - POST {term, partOfSpeech?, meaning?, example?, audioAssetId?}; PATCH, DELETE /teacher/words/{id}: owner or edit grant; a duplicate term answers 409 WORD_EXISTS - POST /teacher/word-lists/{id}/words/bulk {action: move|reset_progress|delete, wordIds (≤ 200), targetListId?}. Move keeps review state and needs a target list with the same owner. Reset deletes review state for students in the actor's classes that have the list. Delete removes the words and their state. The result reports per-word failures, for R4's BulkActions.
  • Import: POST /teacher/word-lists/import, multipart {file (≤ 2 MB), firstRowIsHeader}: - creates a list named after the file, without its extension - columns: word, part of speech, meaning, example; parts of speech accepted in vi and en - at most 2,000 rows; duplicates in the file or the list are skipped - → {listId, added, skipped, rejected: [{row, reason}]} (≤ 100 rejected rows listed) - CSV through encoding/csv with BOM handling. XLSX through R5's tabular reader, with excelize's UnzipSizeLimit and UnzipXMLSizeLimit set and the streaming row reader. Cell text is stored as typed and never evaluated. - synchronous in the API: 2 MB and 2,000 rows with no hashing is not a heavy job under 70's rule
  • GET /teacher/word-lists/{id}/stats (people.students.read) → {studentsPractising, studentsAssigned, masteredPercent, hardestWord: {id, term} | null, bands {mastered, learning, hard}} over active students of the actor's classes that have the list. bands counts the list's words in each of the three band ranges above, which v2's progress card draws and filters by (74d row V-19); a word with no review data is in none.
  • knownPercent and masteredPercent are null unless the caller holds people.students.read.
  • Reads follow R7's share-aware scope (owner, grant, or scope.all). A write needs owner or edit; delete needs owner. Other teachers' lists answer 404, and a use grantee's writes answer 403. Every operation joins R2's isolation suite.
  • Every operation declares x-resource in T-R2.16's per-parameter form, and this PR adds the kinds word-list and word to the enum: the path {id} (word-list or word), the bulk body's /wordIds (word) and /targetListId (word-list), and /audioAssetId (media). GET /teacher/word-lists and the words list declare x-resource-list.
  • Writes are audited through the data-modifying CTE pattern (word_list.*, word.*). Audio references go through T-R10.7's lock. An audioAssetId must be a readable asset under the media module's definition (T-R7.7: own, scope.all, or referenced by content the actor can read, including shares and T-R10.10's transitive arm); anything else gives the same not-found validation error as a missing asset.
  • Tests: - unit: import parsing (header toggle, vi and en parts of speech, duplicates, a row with =CMD() stored literally) - integration: CRUD, bulk move and reset scope, delete refused in use, 409 on a duplicate term - the XLSX zip-bomb fixture refused by the unzip limits - contract and isolation

T-R10.5 — Spaced repetition and flashcard assignments

Depends on: T-R10.4 Touches: server/internal/modules/vocabulary/{domain/{schedule,session}.go,application/command/{assign_classes,review}.go,application/query/{student_home,session}.go,repositories/{assignments,reviews}.go,http/{assignments,student}.go}, api/openapi.yaml (tag student-flashcards), generated code Size: L Done when:

  • domain/schedule.go holds the scheduler exactly as decided in this file, as a pure function Next(state, grade, now) (state, dueAt), plus Preview(state, now), which gives the four intervals for the buttons.
  • PUT /teacher/word-lists/{id}/classes {classIds, newWordsPerDay, dailyReminder} (teaching.assignments.write) sets which of the actor's classes have the list: - listed classes are upserted - the actor's unlisted classes are unassigned; students keep their state - other teachers' classes are untouched, and every classId must be in the actor's class scope (404 otherwise) - reading needs only a read grant, so a use grantee can assign a shared list (D4)
  • GET /app/flashcards (learning.take_tests) → {dueToday, minutes, lists: [{id, name, class: {id, name}, words, known, due}], streak: {current, week: [{date, practised}]}}: - lists come from the student's classes' flashcard assignments - dueToday counts reviews due before the end of the student's local day, plus today's remaining new words - minutes is ceil(cards × 20 s ÷ 60) - the week runs Monday to Sunday in the student's zone
  • GET /app/flashcards/session?listId=&lessonId= → {cards: [{wordId, term, partOfSpeech, meaning, example, hasAudio, intervals: {again, hard, good, easy}}], remaining}: - due cards first (oldest due), then new cards within the allowance, at most 50 - lessonId (a released flashcards lesson, T-R10.9) lifts the allowance for that list - a list the student cannot reach answers 404
  • POST /app/flashcards/reviews {reviewId, wordId, grade: again|hard|good|easy, lessonId?} → {dueAt, intervalDays, known}: - locks the (student, word) row - idempotent on reviewId: a replay returns the stored outcome and changes nothing - upserts practice_days in the same transaction - with lessonId, inserts lesson_progress when that grade completes the lesson's list (every word reviewed once) - the client never sends an interval
  • Student payloads are the FlashCard and FlashcardsHome types, used by nothing else.
  • x-resource (T-R2.16's form): PUT …/classes declares its path {id} (word-list) and body /classIds (class); the session declares query listId (word-list) and lessonId (lesson); the review declares body /wordId (word), /lessonId (lesson) and /reviewId (none, a client idempotency key). Whichever of this task and T-R10.8a merges first adds the lesson kind.
  • Tests: - unit: the scheduler table for new cards, a lapse, the ease floor and ceiling, the 365-day cap, previews - integration: the allowance with two classes (the larger wins), lesson mode, the local-day boundary in Asia/Ho_Chi_Minh, the streak across a missed day, review idempotency under two concurrent posts, an unreachable word refused - isolation for PUT …/classes, including A's class in B's classIds; the three student operations in T-R2.16's two-student pair; E2E 9's walk gains them too (T-R10.11)

T-R10.6 — Daily practice reminder

Depends on: T-R10.5, R4 T-R4.10a/b, R7's email channel Touches: migrations/NNNNN_add_practice_reminder_event.sql (new), api/openapi.yaml (NotificationKind, the preference event enum), generated code, server/internal/modules/notifications/{application/command/materialise_due.go,repositories/due.go}, server/internal/modules/vocabulary/application/query/reminders.go (new), R7's email scheduler rules (77-r7.md), the email templates (vi and en) Size: S Done when:

  • add_practice_reminder_event replaces the check R7's widen_notification_preference_events left under the name notification_preferences_event_check (T-R7.13's seven keys) with one that adds practice.reminder, added NOT VALID and validated. Down first deletes practice.reminder rows, then restores R7's seven-key check exactly as that file writes it.
  • The deck's default is on. The student's Notifications switch covers in-app and email (DG-82).
  • In-app: MaterialiseDue inserts practice.reminder (dedupe practice:{studentId}:{local date}, params {due}) once 19:00 local has passed, when the student has cards due, has not practised today, and at least one of their classes has the list with daily_reminder on.
  • Email: one daily rule on R7's scheduler at 19:00 in APP_TIMEZONE (R5's config) selects the same students, with the switch on and an email address, and enqueues one templated email each ("{n} words to review today"), linking to /app/learn?tab=flashcards. It runs under the scheduler's (73819, 42) key in 20-data-model.md's advisory-lock registry, and 19:00 joins the scheduler's next-due candidates. No per-student timer.
  • Tests: the four conditions, each off; the dedupe; the template parity test (vi and en keys).

T-R10.7 — Media referenced by lessons and words

Depends on: T-R10.2, T-R10.3, T-R10.4 Touches: server/internal/modules/media/{repositories/references.go,repositories/delete.go,repositories/replace.go,application/command/delete.go,application/query/{mint_for_student,signed_url}.go,application/ports/ports.go}, R4's media replace command, server/internal/core/adapters/media.go, server/internal/modules/{vocabulary,courses}/application/query/asset_reach.go (new), server/internal/modules/media/application/tests/versionref_test.go Size: M Done when:

  • Every insert or update that names an asset on a word, or on a lesson (its media, or an asset referenced from its body), calls media.LockForVersionUse first, so it contends with a concurrent delete on the same row. TestLockForVersionUseSerialisesAgainstDelete gains two siblings, one for a lesson and one for a word, which drive both halves concurrently.
  • The delete guard counts lessons (not deleted) and words, and 409 MEDIA_IN_USE names them ("{lesson} in {course}", "{word} in {list}"). The usage view (R4) lists them.
  • R4's replace re-points words and lessons under the owner filter T-R7.7 gives it for bank questions: only words of lists, and lessons of courses, owned by the asset's owner or editable by the actor. Other owners' words and lessons keep the old asset, which stays served, and the response counts what it left. Published test versions are untouched (DG-09); publish_snapshot_test.go passes untouched.
  • Student reachability: a student may mint a URL for an asset used by a word of a list they can reach (T-R10.5), or by a lesson open to them (T-R10.9, including images in its body). Media asks through two ports typed as the vocabulary and courses query handlers, wired in core/adapters/media.go. A draft lesson's asset, a locked lesson's asset and another class's course asset answer 404.
  • Teacher reachability is the media module's readable-asset definition (T-R7.7), whose "referenced by content the actor can read" arm gains words and lessons, including through a course share (T-R10.10). The same definition checks every binding on a word (T-R10.4) and on a lesson's media and body (T-R10.8a).
  • Tests: the two lock races, the guard, replace (including another owner's copied word and lesson left on the old asset), each reachability case; the media unit tests and the five canaries before and after.

T-R10.8a — Course authoring

Depends on: T-R10.3, T-R10.4, T-R10.7 Touches: server/internal/modules/courses/ (new: domain/{course,unit,lesson,outline,commands,errors,repository}.go, application/{app.go,command/{create_course,update_course,delete_course,save_outline,add_unit,update_unit,delete_unit,add_lesson,update_lesson,delete_lesson}.go,query/{courses,course,lesson}.go}, repositories/{courses,units,lessons}.go, http/{courses,units,lessons}.go), server/internal/core/wiring/courses.go (new), server/internal/core/router/{modules.go,server.go}, api/openapi.yaml (tag teacher-courses), generated code, migrations/NNNNN_grant_maintenance_learn.sql (new), R5's server/internal/core/maintenance/anonymize.go, server/internal/core/maintenance/tests/retention_test.go, server/internal/platform/db/tests/privileges_test.go Size: L Done when:

  • The package comment names the model: a course, owned by a teacher, is units of ordered lessons that open by rule, and its progress per student.
  • Courses (content.tests.write): - GET /teacher/courses?status=&page= → {items: [{id, title, level, status, units, lessons, classes: [{id, name}], averageCompletion | null, access, owner}], counts: {all, published, draft}, page, total} - POST {title, level?, description?} → 201, as a draft - GET /{id} → the course, its access and owner, and units: [{id, title, releaseRule, releaseOn, lessons: [{id, kind, title, status, minutes, wordCount, questionCount}]}] - PATCH /{id} {title?, level?, description?, status?}: publishing needs at least one published lesson - DELETE /{id}: owner only; soft delete; 409 COURSE_ASSIGNED while a class has it
  • Outline: PUT /teacher/courses/{id}/outline {units: [{id, lessonIds}]} reorders units and lessons, and moves lessons between units, in one transaction under the course row lock. The deferrable ordinal uniques absorb the swap. Every existing unit and lesson must appear exactly once (422 OUTLINE_MISMATCH otherwise).
  • Units: POST /teacher/courses/{id}/units {title, releaseRule, releaseOn?} (appended); PATCH, DELETE /teacher/units/{id}. Delete removes the unit's lessons, with T-R10.8b's detach for test lessons.
  • Lessons: - POST /teacher/units/{id}/lessons {kind, title} → a draft - GET /teacher/lessons/{id} → the full lesson with body and its reference - PATCH {title?, body?, minutes?, mediaAssetId?, wordListId?, testId?, status?} - DELETE, which closes and detaches its assignments (T-R10.8b) in the same transaction
  • Lesson rules: - body validates as a semantic_v1 document; its asset references go through T-R10.7's lock - mediaAssetId and every asset referenced from body must be a readable asset under the media module's definition (T-R7.7, widened by T-R10.7); anything else gives the same not-found validation error as a missing asset - a reading lesson's minutes defaults to its word count at 200 per minute; a video or listening lesson's comes from the asset's duration, rounded up - wordListId and testId must belong to the course owner (422 LESSON_REFERENCE_NOT_OWNED), and a test needs a published default version - checked under the course row lock
  • Writes need owner or an edit grant, and delete needs the owner. Others answer 404, use grantees 403. Audited (course.*, course_unit.*, course_lesson.*). Every operation joins R2's isolation suite.
  • Every operation declares x-resource in T-R2.16's per-parameter form, and this PR adds the kinds course, unit and lesson to the enum: the path {id} (course, unit or lesson), the outline body's unit ids (unit) and lesson ids (lesson), and a lesson's /mediaAssetId (media), /wordListId (word-list) and /testId (test). GET /teacher/courses declares x-resource-list.
  • R5's ownership transfer moves a user's courses and word lists in the same transaction as their tests (the invariant holds after it). Grants whose grantee becomes the owner are removed first, as R5 does for tests. R5's data export gains the ten new tables, formula escaped.
  • D16 anonymisation: core/maintenance/anonymize.go's owner check (R5's "skipping users who own classes or content", widened by R7 to question sets) gains word_lists.owner_id and courses.owner_id, counted the way R5 counts its own content tables. grant_maintenance_learn grants quizzivy_maintenance SELECT on word_lists and courses, as R5's rule for a widened cmd/maintenance command requires, and privileges_test.go pins it. Down revokes it.
  • Tests: - unit: the outline validator, minutes - integration: CRUD, the owner-only reference rule, publish requires a lesson, the outline move across units, delete refused while assigned, the transfer - retention_test.go: a disabled account that owns only a word list, or only a course, is skipped and counted - contract and isolation

T-R10.8b — Assigning a course; test lessons as assignments

Depends on: T-R10.8a Touches: server/internal/modules/courses/{application/command/{assign_classes,unassign_class,sync_lesson_assignments,extend_course_windows}.go,application/ports/assignments.go (new),repositories/assignment_sync.go (new),http/classes.go (new)}, server/internal/modules/assignments/{domain/commands.go,domain/assignment.go,repositories/write.go,repositories/assignments.go,repositories/student.go,application/query/{list,for_student}.go}, server/internal/modules/attempts/{application/ports/lesson_gate.go (new),application/command/start_or_resume.go}, server/internal/modules/notifications/repositories/{due.go,weekly.go}, R7's server/internal/core/jobs/notify_schedule.go and server/internal/core/jobs/tests/notify_schedule_test.go, server/internal/modules/timetable/application/query/{calendar,student_week}.go (R8), server/internal/modules/dashboard/{application/query/nav.go,repositories/postgres.go}, server/internal/modules/gradebook/repositories/{gradebook,reports,grades}.go, server/internal/core/wiring/{courses,attempts,assignments}.go, api/openapi.yaml (Assignment.courseLesson, the student assignment's courseLesson), generated code, docs/plan/20-data-model.md (the advisory-lock registry) Size: L Done when:

  • PUT /teacher/courses/{id}/classes {classIds, startsOn, pace} (teaching.assignments.write) sets the actor's classes that have the course: - the course must be published and readable by the actor (owner, grant, or scope.all) - each class must be in the actor's class scope - unlisted classes of the actor are unassigned; other teachers' classes are untouched

  • DELETE /teacher/courses/{id}/classes/{classId} unassigns one of the actor's classes. It works after a revoked share, because the class is the actor's.

  • x-resource (T-R2.16's form): both operations declare the path {id} (course); PUT declares body /classIds (class) and DELETE the path {classId} (class), so A's class is substituted into each alone.

  • domain.WriteInput gains CourseLessonID, settable only through the port and never through the HTTP contract. The courses module declares AssignmentCreator and AssignmentUpdater ports typed as the assignments Create and Update command handlers (AGENTS: a cross-module need is a port typed as the other module's handler).

  • SyncLessonAssignments(courseID) runs after an assign, an unassign, a test lesson's publish and a lesson's delete. Under pg_advisory_xact_lock(hashtextextended('course:' || course_id::text, 0)), the per-course key this task adds to 20-data-model.md's advisory-lock registry (the single-bigint key space, which never overlaps the registry's (73819, n) pairs), for each published test lesson and each teacher with classes on the course, it ensures one assignment: - created_by that teacher, targeting exactly their classes that have the course - the test's default version - rules from the teacher's preferences.assignmentDefaults - window from now to now + 365 days, extended by extend-course-windows below

    The unique index makes a lost race an idempotent no-op. A teacher with no classes left gets
    their assignment closed early.
    
  • extend-course-windows (courses/application/command/extend_course_windows.go) is a rule of R7's notify scheduler (core/jobs/notify_schedule.go), not a cmd/maintenance step: - it runs under the scheduler's pg_try_advisory_xact_lock(73819, 42), then each course's sync key above - each course-lesson assignment with fewer than 30 days left, whose course is still assigned to one of its teacher's classes, gets closes_at + 365 days through the AssignmentUpdater port (the managed-field 409 below guards the HTTP contract, not the port), audited as System - its next due time, the earliest such closes_at − 30 days, joins the scheduler's candidates, so it adds no wake of its own (PR-7) - a second run changes nothing; it needs no maintenance-role grant, since it runs as the app role

  • A course-lesson assignment refuses changes to Test & timing and Window with 409 ASSIGNMENT_MANAGED_BY_COURSE. Extend, close, reopen, Integrity and Results work as for any assignment.

  • The lesson gate: attempts/application/ports/lesson_gate.go declares LessonGate typed as the courses query handler LessonOpenFor(studentID, lessonID). StartOrResume consults it only for assignments with a course_lesson_id, before creating a new attempt, and answers 409 LESSON_LOCKED. Resume is never refused. It is wired in wiring/attempts.go.

  • Exclusions (course_lesson_id IS NULL) in: - the student's assignment lists (R3's ForStudent, so Home's Coming up) - R4's MaterialiseDue - R7's scheduler candidates in due.go and weekly.go: the student's due-soon at close − 24 h and − 1 h, the teacher's closing email, and the after_close result release - R8's /teacher/calendar assignment events (opens and closes) and /app/week due items - R4's dashboard Today, "Closing in 24 h", takingNow and submissions, and /teacher/summary's liveAssignments (nav.go) - R9's gradebook, reports and student grades

    The teacher's Assignments list keeps them, with
    `courseLesson: {courseId, courseTitle, lessonNo}`. The student's assignment detail carries
    `courseLesson: {courseId, lessonId}`, so the intro's back link returns to the lesson.
    
  • Tests: - integration: the sync's idempotency under two concurrent calls, the targets after assign and unassign, a grantee's own assignment, publish then sync, the managed-field 409 - notify_schedule_test.go: extend-course-windows extends at 29 days left, leaves 31 days alone, leaves a course no longer assigned alone, and a second run changes nothing; its next due time is computed - the gate: locked start refused, resume allowed, an ordinary assignment unaffected - one test per exclusion above, each with an ordinary assignment beside the course lesson - the attempts and assignments unit tests, and the five canaries, before and after (high-risk areas)


T-R10.9 — Learner courses, release rules and progress

Depends on: T-R10.8b, T-R10.5 Touches: server/internal/modules/courses/{domain/release.go (new),domain/progress.go (new),application/query/{student_courses,student_course,student_lesson,progress,preview,lesson_open}.go,application/command/complete_lesson.go,repositories/{student,progress}.go,http/{student,progress}.go}, api/openapi.yaml (tag student-courses), generated code Size: L Done when:

  • domain/release.go is a pure function over the course outline, the class's starts_on and pace, the student's completions and "today" in the student's zone. It returns each unit's state (done, open, locked with reason after_unit, on_date or pace, and the opening date) and each lesson's state (done, current, available, locked). It is the only implementation: the student reads, the gate and media reachability all call it.
  • Completion: stored rows in lesson_progress, plus test lessons with a handed-in attempt on their assignment for this student. Only published lessons of published courses count. A student reaches a course through their classes; with several classes on it, the earliest starts_on applies.
  • Student operations (learning.take_tests): - GET /app/courses → [{id, title, class: {id, name}, level, done, total, upNext: {lessonId, unitNo, unitTitle, lessonNo, lessonsInUnit} | null}] - GET /app/courses/{id} → {id, title, class, teacherName, done, total, units: [{id, no, title, state, lockReason, opensOn, done, total, lessons: [{id, kind, title, minutes, words, questions, state}]}]} - GET /app/lessons/{id} → StudentLesson {id, courseId, unit: {no, title}, lessonNo, lessonsInUnit, kind, title, minutes, body | null, media: {assetId, kind} | null, wordList: {id, words} | null, test: {assignmentId, status: not_started|in_progress|submitted|graded, score | null} | null, done, next: {id, title} | null}. A locked lesson answers 409 LESSON_LOCKED {reason, opensOn} with no body. A draft lesson or an unreachable course answers 404. - POST /app/lessons/{id}/complete for reading, video and listening lessons only: idempotent, and returns {done, total, next}
  • test.score appears only when the student's result page would show it (R9's rule).
  • Teacher operations: - GET /teacher/courses/{id}/progress?classId= (people.students.read) → {students, averageCompletion, currentUnitForMost, lastActivityAt, fallingBehind: [{student, reason: not_started|stuck|behind_pace, lesson, days, unitNo, done, total}]}, for the actor's classes. Not started: no completion three days after starts_on. Stuck: the current lesson unchanged for three days or more. Behind pace: the expected unit is ahead of the current one. Capped at five, most days first. - GET /teacher/courses/{id}/preview (content.tests.write) → the student shape with every published lesson available and each test lesson's testId and default version, for R4's test preview
  • Tests: - unit: release_test.go covers each rule, the pace floor, sequential lessons, a date rule in the student's zone, a lesson inserted before the current one, and an unpublished lesson skipped - integration: completions stored and derived, the earliest start across two classes, locked 409 with no body, complete idempotency, progress and falling-behind reasons - isolation, and the student cross-class 404 in T-R2.16's two-student pair; x-resource declares the path {id} (course or lesson) and progress's query classId (class)

T-R10.10 — Sharing word lists and courses; transitive access

Depends on: T-R10.4, T-R10.8a, R7's sharing module Touches: migrations/NNNNN_add_content_shares_word_list.sql, migrations/NNNNN_index_content_shares_word_list.sql, migrations/NNNNN_add_content_shares_course.sql, migrations/NNNNN_index_content_shares_course.sql (all new), server/internal/modules/sharing/, R7's share-aware scope predicates, server/internal/core/adapters/sharing.go, api/openapi.yaml (ShareKind), generated code, R2's isolation suite Size: L Done when:

  • Each add_content_shares_* migration follows the recipe in T-R7.1's create_content_shares header (content_shares stores no owner): - word_list_id uuid REFERENCES app.word_lists ON DELETE CASCADE (or course_id uuid REFERENCES app.courses ON DELETE CASCADE), nullable, the FK added NOT VALID and then validated; no owner column and no composite FK - content_shares_one_resource replaced with num_nonnulls(test_id, question_set_id, word_list_id) = 1 in the word-list file, and num_nonnulls(test_id, question_set_id, word_list_id, course_id) = 1 in the course file - content_shares_kind_check replaced with kind IN ('test','question_set','word_list'), then kind IN ('test','question_set','word_list','course') - content_shares_kind_matches replaced with one equality per kind, (kind = 'test') = (test_id IS NOT NULL) AND (kind = 'question_set') = (question_set_id IS NOT NULL) AND … through the file's new kind - each replacement CHECK added NOT VALID and then validated

    Down refuses while a share of that kind exists, and otherwise restores the three previous
    CHECKs and drops the column.
    
  • Each index_content_shares_* file is -- +goose NO TRANSACTION: CREATE UNIQUE INDEX CONCURRENTLY … (word_list_id, grantee_id) WHERE word_list_id IS NOT NULL (and the course twin). Down drops it CONCURRENTLY.

  • ShareKind gains word_list and course. R7's grant, change, revoke, remove-from-my-list, Shared with me and notification paths accept them. Make a copy copies a word list (words and audio references), and a course as decided above.

  • GET /teacher/shared-with-me accepts kind=word_list|course, and its counts gain wordList and course. The web side is T-R10.22.

  • Access on read models: word list and course reads return access: owner|edit|use and owner. A use grant reads, assigns and copies; an edit grant also edits words, units and lessons; neither shares further or deletes (D4).

  • Transitive access: R7's readable predicate for tests and for word lists gains an arm, "used by a published lesson of a course the actor can read". It allows reading and assigning (published versions only for tests) and never writing: write predicates are unchanged. Media reachability follows (T-R10.7).

  • An edit grantee adding a test or flashcards lesson may pick only the owner's tests and lists that the grantee can read (the owner-only rule of T-R10.8a).

  • The isolation suite gains the shared-course paths: - teacher C, with no grant, gets 404 on the course, on its lesson test through the test operations, and on its word list - teacher B, with a use grant, can read and assign, and gets 403 on writes to the course, the test and the list - after revocation, B gets 404 on the course, while B's class keeps the course and its students can still start an open test lesson

  • Tests: the four migrations (up/down/up, rehearsed on a Neon branch), including a share whose kind disagrees with its column refused; the kinds and counts in Shared with me; copy of a course with a word list and a test; the transitive arm; the isolation cases above.


T-R10.11 — Learner leak guarantees

Depends on: T-R10.5, T-R10.9, T-R10.7 Touches: web/tests/units/contract/openapi-contract.test.ts (the static walk of every schema reachable from /app/*), web/tests/e2e/payload-leak.live.spec.ts (E2E 9), web/tests/e2e/support/live.ts, seed/04-dev-e2e.sql Size: M Done when:

  • The static walk covers every schema reachable from the new /app/* operations and finds none of isCorrect, sampleAnswer, acceptedAnswers or transcript at any depth. The key list is unchanged.
  • E2E 9 seeds a course with each lesson kind and an assigned word list, then walks /app/courses, /app/courses/{id}, every /app/lessons/{id}, /app/flashcards, the session and a review response. It asserts: - a test lesson exposes only assignmentId, status and a released score, and its questions arrive only through /app/attempts/* - a locked lesson's response has no body, media or wordList - a draft lesson and a draft course are absent - another class's course and list answer 404 - the meaning on a card is present, as the card's back by design
  • The PR carries the leak review of each new student operation.

T-R10.12 — Teacher: Vocabulary and word list

Depends on: T-R10.4, T-R10.5, T-R10.10, D4 in production (the AI panel, T-D4.34, and the ai module, T-D4.30 to T-D4.32) Touches: api/openapi.yaml (generateWords), generated code, server/internal/modules/ai/{domain/prompts/words.go,application/query/generate_words.go,http/}, server/internal/core/router/{ratelimits.go,tests/testdata/permissions.golden}, server/tests/isolation_cases_test.go, server/internal/modules/ai/application/tests/operations_test.go, web/src/features/ai/kinds/vocab.ts (new), web/src/features/vocabulary/ (new: pages/teacher/{VocabularyPage,WordListPage}.tsx, components/{WordListCard,WordTable,WordDialog,ImportWordsDialog,AssignFlashcardsDialog,NewWordListDialog,PronounceButton,MoveWordsDialog}.tsx, api.ts, input.ts, speech.ts), web/src/app/router.tsx, web/src/layouts/teacherNav.ts, web/src/features/search/CommandPalette.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/vocabulary/ (new), web/tests/units/contract/teacher-nav.test.ts Reuses: CardGrid, DataTable, Pager, BulkActions, RowMenu, FormDialog, ProgressBar, R4's media picker, R7's share dialog and access banner Size: L Done when:

  • Routes /teacher/vocabulary and /teacher/vocabulary/:id, lazy, with crumbs "Vocabulary" and "Vocabulary › {list}". The Content group gains Vocabulary (layers) after Courses, as the deck orders them, with content.questions.write. The palette gains "Vocabulary".
  • Vocabulary: "Vocabulary" / "Word lists students practise as flashcards.", with Import words (secondary) and New word list (primary). Cards show level, updated, name, "{n} words · {classes}" (or "Not assigned") and a Mastered bar ("—" when null).
  • New word list: Name (required), Level Pre-A1…C2 (DG-62). Import words: the file (CSV or XLSX), "First row is a header"; then a result dialog ("{added} words added, {skipped} skipped" with the rejected rows) that opens the new list.
  • Word list: header with name, level and assigned classes; "Generate from text" (sparkles, before Add word, shown only while the AI entry points are, useAiAvailable), Add word, Assign flashcards (primary), Share (R7's dialog, DG-90), and "…" (Rename, Delete list with the 409 message).
  • Progress: one card in place of the fourth export's four tiles (74d row V-19), shown only with people.students.read: "{p}% mastered on average"; a three-segment bar of mastered (--success, a word 70% or more of students know), learning (--warning, 50 to 69) and hard (--danger, under 50) from stats.bands; legend buttons with their counts, which are toggle buttons with aria-pressed that filter the word table through ?band= and reset the page; and, after a divider, "Practising {a} / {b}". The hardest word is no longer drawn.
  • "Generate from text" opens D4's AI panel in its vocab kind: "Generate words from a text" / "Picks the words your students need to understand a passage, with meanings and new example sentences."; Text (7 rows, up to 8,000 characters), How many words 8 / 12 / 20, Students’ level A2 / B1 / B2, Meanings in Tiếng Việt | English. Each suggestion shows the term, its part of speech and level as tags, the meaning, and the example as a note; the primary "Add {n} to {list}" calls the add-word write once for each accepted word, and a WORD_EXISTS is counted in the toast, not shown as an error. The panel, its states and its credit note are D4's (T-D4.34); this task adds the kind.
  • generateWords (POST /teacher/ai/words/generate, ai.use): {text, count, level, meaningLanguage} → {items: [{term, partOfSpeech, level, meaning, example}], usage}. It is a suggestion and writes nothing. It runs on the fast tier, reserves 20 credits and settles the metered usage (74d section G: about 2 credits), takes 10 a minute and 100 an hour per actor, answers 501 while the adapter is nil, sends the text, count, level and language and nothing else (D20), and has its isolation-suite entry; permissions.golden gains the operation in this pull request. operations_test.go drives it through the fake Completer and asserts the request holds no name, email or id.
  • Table: Word (pronounce button, part of speech), Meaning, Example (from 880 content width), "Students know it" (from 600) with a bar in danger below 50, warning below 70, else success; "1–10 of {n} words".
  • Row menu: Edit, Play pronunciation, Delete. Bulk: Move to list, Reset progress ("Students start these words again from the beginning."), Delete ("Students lose their progress on them.").
  • Word dialog: Word (required), Part of speech, Meaning, Example sentence, and Pronunciation (upload or pick audio, R4's picker). Pronunciation is extrapolated from deck parts and listed for DG-95 sign-off.
  • Pronounce: plays the word's audio when present, else speechSynthesis with an English voice. The button is hidden when neither is available.
  • Assign flashcards: Classes chips (required, "Pick at least one class"), New words a day 5/10/15/20 (default 10), "Daily reminder at 19:00" (on). It is prefilled with the current assignment.
  • A use grant hides every edit control and shows "Make a copy". An edit grant shows R7's banner ("Changes you make are saved to {owner}'s list").
  • States (DG-83): skeletons; "No word lists yet." with New word list; "No words in this list yet." with Add word; errors with retry.
  • Unit tests: the column drops at 880 and 600 (content width), bars, the progress card's bands and legend filters, bulk confirms, use and edit modes, the pronounce fallback, the assign prefill, the Generate button hidden while AI is off, the add of accepted words with one duplicate, zod assertions against the generated types.
  • Compared with the deck's Vocabulary and word list at the five widths, light and dark. Keys vi first and listed.

T-R10.13 — Teacher: Courses and course detail

Depends on: T-R10.8a, T-R10.8b, T-R10.9, T-R10.10, D4's T-D4.20 (StickyAside) Touches: web/src/features/courses/ (new: pages/teacher/{CoursesPage,CourseDetailPage}.tsx, components/{CourseCard,UnitAccordion,LessonRow,AddUnitDialog,AddLessonDialog,AssignCourseDialog,CoursePreviewDialog,ProgressAside,FallingBehindAside,NewCourseDialog}.tsx, outline/{useOutlineDnd,OutlineSortable}.tsx, api.ts, input.ts, releaseLabel.ts), web/src/app/router.tsx, web/src/layouts/teacherNav.ts, web/src/features/search/CommandPalette.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/courses/ (new), web/tests/integration/router-chunks.test.ts Reuses: CardGrid, the tabs, FormDialog, ProgressBar, Avatar, R7's share dialog, @dnd-kit (already a dependency) Size: L Done when:

  • Routes /teacher/courses and /teacher/courses/:id, lazy. The Content group gains Courses (book-open), with content.tests.write, and the palette gains "Courses".
  • Courses: "Courses" / "Units and lessons your classes work through in order."; tabs "All {n} · Published {n} · Drafts {n}"; cards with status, level, title, "{u} units · {l} lessons · {classes}" and "Average completion". New course: name (required), Level, Short description, "Starts as a draft. Add units and lessons, then assign it.", toast "Course created as a draft".
  • Course detail header: title, status, "{level} · {classes}", Preview as student, and Assign to class (while draft, Publish takes its place, extrapolated and listed for DG-94), plus Share and "…".
  • Units as an accordion: - number, title and release label ("Open now", "After Unit {n}", "Opens {date}" formatted per locale, DG-94) - lesson rows: kind tile (Reading book-open-text info, Video circle-play danger, Listening headphones warning, Flashcards layers accent, Test file-text muted), title, meta ("8 min", "18 words", "10 questions"), a Draft chip, and "{done}/{n}" - "Add lesson" per unit, "Add unit" at the end - a row opens the lesson editor
  • Reorder by drag and by keyboard (DG-94): @dnd-kit sortable for units and for lessons within and across units, saving through PUT …/outline, with a rollback and a toast on error. router-chunks.test.ts keeps @dnd-kit inside the teacher graph: the builder's assertion is widened to the course detail and still asserts its absence from the student, public and entry graphs.
  • Add unit: Unit name; Opens (Open now | After previous unit | On a date, default After previous unit), with Date when needed. Add lesson: Type (Reading, Video, Listening, Flashcards, Test) and Title; it creates a draft and opens the editor.
  • Assign course: "Students see it in Learn. Units open by their rules."; Classes chips (required), Start date, Pace (Self-paced | One unit a week); prefilled from the actor's current assignment.
  • Preview as student: a dialog, "Lessons in the order students see them.", with kicker "Unit {i} · {unit} · {kind}". Reading, video and listening lessons render as the student sees them; a test lesson's "Start test" opens R4's test preview.
  • Asides from a content width of 1000 (below, after the units): - Progress: Students, Average completion, Current unit for most, Last activity - Falling behind: a row per student with the reason; "Send a reminder" opens R7's class announcement composer - both need people.students.read - both sit in D4's StickyAside (T-D4.20, 74d row V-02): sticky at 16px, capped at calc(100vh − 88px) and scrolling inside themselves, from a content width of 800
  • Use and edit grants as in T-R10.12.
  • States (DG-83): skeletons; "No courses yet." with New course; an empty course ("Add a unit to start."); errors.
  • Unit tests: release labels in vi and en, the outline payload after a cross-unit drag, the keyboard reorder, the rollback, the Publish/Assign swap.
  • Compared with the deck's Courses and course detail at the five widths, light and dark. Keys vi first and listed.

T-R10.14 — Teacher: lesson editor

Depends on: T-R10.13, T-R10.2 Touches: web/src/features/courses/{pages/teacher/LessonEditorPage.tsx,components/{LessonKindFields,VideoField,ReferencePicker}.tsx} (new), web/src/features/media/ (R4's picker gains video), web/src/app/router.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/courses/lesson-editor.test.tsx (new), web/tests/e2e/lesson-video.live.spec.ts (new) Reuses: ContentEditor, R4's media picker and upload, FormDialog, ConfirmDialog Size: M Done when:

  • Route /teacher/courses/:id/lessons/:lessonId, crumbs "Courses › {course} › {lesson}". The screen is extrapolated from deck parts and listed for DG-94 sign-off.
  • Title, plus per kind: - Reading: the body in ContentEditor, and Minutes (the estimate, editable) - Video: a video from the library or an upload (MP4, up to 95 MB and 30 minutes, with progress; a larger file is refused in the browser before it is sent), a <video controls> preview, and notes in ContentEditor - Listening: an audio file, a plain <audio controls> preview, and notes - Flashcards: a word list select (the owner's lists) with its word count - Test: a test select (the owner's tests with a published default version) with its question count
  • Footer: Save (enabled when dirty), Publish or Unpublish, and Delete lesson (confirm; for a test lesson the confirm says its assignment closes and results stay). A leave guard holds unsaved changes. A 422 LESSON_REFERENCE_NOT_OWNED shows its message.
  • Rich editors stay out of learner graphs (the router-chunks assertion is unchanged).
  • lesson-video.live.spec.ts (live project): uploads a real short MP4, so the sniff and the duration probe run on both halves (AGENTS: the live suite is for claims about the two halves meeting), and rejects a WebM.
  • Unit tests: fields per kind, dirty state and guard, publish requires a reference. Keys vi first and listed.

T-R10.22 — Teacher: Shared with me gains word lists and courses

Depends on: T-R10.10, T-R10.12, T-R10.13 Touches: web/src/features/sharing/pages/teacher/SharedWithMePage.tsx (R7), web/src/features/sharing/components/ShareDialog.tsx (R7), web/src/features/notifications/kinds.ts, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/sharing/, web/tests/units/notifications/ Size: S Done when:

  • Tabs gain Word lists and Courses, counted from counts.wordList and counts.course (T-R10.10), and the sub-line becomes the deck's full one, which T-R7.17 held back until these kinds shipped.
  • Open goes to /teacher/vocabulary/:id for a word list and /teacher/courses/:id for a course, in the use or edit mode of T-R10.12 and T-R10.13.
  • Make a copy toasts "Copied to your Vocabulary" or "Copied to your Courses".
  • notifications/kinds.ts routes content.shared for both kinds to the same pages.
  • The share dialog's explainer copy covers both kinds.
  • Unit tests: the tabs and counts, each Open route, both toasts, the bell route. Compared with the deck's Shared with me at the five widths, light and dark (T-R10.21). Keys vi first and listed.

T-R10.15 — Student: Learn

Depends on: T-R10.5, T-R10.9 Touches: web/src/features/learn/ (new: pages/LearnPage.tsx, components/{CourseCard,FlashcardsSummary,DeckCard}.tsx, api.ts), web/src/app/router.tsx, web/src/app/modules.ts, web/src/layouts/StudentLayout.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/student/{learn,shell}.test.tsx Size: M Done when:

  • modules.ts turns Learn on. The top nav and the bottom tab bar gain Learn (book-open) after Classes, completing the deck's five tabs.
  • Route /app/learn?tab=courses|flashcards (DG-81), a root destination, max width 960. "Learn" / "Courses from your teachers and words to practise." Tabs Courses and Flashcards, the latter with a badge for words due.
  • Courses tab: cards with "{class} · {level}", title, "Unit {n} · {unit} · lesson {a} of {b}", a progress bar with its percentage, and Continue (to the up-next lesson, else the course).
  • Flashcards tab: the summary "{n} words due today", "About {m} minutes · from {k} lists", and Start review. Deck cards show class, "{n} due", name, and "{known} of {words} known" with a bar. A list with nothing due shows the toast "All words in this list are known. Nothing due." when every word is known, else "Nothing due today. Come back tomorrow." (the second is extrapolated).
  • States (DG-83): skeleton; no courses ("No courses yet. Your teacher adds them to your class."); no lists; error with retry. Nothing polls.
  • Unit tests: tabs in the URL, the badge, Continue's target, both toasts, the nav with the module flag.
  • Compared with the deck's learn at the five widths, light and dark. Keys vi first and listed.

T-R10.16 — Student: Course and Lesson

Depends on: T-R10.15 Touches: web/src/features/learn/{pages/{CoursePage,LessonPage}.tsx,components/{UnitCard,LessonStateRow,LessonBody,MarkDoneBar}.tsx} (new), web/src/app/router.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/student/{course,lesson}.test.tsx (new) Reuses: ContentView, the student result score display Size: M Done when:

  • /app/learn/courses/:id is a detail route (handle.detail: "Course", back to /app/learn), max width 820. It shows the back link "Learn" from 768, "{class} · {teacher}", the title, and "{done} of {total} lessons" with a bar.
  • Unit cards: the number badge (success when complete, lime when open, a lock when locked); meta "Completed", "{a} of {b} lessons done", "Opens after Unit {n}" or "Opens {date}".
  • Lesson rows: a state dot (a success check when done; a lime play ring on a lime row with the "Up next" pill when current; hollow otherwise), the kind icon and meta. A locked lesson gives the toast "Finish the lessons before this one first" (or "Opens {date}").
  • /app/learn/lessons/:id is a detail route ("Lesson", back to its course), max width 720. It shows the pill "{kind} · {n} min", the h1, "Unit {n} · lesson {a} of {b}", and: - reading: ContentView - video: <video controls preload="metadata" playsinline> from a signed URL, then the notes - listening: <audio controls>, then the notes - flashcards: "Practise these {n} words", opening the session with lessonId - test: the status, the score when released, and "Start test", opening /app/assignments/:assignmentId, whose intro links back to the lesson
  • The sticky "Mark as done" (reading, video, listening) gives the toast "Lesson done · {done} of {total}", then turns into "Next: {title}". A 409 LESSON_LOCKED opened by URL shows the reason and a link back to the course.
  • States (DG-83): skeletons; errors with retry. Only the reading kind is drawn in the deck; video, listening, flashcards and test are extrapolated and listed for DG-94.
  • Unit tests: each unit state and reason, the toasts, mark as done, the next link, locked by URL, back targets at viewport("phone").
  • Compared with the deck's course and lesson at the five widths, light and dark. Keys vi first and listed.

T-R10.17 — Student: Flashcards session

Depends on: T-R10.5 Touches: web/src/features/learn/{pages/FlashcardsPage.tsx,components/{Flashcard,GradeButtons,SessionDone}.tsx,useReviewQueue.ts} (new), web/src/app/router.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/student/flashcards.test.tsx (new) Size: M Done when:

  • /app/learn/flashcards?list=&lesson= is a detail route ("Flashcards", back to Learn, or to the lesson when lesson is set), max width 560.
  • Progress "{done} done · {left} left".
  • Card (radius 18): - front: part of speech in italics, the term at 34px, "Tap to see the meaning · or press Space", and the Listen pill (the word's audio, else speech synthesis, else hidden) - back: the meaning, and the example in quotes
  • Grade buttons after the flip: Again (danger-soft), Hard, Good, Easy (success-soft), each with the server's preview interval ("< 1 min", "1 day", "3 days", "1 week", or the computed one). Keys 1–4 after the flip; Space flips.
  • Again puts the card back three cards later (or last).
  • Each grade posts at once with a client reviewId. A failure retries in order with backoff and a quiet "Not saved yet" line, and the done screen waits for the queue.
  • Done: "Done for today", "{n} words reviewed. {m} needed another look. Next review {when}.", and "Back to Learn".
  • Under prefers-reduced-motion there is no flip animation. The card is a button with aria-pressed for the flipped state, and the back is announced politely.
  • States: skeleton; "Nothing due" (for a direct URL); error.
  • Unit tests: the key map, requeue after Again, a replay after a failed post keeps its reviewId, reduced motion, the done summary.
  • Compared with the deck's flash at the five widths, light and dark. Keys vi first and listed.

T-R10.18 — Student: Home practice card and the reminder switch

Depends on: T-R10.5, T-R10.6 Touches: web/src/features/assignments/pages/StudentHomePage.tsx, web/src/features/learn/components/PracticeCard.tsx (new), R3's student Settings page (Notifications), web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/student/home.test.tsx Size: S Done when:

  • Home shows the Practice card when the student has any assigned list: - the layers tile, "{n} words to review", "{list} · about {m} min" (or "from {k} lists") - the Monday-to-Sunday strip: practised days lime, today in the border colour, future days muted; tooltips "Practised" and "Today" - it opens /app/learn?tab=flashcards - with nothing due: "All caught up" and the next review day
  • Recent results and Practice sit side by side at a container width of 820 or more (1.4fr / 1fr), as R3 deferred until Practice exists.
  • Student Settings › Notifications gains "Daily practice reminder" (R4's switch pattern, both channels, DG-82).
  • Unit tests: the card with and without lists, the strip, the split at 820, the switch. Compared with the deck's Home at the five widths, light and dark.

T-R10.19 — Volume seed and query review for Learn

Depends on: T-R10.9, R9 T-R9.10 and T-R9.11 Touches: seed/volume/volume.sql, server/tests/perf_flow_test.go, docs/perf/explain-<date>.md, docs/plan/50-nfr-registry.md Size: S Done when:

  • The volume seed gains 60 word lists (3,000 words), flashcard assignments, 120,000 word_reviews, 100,000 practice_days, 12 courses (60 units, 300 lessons), course classes and 50,000 lesson_progress rows.
  • p95 budgets at volume: /app/flashcards < 150 ms, the session < 150 ms, a review < 100 ms, /app/courses/{id} < 150 ms, /app/lessons/{id} < 100 ms, course progress < 300 ms, word list stats < 300 ms. Statement counts are fixed. The capture is committed.
  • Any index added is in its own NO TRANSACTION, CONCURRENTLY file.

T-R10.20 — Docs

Depends on: the tasks whose behaviour each part describes Touches: docs/quizzivy-spec-v0.3.md (§7, §8, §9, §11, §13, §15; version bumped per §18), AGENTS.md, docs/plan/20-data-model.md, docs/plan/30-risks.md, docs/plan/40-open-items.md, docs/plan/70-redesign-overview.md, docs/setup/dns.md (the Cloudflare body limit now binds video uploads) Size: S Done when:

  • Spec: §7 word lists, courses and lessons; §8 the teacher screens with their rules (scheduler, known, release rules, the owner-only reference rule, transitive access); §9 the student's Learn, Course, Lesson and Flashcards; §11 video as a media kind with its limits; §13 and §15 the tables and operations. The "Availability: R10" lines are flipped.
  • AGENTS.md: - the repository map gains modules/{vocabulary,courses} and features/{vocabulary, courses,learn} - the high-risk paragraph adds that anything inserting a media reference on a word or a lesson must call media.LockForVersionUse first, naming the two new race tests - a note that StartOrResume consults the lesson gate only for course-lesson assignments
  • 20-data-model.md is complete for R10, with its deviations. 30-risks.md gains the lesson-gate risk and its tests. 40-open-items.md holds this file's open items. The R10 row in 70 §2 is marked shipped at release.
  • Every PR that changed behaviour earlier in the release already changed its own lines; this task checks the set.

T-R10.21 — Browser verification against the deck

Depends on: T-R10.12 … T-R10.18, T-R10.22 Touches: seed/NN-dev-learn.sql (new, development only, numbered at merge), web/tests/e2e/fixtures/ (a short MP4 for development) Size: M Done when:

  • The development seed holds the deck's shapes: - the five word lists with the travel words and the environment cards - the four courses, with "IELTS Reading Foundations" carrying the deck's four units (one done, one open, one after Unit 2, one on a date) and a lesson of every kind - two classes with the course at different starts - students at different points, including one per falling-behind reason
  • Every new route is compared with design-deck on 5175 at 360, 768, 1024, 1280 and 1440, light and dark: /teacher/vocabulary(/:id), /teacher/courses(/:id), the lesson editor, /app/learn (both tabs), /app/learn/courses/:id, /app/learn/lessons/:id (each kind), the flashcards session (front, back, done), Home with Practice, and /teacher/shared-with-me with its Word lists and Courses tabs. The dev server is confirmed fresh, and sizes are measured with getBoundingClientRect.
  • Deviations are fixed in the owning task's branch or filed as issues. Vite and the API are stopped afterwards.

Release checklist

  • Every task is ticked on work/redesign-r10. develop is merged into it, and verification is re-run after that merge.
  • Every CI step runs locally, in order, judged by exit code (70 §8.1): - make lint, make gen-check - make test-api (after docker compose up -d db minio), and goose up/down/up - pnpm lint, pnpm typecheck, pnpm format:check - pnpm test:unit, pnpm test:integration, pnpm build - pnpm e2e, pnpm e2e:content - pnpm e2e:live, required because media, take-test (the lesson gate) and the leak walk changed; it includes lesson-video.live.spec.ts - node scripts/check-design-deck.mjs
  • The perf job is green on the release commit, and its capture is committed.
  • The five canaries pass, and their files are untouched against develop. The two new lock race tests pass.
  • Security: the isolation suite (with the shared-course paths) passes; E2E 9 passes with its key list unchanged; no public operation was added.
  • All twenty migrations are rehearsed on a Neon branch of production, including the enum addition, the maintenance-role grant, the three CONCURRENTLY indexes and each VALIDATE. The timings go in the release PR.
  • The deploy window is outside exam windows (query production assignments). The API and the worker run the same binary.
  • Deploy and smoke: - Thuong's go, then release/0.17.0 → main - deploy the API (the release command migrates), then Pages - smoke /healthz, /livez, and sign-in as each role - in the owner's test class: create a word list and assign it; create a course with a reading, a video (upload a short MP4), a flashcards and a test lesson; assign it; as a student, finish it and review the cards - upload an MP4 just under 95 MiB through api.quizzivy.com, so Cloudflare's body limit and the raised deadlines are exercised on the real path, then delete it - back-merge to develop, and tag v0.17.0
  • Release notes, vi first: - Vocabulary and flashcards, with how the review intervals work - courses and lessons, and how units open - video uploads (MP4, up to 95 MB and 30 minutes, not converted) - test lessons appear under Assignments and in Grading, not in the Gradebook - sharing word lists and courses, and what a course share lets a colleague do - the daily practice reminder, and how to turn it off
  • T-R10.20's docs are in the release.

Open items

  • DG-94. The lesson editor, the Publish control on a draft course, unit rename and delete, and the student render of video, listening, flashcards and test lessons are extrapolated from deck parts and need the design team's sign-off.
  • DG-95. The per-word pronunciation upload in the word dialog is extrapolated; the speech synthesis fallback is interim.
  • DG-90. Share entry points for word lists and courses follow the test's share dialog until the deck draws them.
  • O-R10.1 — Video limits (Thuong). 95 MiB, 30 minutes, MP4 only, and one upload at a time are this file's defaults. The 95 MiB comes from Cloudflare's 100 MB request-body limit on the Free plan in front of api.quizzivy.com (docs/setup/dns.md); it holds about 13 minutes of 720p at 1 Mbit/s. Longer recorded lessons need one of: presigned direct upload to R2 (outside v1, 15-phase-5.md), an upload host that Cloudflare does not proxy, or a Cloudflare plan with a larger body limit. Each changes the edge, the cost or the security posture, so Thuong chooses. Default: 95 MiB through the proxied route, revisited with real usage.
  • O-R10.2 — Course test lessons in the Gradebook. They are kept out of Insights (O-R9.2). Default: out.
  • O-R10.3 — Listening scripts. A listening lesson has notes but no separate script, so no field collides with E2E 9's transcript key. If teachers want a revealable script, it needs a name, a review rule and a leak-test change. Default: none; the teacher writes it in the notes.
  • O-R10.4 — Levels as ranges. The deck's fixtures show "B1–B2" on course cards; lists and courses store one level, as the dialogs draw. Default: one level.
  • DG-83. The empty, loading and error states of every new screen ship from deck primitives and are listed for sign-off.

Generated from docs/plan/80-r10.md. Edit that file in the repository; changes made on this page are overwritten.

Home

Start here

Product

Implementation plan

Redesign (Phase R)

Setup and operations

Design

Team

Code READMEs

Clone this wiki locally