Repository navigation
Plan 74 R4 Part 2
Part 2 of 6 of R4 — Teacher workspace (v0.10.0) · Previous · Next
Depends on: —
Touches: web/src/app/router.tsx, web/src/app/LegacyTeacherRedirect.tsx (new), web/src/features/auth/home.ts, web/src/app/guards/RequireWorkspace.tsx, web/src/app/boot/handoff.ts, web/src/features/auth/AccountMenu.tsx, web/src/layouts/AdminLayout.tsx (its nav links), web/src/features/*/pages/** → web/src/features/*/pages/teacher/** (the 22 teacher pages, moved), every web/src/features/** file linking to an /admin/… route, web/tests/** with /admin/… routes, web/tests/e2e/smoke.spec.ts, web/tests/e2e/theme.spec.ts, web/tests/e2e/support/live.ts, web/tests/units/student/shell.test.tsx, web/tests/units/student/imports.test.ts (it reads the router's adminTree by name)
Size: L
Delivered in parts: two, each one branch and one pull request
T-R4.5a: the page moves. Depends on nothing in R4
T-R4.5b: the rename (router, redirect, link sweep in src and tests, the literal test). Depends on T-R4.5a
As built:
-
T-R4.5a: 22 pages moved with
git mv, each tofeatures/<f>/pages/teacher/<same name>:assignments(AssignmentsListPage,AssignmentDetailPage,AssignmentFormPage),attempts(AssignmentAttemptsPage,AttemptReviewPage,GradingQueuePage),classes(ClassesListPage,ClassDetailPage),imports(ImportsGate,ImportsListPage,NewImportPage,ImportDetailPage,ImportReviewPage),media(MediaLibraryPage),question-bank(QuestionBankPage,QuestionEditorPage),question-groups(GroupsListPage,GroupEditorPage),students(StudentsListPage) andtests(TestsListPage,TestDetailPage,TestBuilderPage). -
T-R4.5a: what stayed, against the Touches glob.
features/*/pages/**would also move the student's pages (StudentHomePage,AssignmentIntroPage,StudentClassesPage,StudentSettingsPage,ResultPage,TakeTestPage),JoinPageand the four auth pages; they are not the teacher's and stay.auth/pages/AdminSettingsPage.tsxandapp/pages/AdminDashboardPage.tsxstay too: T-R4.43 and T-R4.23 replace them, so a move would only be undone. -
T-R4.5a: only paths changed. The 49 relative imports of the eleven pages that have them
gained one
../(not rewritten to@/, so the diff stays a path fix);router.tsx's 24 lazy imports and the 39 files underweb/teststhat import a page gainedteacher/. Prettier re-wrapped the statements the longer path pushed past 88 columns and nothing else: 17lazy: page(…)calls in the router, and one named import each inGroupEditorPageandTestBuilderPage. Every rename is at 92% similarity or more. Routes, links and layouts are untouched;/adminstays/adminuntil T-R4.5b. -
T-R4.5a: the canary passed unedited.
router-chunks.test.tsnames by path the seven moved pages of the four features itsADMINpattern lists (tests,question-bank,media,students), as before the move:features/tests/pages/teacher/TestsListPage.tsximported eagerly turns it red. The fifteen moved pages of the other five features are not named by path, also as before:features/assignments/pages/teacher/AssignmentsListPage.tsximported eagerly passes it, 12 of 12. T-R4.5b'spages/teacher/pattern is the first to name them.tests/units/student/imports.test.ts, which reads the router'simport("…")calls, also passes: prettier keeps each call on one line. -
T-R4.5a: no test compares a teacher path with the page it loads, as at the base. Of the
three that read the router (
student/imports.test.ts,student/side-column.test.ts,take-test/focus-layout.test.tsx) none does; typecheck accepts any page behind any path, and the canary asks which chunk holds a module, never which path loads it. The proof here is that the base'sadminTreeand the moved one are identical onceteacher/and prettier's wrapping are stripped. The test belongs to T-R4.5b, which rewrites the table: pair eachpath(orindex) with itsimport("@/…"), and allow ahandlebetween the two, because every screen task adds one there. -
T-R4.5b: the tree moved, the screens did not.
router.tsx'steacherTreeis the oldadminTreeatpath: "teacher": the sameTeacherWorkspaceguard, the same lazyAdminLayout, the same 26 route objects in the same order, nohandle./teacher/assignments/:id/attemptsstays a real route until T-R4.25.homePathForandStudentAreasend a holder of the teacher or the admin workspace to/teacher(a user with the admin workspace alone meets the guard's 403 there, as at/adminbefore; R5 changes that), andhandoff.tslooks for the teacher frame at/teacher.AccountMenu's default Settings address, which onlyAdminLayoutrelies on, is/teacher/settings;auth/account-menu.test.tsxpins it. -
T-R4.5b: the redirect is one route and one pure function.
{ path: "admin/*", element: <LegacyTeacherRedirect /> }sits at the top level besideauthTree, outsideRequireSession. The splat also matches the bare/admin, so the second route the plan names would be one no test could tell from its absence.app/legacyTeacherPath.tsmaps the prefix (/adminto/teacher,/admin/<rest>to/teacher/<rest>, anything else null), in any letter case, because the router matches that way, and leaves the rest of the path as it was written; the element is a<Navigate replace>that keeps the query and the hash. It knows nothing of who asks: a signed-out visitor reaches/loginwithnextalready at the new address, and a student gets the 403 at/teacher/…. It is imported eagerly and lives until R5. -
T-R4.5b:
next=has no map.destinationAfterSignInis unchanged: anextof/admin/xsaved before the release comes back as written and reaches the redirect route by itself.legacy-teacher-redirect.test.tsxpins both halves, the second against the app's own route table with its lazy pages replaced by stubs. -
T-R4.5b: the sweep was a script, kept outside the repository with its before and after
lists: replace
/adminwherever it is a route (the\/adminof a URL regex included), then prettier on exactly the files changed.web/src: 43 files, 114 hunks, two JSX links re-wrapped inImportReviewPage.web/tests: 74 files, 212 hunks, two statements re-wrapped (attempts/papers.test.tsx,auth/permissions.test.ts). Two tests name the tree without a slash and follow it:student/imports.test.tsreadsteacherTree, andstudent/side-column.test.tswalks"teacher". No case was removed and no assertion changed its meaning. 403 lines said/adminbefore (137 insrc, 266 intests); the sweep left 67, and 69 do now (8 insrc, 61 intests). -
T-R4.5b: what still says
/admin, and why. Inweb/src: the two API paths that keep the prefix (/admin/docs-sessioninfeatures/auth/api.ts,/admin/users/{id}infeatures/students/api.ts), the generatedlib/api/schema.d.ts, and the redirect's two files. Inweb/tests: the redirect's two tests, the literal test's planted lines, and the two old addresses at whichteacher-landing.test.tsxexpects no teacher frame. Outsideweb/, left alone: three comments inseed/01-dev.sqlandseed/02-dev-assignments.sql; and, for T-R4.50,docs/setup/word-import-worker.md:16(/admin/imports), the route lines ofdocs/plan/17-word-import.mdand18-word-import-ux.md,AGENTS.md, and spec §2 (line 503, the three route trees), §5.4 (lines 604 to 606), §6.4 (line 684), §8 and §10.4 (line 1220). -
T-R4.5b: the literal test (
tests/units/app/no-admin-routes.test.ts) readsweb/srcas text and fails, naming the file and the line, for any string or template literal that starts with/admin, in any letter case, followed by/, a quote,?,#, an interpolation or the end of the line. That is wider than "route literals only": a prefix check or a route object is refused too. Allowed, each by an exact rule: the redirect's two files andlib/api/schema.d.tsby their full paths, and the two API paths by their whole literal, so a longer path or one built by hand is refused. The test proves itself on planted lines. -
T-R4.5b:
teacher-routes.test.tspins the route table, the gap T-R4.5a recorded. It readsrouter.tsxas text, pairs eachpathorindexofteacherTreewith the module it imports, finds a route's parents by brace depth, and compares the result with the tree written out row by row (27 rows: the layout and the 26 routes). Ahandlebetween a path and its import changes no row, so a screen task that only adds one leaves the test alone; a task that changes a path, a page or the layout changes its row. A route with noimport()(T-R4.25's redirect) is not a row. A second case pins the nesting by brace depth: every page sits in the layout's children and every import page in the gate's, so a page written beside either with its whole path fails although its row reads the same (T-R4.39's confirm page goes inside the gate). Ahandlechanges no depth either. -
T-R4.5b: the canary names every teacher page, which the Done-when line below still leaves
to T-R4.4: the lead moved it here after T-R4.5a's review.
ADMINgainedfeatures/[^/]+/pages/teacher/; nothing was removed and no assertion changed.ClassesListPageimported eagerly passed the old pattern 12 of 12 and fails the new one in two cases, the first naming the file. T-R4.4 finds the page pattern present and addslayouts/TeacherLayoutonly. -
T-R4.5b: specs changed and not run here; CI's "E2E" and "E2E (live API)" jobs are their
first run. Stubbed:
smoke,theme,settings,settings-navigation,group-authoring,group-preview,group-review,builder-group-recovery,rich-blanks,rich-options,rich-question-content. Live:admin-change-requests,audio-plays,author-and-publish,builder-groups,group-authoring, andsupport/live.ts(signInAsAdminexpects/teacher;assignToClass, which every student-side live spec uses, drives/teacher/assignments/new). -
T-R4.5b: left to others. T-R4.4: the shell switch on
handle.crumb, and the teacher skeleton (skeletonFor("/teacher")is null until it registers one;shell.test.tsxandtheme.spec.ts's "console not yet rebuilt" case say so at the new address). T-R4.23:smoke.spec.ts's three sidebar cases, which now open/teacherinAdminLayout. T-R4.25: the redirect from/teacher/assignments/:id/attemptsto the Students tab. T-R4.39:/teacher/imports/:id/confirm. T-R4.48: the file and component names that still say "Admin". T-R4.50:AGENTS.mdand the spec. Done when: - Two pull requests: T-R4.5a the page moves (the 22 teacher pages; student and auth pages
stay); T-R4.5b the router at
/teacher,LegacyTeacherRedirect,homePathFor, the guard,handoff.ts, the sweep insrcand intestsas two mechanical commits, and the literal test. A task that depends on T-R4.5 waits for T-R4.5b - Routes under the guard and, until its screen task,
AdminLayout:/teacher,/teacher/assignments(/new,/:id,/:id/edit),/teacher/attempts/:id,/teacher/grading,/teacher/classes(/:id),/teacher/students,/teacher/tests(/:id,/:id/edit),/teacher/imports(/new,/:id,/:id/review),/teacher/question-bank(/new,/:id,/groups,/groups/:id),/teacher/media,/teacher/settings/:section?; a screen task addshandle.crumbandhandle.width, which moves its route toTeacherLayout./teacher/imports/:id/confirmis added by T-R4.39; the imports gate route stays -
LegacyTeacherRedirectmaps/admin/*web paths to/teacher/*, keeping query and hash: a prefix map; the attempts rule is added by T-R4.25 with the Students tab. It lives until R5 gives/adminto the Admin console. Anextof/admin/xreaches the redirect route by itself -
homePathForsends aworkspace.teacherholder to/teacher -
router-chunks.test.ts: T-R4.5b addsfeatures/[^/]+/pages/teacher/to the teacher pattern, so every moved page is named by path (before it, an eager import of fifteen of the twenty-two passed). T-R4.4 addslayouts/TeacherLayout; T-R4.48 dropsAdminLayoutandAdminDashboardPage.features/students/andfeatures/media/stay named whole: the narrowerfeatures/media/(pages/|components/(UploadPanel|AssetLibraryDialog))this line once proposed was not adopted (router-chunks.test.ts:51), so T-R4.35 needs no edit - No
/adminroute literal remains inweb/srcoutside the redirect's two files (LegacyTeacherRedirect.tsxandlegacyTeacherPath.ts): a unit test reads every source line for an/adminaddress in any letter case, excludinglib/api/schema.d.tsandapi()call sites, so R2's retained API paths (/admin/docs-session,/admin/users/{id}) do not trip it -
smoke.spec.ts: its three sidebar cases open the index route and stay until T-R4.23 flips that route and removes them. The first route in the new shell (T-R4.23, or T-R4.25 when T-R4.19 is not merged as T-R4.4 merges) addsweb/tests/e2e/teacher-shell.spec.ts;pnpm e2egreen
Depends on: T-R4.4, T-R4.5b, T-R4.13
Touches: web/src/features/search/CommandPalette.tsx, web/src/features/search/useCommandPalette.ts, web/tests/units/search/
Size: S
Done when:
- ⌘/Ctrl+K toggles it, the search button opens it, Esc closes it; 560px panel at 12% from the top; placeholder "Search pages, tests, students…" (vi first)
- Groups: Pages (in R4 only Dashboard, Assignments, Grading, Tests, Question bank, Students,
Classes, Media, Settings, each filtered by the item's permission), Assignments (up to 4,
status hint) and Students (up to 3, first class hint), then Tests (up to 4) and Questions
(up to 3), which exist today; matching is accent-insensitive. Assignments needs
qonlistAssignments(T-R4.13); a Students result opens/teacher/students?q= - ↑/↓ move, Enter opens and the "Nothing matches" line on no results are kept (DG-36)
- Unit tests: keyboard navigation, no-results line, a destination the user lacks is absent
Depends on: —
Touches: migrations/00085_add_users_profile_fields.sql, migrations/00086_add_users_preferences.sql, api/openapi.yaml, server/gen/openapi/, web/src/lib/api/schema.d.ts, server/internal/modules/identity/{domain/user.go,application/command/rename.go → update_profile.go,application/command/update_preferences.go (new),application/query/current_user.go,repositories/store.go,http/account.go}, server/internal/modules/classes/repositories/{postgres.go,lookup.go}, server/internal/modules/assignments/repositories/student.go, the dashboard's zone port, web/src/features/auth/useSession.ts, web/src/features/auth/profileSchema.ts, web/src/lib/theme.ts, web/src/lib/i18n/index.ts, web/src/lib/i18n/datetime.ts, docs/plan/20-data-model.md
Size: M
Done when:
-
add_users_profile_fields:display_name text(1–80 after trim),phone text(^[0-9+ ]{6,20}$),avatar_key text,locale text CHECK IN ('vi','en'),time_zone text(≤64). All nullable. Down drops the five columns -
add_users_preferences:preferences jsonb NOT NULL DEFAULT '{}'with a CHECK that it is an object of at most 8192 bytes. Down drops it. An integration test inserts a user with the R3 column list and succeeds - Contract:
CurrentUser(the caller, R2's split) gains optionaldisplayName,avatarUrl,phone,locale,timeZoneandpreferences(UserPreferences:themelight|dark|system,compactTables,largerTestText,assignmentDefaults{durationMinutes1–600,shuffleQuestions,showScore,blockCopyPaste,requireFullscreen}, all optional,additionalProperties: false).User(someone else) gains onlydisplayNameandavatarUrl, so one person's phone, locale, time zone and preferences never reach another -
PATCH /auth/me(x-permission: self) takes{fullName?, displayName?|null, phone?|null, locale?, timeZone?}withminProperties: 1; an unknown time zone (time.LoadLocation) answers 400VALIDATION_FAILEDontimeZone; the response is the wholeCurrentUser -
PATCH /me/preferences(self) merges top-level keys and returnsUserPreferences; a body over the cap answers 400. T-R4.10a adds the first/me/*operations and with them/me/inhttpx's private trees and its test; this task addsPATCH /me/preferences: R2's tree rule (/me/*accepts onlyself) andTestPermissionTreesMatchPathscover it - Where a student sees the teacher (
MyClass.teacherName, the assignment intro, the/join/previewteacherName), the name isdisplay_name, falling back tofull_name. The PR carries the leak review for/join/preview: a name the teacher chose for students, no new field - Web: after sign-in the session applies the server locale and theme and mirrors them to
quizzivy.localeandquizzivy.theme; a stale local value never overrides the server;lib/i18n/datetime.tsexposessetDisplayTimeZone, withAsia/Ho_Chi_Minhas the default - Tests: identity unit tests for validation; repository integration tests for merge and the
cap; a contract fixture test (ajv) that also asserts
Usercarries none ofphone,locale,timeZoneorpreferences; a web unit test that a server theme applies and mirrors;20-data-model.mdrecords both migrations
Migrations 00085 and 00086 add nullable profile fields and bounded object preferences. Self-scoped PATCH preserves omitted profile keys, clears only the documented nullable fields, and merges preference top-level keys with atomic audits. Private fields appear only in CurrentUser; public display names fall back to full names. Join preview exposes the same student-facing name field and no private profile data.
The frontend applies acknowledged account locale/theme/display zone/larger-test-text over browser mirrors, preserves mounted drafts, and keeps explicit Retry on failed preference writes. Same-page actor generations, serialized cookie transports and checked global draft/cache cleanup prevent old-account results from affecting a replacement account. Refresh remains single-flight. A 20-second admission/transport status bound and 10-second cleanup status bound do not abandon raw ownership; a timed-out admission cannot later sign in automatically. Cross-tab cookie ordering remains outside this task.
Display consumers react to the accepted zone. Existing teacher wall-clock inputs and their rules preview retain fixed-HCM interpretation until Task43; full redesigned profile controls remain43 and photo operations8. Compact-table and assignment-default consumers are not claimed implemented here. Anonymous preferences stay local and omitted defaults are read without materializing server writes. Unsupported browser zones preserve the stored value, report compatibility and temporarily display HCM without persisting that fallback.
Independent source reviews, actual PG18.6 app/migrate profile/preference tests with checked fixture cleanup and retained audits, unchanged high-risk canaries, contract generation/type/lint, hosted CI and the 12-family native cookie/IndexedDB/presentation campaign passed. The native suite used real browser cookie/IDB mechanics with controlled HTTP fixtures; server authority has separate Go/live CI coverage. Five-width/two-theme image acceptance includes the separately captured phone account menus. Earlier failures and repairs remain in the takeover evidence. This completes Task7 within R4, not a production release.
Depends on: T-R4.7, T-R4.53
Touches: server/internal/platform/imagesafe/ (new), server/internal/modules/identity/{application/command/set_avatar.go (new),http/account.go}, server/internal/core/router/ratelimits.go, server/internal/core/wiring/identity.go, an object-store port in server/internal/modules/identity/application/ports, server/internal/core/router/tests/testdata/permissions.golden, api/openapi.yaml (IMAGE_DIMENSIONS is a new ErrorCode value), generated code
Size: S
As built (2026-10-10):
-
Touched beyond the list:
core/adapters/photos.go(the identity port's implementation and the avatar limits),identity/{domain,repositories/avatar.go,application/{app.go,ports,command/remove_avatar.go, query/avatar_url.go,internal/support/avatar.go},http/avatar.go}, the pinned counts incore/router/tests/{permissions_test.go (116 to 118 gated operations),me_test.go (6 to 8 under /me/), group_authoring_test.go (11 to 12 body budgets),principal_limits_test.go}, and inserver/tests/the two isolation entries, a 300 x 300 body for them andprofile_photo_flow_test.go. No migration:users.avatar_keyhas existed since 00085. -
The checks run cheapest first, and nothing is decoded until the sixth: the request cap (2 MiB +
16 KiB for the multipart framing, 413 before the handler), the multipart
filepart (400), the read through aLimitReaderof the limit plus one byte (413MEDIA_TOO_LARGE: a file of exactly 2 MiB passes, one of 2 MiB and a byte does not), the file signature (PNG or JPEG, 415MEDIA_TYPE_UNSUPPORTED; the part's Content-Type is never read),image.DecodeConfig(415MEDIA_UNREADABLE), the sides, the memory estimate and, for a JPEG, the count of scans (415IMAGE_DIMENSIONS), then the slot, the decode, the crop, the resample and the encode. -
One
imagesafe.Gateof two slots serves the process, built inwiring/identity.goand meant to be handed to R5's logo upload. The slot is taken after the file is read, so a slow uploader holds a connection and never a slot. A waiter leaves when its request context ends. No "busy" answer and no new code were added (decided): waiters stay bounded by the request context and the server's timeouts, each holding at most 2 MiB of encoded bytes. -
imagesafe.MaxDecodedBytesis 48 MiB (decided; 64 was refused: Argon2 can hold 256 MiB of a 512 MB machine). The estimate comes from the header: the decoded image by colour model, and for a progressive JPEG theint32coefficient block the standard decoder keeps per component (4 bytes a pixel per full-resolution component, read fromimage/jpeg). A progressive 4:4:4 JPEG at 2048 x 2048 needs 60 MiB, over about 1830 pixels a side; a progressive CMYK one over about 1450; both answer 415IMAGE_DIMENSIONSbefore any allocation. 16-bit PNG at 2048 x 2048 (32 MiB), baseline JPEG and 4:2:0 progressive are admitted. A test holds the estimate between 0.8 and 1.6 times what the decoders really allocate for seven kinds of image. The bound for two slots is 96 MiB of image memory. - The box filter is exact area averaging in integers (centred square crop, any scale including a small image scaled up, premultiplied alpha, one pass), so a transparent pixel adds no colour and the result is the same on every machine. The output is always 256 x 256, encoded by the standard PNG encoder, which writes IHDR, IDAT and IEND only.
- EXIF orientation is applied, then dropped (decided). The reader takes the first Exif APP1 segment, reads IFD0 only and never follows a next-IFD offset, so no chain can loop; an IFD that declares more than 256 entries or runs past the block, and any wrong byte order, magic, type, count or value, is ignored whole. The transform runs on the 256 x 256 result.
-
Both operations answer 200
CurrentUser, and everyCurrentUser(login, Google sign-in and link,GETandPATCH /auth/me) carriesavatarUrl, a presigned GET valid 24 hours, when the user has a photo.User(someone else) is unchanged: a list that shows other people's photos (admin Users, the roster) needs a key and a signature per row and gets them in its own task. The signed URL's path names its object (avatars/{userId}/{uuid}.png), which is no secret from its owner. -
The write is one statement: a data-modifying CTE sets
avatar_key, returns the old key and writesuser.avatar_setoruser.avatar_removed(the diff names no key). Concurrent writes chain: each deletes the key the other replaced. A failed write deletes the new object; a failed delete of the old one is logged and not returned. A crash between the put and the write, or a failed delete, can leave one unreferenced object that no response reaches; deleting a user does not delete the photo yet, and a sweep of theavatars/prefix is the fix for both (R5's anonymisation, T-R5.17a, should delete it). -
Rate limit:
PUT /me/avataris 10 an hour per user inPrincipalRateLimits();DELETEis not limited. A deployment with no bucket answersPUT501 and signs nothing;DELETEstill clears the key. -
Security review (2026-10-10, approve with changes; SR-472-1 to 4, all fixed in the PR). (1) The
standard JPEG decoder makes a full pass over its coefficients for every scan, about 2.1 ms a scan at
2048 x 2048, with no scan limit and no way to cancel it, so a progressive file of 20,000 near-empty
scans (18 bytes each) held a slot for 32 s and a 2 MiB one would for minutes. Only the input can bound
that time: the SOS markers after the SOF are counted before the Gate and more than
Limits.MaxScans(the constantMaxScans, 32; libjpeg's script writes 10 for colour and 6 for grey) answersIMAGE_DIMENSIONS; the same cap bounds a baseline file with many scans. (2) The PNG estimate counts an Adam7 file at 3/2 (the image and its largest pass) and a grey file with atRNSchunk at what the decoder makes of it (4 bytes a pixel, 8 at 16 bits); the largest admitted shapes need exactly 48 MiB and the bound is inclusive, soMaxDecodedBytesdid not move. (3) A recovered decoder panic is wrapped (ErrDecoderPanic) and logged at Warn by the adapter; no test forces a panic inside the standard decoders, so only the adapter's side is tested. (4)setAvatartakes 2 MiB + 16 KiB so that 2 MiB is reachable, and a nil multipart reader answers 400, not 501. -
The web half is T-R4.43. A phone photo (4032 x 3024) is over 2048 on its long side and answers 415
IMAGE_DIMENSIONS, so the client downscales on a canvas to at most 2048 on the long side before it uploads (F-45). The server's limits do not change. Done when: -
PUT /me/avatar(self, multipart,x-max-body-bytes2 MiB): the type is sniffed (PNG or JPEG only, 415 otherwise);image.DecodeConfigruns before any decode and refuses under 200×200 or over 2048×2048 (415IMAGE_DIMENSIONS); decoding runs under a semaphore of two; the image is downscaled to 256×256 with an in-repo box filter and re-encoded as PNG, which drops EXIF; stored atavatars/{userId}/{uuid}.png; the previous object is deleted best effort -
DELETE /me/avatar(self) clears it;avatarUrlis a presigned GET valid 24 hours -
platform/imagesafeis reusable by R5's logo upload; no new dependency - Rate limit: 10 an hour per user in
PrincipalRateLimits(), keyed byratelimit.PrincipalKey(T-R4.53) - Tests: a small PNG declaring 30000×30000 is refused before decoding; EXIF is gone after upload; a GIF answers 415; replacing a photo removes the old object
Depends on: —
Touches: migrations/00102_add_refresh_tokens_geo_label.sql, server/internal/platform/uaparse/ (new), server/internal/modules/identity/{domain/session.go,application/command/revoke_session.go (new),application/query/list_sessions.go (new),repositories/{refresh_store.go,sessions.go (new)},http/{auth.go,sessions.go (new),geolabel.go (new)}}, api/openapi.yaml, generated code, server/internal/core/router/{router.go,ratelimits.go}, and the tables that pin the operations (permissions_test.go, testdata/permissions.golden, principal_limits_test.go)
Size: M
Done when:
-
add_refresh_tokens_geo_label:geo_label text(1 to 80 characters when set), nullable. Down drops the constraint and the column - At issue the label is built from
CF-IPCityandCF-IPCountryonly whenCLIENT_IP_HEADER=CF-Connecting-IPand the request carriesCF-Connecting-IP(headers Cloudflare overwrites). A rotation takes the label of its own request when that has one and copies the predecessor's only when it has none, so the list shows where the device last refreshed (W1-APPROACH item 7), not where it first signed in -
platform/uaparseclassifies a user agent into OS (Mac, Windows, iPhone, iPad, Android, Linux, ChromeOS) and browser (Chrome, Safari, Firefox, Edge, Opera, Samsung Internet); the device label is computed at read time, "Mac · Chrome" (DG-58); no dependency -
GET /auth/sessions(self) returns{items}, one row per live family:{familyId, device|null, deviceKind, location|null, lastUsedAt, current};currentis the family of the refresh cookie, which the/authpath receives -
DELETE /auth/sessions/{familyId}(self) revokes that family, bumps the caller's session epoch and callsPrincipals.Forget(userID); another user's family answers 404; the current family answers 409SESSION_IS_CURRENT(sign out instead).POST /auth/sessions/revoke-others(self) revokes every other family and returns{revoked}; only when it revoked one does it bump the epoch and callPrincipals.Forget(userID). A request whose refresh cookie names no live session of the caller answers 401 from both writes and writes nothing. The current tab's next request gets a 401 and recovers through the single-flight refresh - Tests: rotation copies the label; revoked and expired families are not listed; a revoked
device's access token fails on its next request on the same machine, and within R2's 10 s
cache on any other;
client.refresh.test.tsis green and untouched; another user's family answering 404 has a test of its own, because/authis outside the isolation suite
As built:
-
Files beyond the list:
application/app.go,domain/{errors,repository}.go, thelogin,google_sign_inandrefreshcommands andapplication/internal/support/{service,shared}.go(they carryGeoLabelbesideUserAgentandIP),http/google.go, and the three tables that pin operations:permissions_test.go(116 to 119),permissions.goldenandthePerActorLimits. The handlers are inhttp/sessions.go, notauth.go. -
One transaction, one lock order. Both writes take the lock a rotation takes
(
SELECT … FROM app.users … FOR NO KEY UPDATE), then resolve the current family, then revoke, bumpsession_epoch, audit and commit;Principals.Forgetruns after the commit, so a resolve between the two cannot cache the old epoch. Lock order is users, then tokens, as inRotate, so a rotation in flight either commits first and its successor is revoked, or waits and meets the revoked family (RotateRevoked, a plain 401, not reuse). Audit rows:session.revoked(entityrefresh_token_family, the family) andsession.revoked_others(entityuser, diff{"revoked": n}); neither carries a device or location label. - Order of answers: the cookie is checked first (401, so a caller who cannot be placed learns nothing about ids), then the id (404 for another user's, unknown, revoked and expired alike), then whether it is the current one (409).
-
The current family still refreshes after the bump:
Rotatenever reads the epoch and signs the new token with the freshly loaded one. A test pins it. -
Q3 corrected: a
revoke-otherswith nothing to revoke writes nothing, not even the epoch, so a no-op never 401s the caller's other tabs. The original text bumped always. -
Contract:
deviceKindiscomputer|phone|tablet|unknown(never null),deviceis null anddeviceKindunknownwhen neither the system nor the browser is recognised, the list is capped at 100 with the current session first and the rest bylastUsedAtthenfamilyId,locationis one string, "City, CC". Nox-resourceonfamilyId:/authis outside the scoped trees and the isolation suite, soserver/tests/sessions_flow_test.gocarries the cross-user 404. The 401 uses the existingUNAUTHORIZEDcode. -
Rate limits: the two writes are in
PrincipalRateLimits()(10 a minute and 60 an hour, 5 and 30), keyed by the user. The list takes none. None mints a credential, sotheCredentialMintersis unchanged. -
Location:
identityhttp.WithGeoLabelfollowsWithRefreshCookiein the chain and does nothing unlessCLIENT_IP_HEADERnamesCF-Connecting-IP, and then only for a request that carries that header. The city is percent-decoded, cut to printable text on one line, at most 70 characters; the country is two letters,XXdropped. The label is stored with the token, never logged and never put inaudit_log. -
uaparseanswers from closed lists only. No part of the header reaches a result; a test walks random agents and requires every part to be a member of the lists. An iPad that asks for desktop sites sends a Mac agent and reads as "Mac · Safari". -
Residual risks (R-06 in
30-risks.md): every epoch bump 401s all of the caller's open tabs at once; two tabs on one device may refresh inside one round trip, and the second trips reuse detection and revokes that device. Two revokes in quick succession can leave a retried request with a second 401, which shows the sign-in overlay (T-R4.43 disables the buttons while a revoke is pending). F-49 is the durable fix, a cross-tab refresh lock in the client. -
Security review (SR-473-1, SR-473-2): a stale, already rotated cookie still names its
family here, and does not trip reuse detection as it would on
POST /auth/refresh. That is deliberate: a request racing a rotation still resolves, and the alternative (the head row only) would answer it 401 and leave the web to retry after its refresh. It widens nothing: the holder already acts as that device for the bearer's lifetime, and both writes are limited per user. Migration 00102 validates its CHECK inside theALTER TABLE, under ACCESS EXCLUSIVE; at a school's size that scan is seconds at most. Past a few million rows the expand pattern applies (ADD COLUMN,ADD CONSTRAINT … NOT VALID, thenVALIDATE CONSTRAINT).
Depends on: —
Touches: server/internal/modules/notifications/ (new: domain, application/{command,query}, repositories, http), server/internal/core/wiring/notifications.go (new), server/internal/core/wiring/wiring.go, server/internal/core/router/{modules.go,server.go}, server/internal/core/router/router.go (the Server literal), server/internal/core/core.go, server/internal/core/jobs/prune.go, server/internal/platform/httpx/hardening.go and server/internal/core/router/tests/hardening_test.go, server/internal/core/router/tests/resource_contract_test.go, server/internal/core/router/tests/testdata/permissions.golden, the isolation world and an entry for each of the five operations (mark-read excused and blind), server/internal/modules/notifications/http/tests/language_test.go, web/tests/units/contract/openapi-contract.test.ts, migrations/00080_create_notifications.sql, migrations/00081_create_notification_preferences.sql, api/openapi.yaml, generated code. core/tests/messages_test.go finds the new package by itself
Size: M
As built:
-
Touched beyond the list, as T-R4.0 corrected it on the integration branch:
core/router/tests/permissions_test.go(the count of gated operations, 97 to 102) and four new test files:core/router/tests/me_test.go(the tree through the router),core/jobs/tests/prune_test.go,core/tests/jobs_test.go(Servestarts both prunes; removing the line passed every test) andserver/tests/notifications_flow_test.go(two users over HTTP, and the start-up path). -
The contract is complete for the release. Nine kinds, five events, one closed
NotificationParams, a typedNotificationTarget; the kind table inNotificationKind's description is checked against the Go type of each kind by a test. Operation ids:listNotifications,markNotificationsRead,getMySummary,getNotificationPreferences,updateNotificationPreferences, tagme. T-R4.10b and T-R4.22 add no contract line. -
Notifyis one statement.INSERT … ON CONFLICT (user_id, dedupe_key) DO UPDATEtakes the new kind and target, setsread_at = NULL, and underdomain.AddsumscountandtoGradewith the stored values in SQL;domain.Replacestores the new params.Mergeis a closed enum whose zero value is refused. A producer passescommand.Notify{UserID, Kind, Params, Target, DedupeKey, Merge}; wiring builds the module first and holds it asnotificationsApp, so a port isnotificationsApp.Commands.Notify. The target of a merged row is the newest notice's, whatever the sums are: a paper with an answer to mark (targetgrading) followed in the same bucket by one without (targetassignment) leavestoGrade1 and the targetassignment. So T-R4.10b's rule on the integration branch, "target Grading whentoGradeis above zero", cannot be chosen from one paper'stoGrade, and a target read from the assignment's state after the commit races as a count read first would. That task decides whether this statement or the web (fromparams.toGrade) applies the rule (decided: the web applies it, T-R4.45a). -
The switch is applied inside that statement, which the brief did not ask for and the plan
needs: producers hold only the
Notifyport, so nothing else could apply it. The insert is skipped while the user's row for the kind's event hasin_app = false.domain.Kind.Eventmaps the kinds:assignment.openedandassignment.extendedfall underassignment.due_soon;class.joinedandjoin_codes.rotatedhave no switch and are always written. -
Params are a sealed set of Go types, one per kind (
domain.Submitted,Flagged,Closing,Joined,CodesRotated,Opened,DueSoon,Extended,Ready), encoded through one struct that has exactly the contract's names.Notifyrefuses params of another kind and values outside the contract's bounds; times are stored in UTC. -
A target names what its route needs:
assignmentandstudentAssignmentanassignmentId,attemptandresultanattemptId;gradingandclassesneed neither, and either id may accompany any route. Ids are canonical uuids. -
Dedupe keys are free text of 1 to 200 characters. The format producers are expected to use
is
<what>:<id>[:<bucket>], for examplesubmitted:{assignmentId}:{unix seconds / 900}andflagged:{attemptId}; nothing parses it. -
The list is a keyset on the uuidv7 id.
beforeis declaredkind: none: it bounds the caller's own page and looks nothing up. The page is 20 by default and 50 at most; the store asks for one row more to answernextBefore. -
Mark-read answers 204 whatever matched. One command takes 1 to 100 ids, another marks all;
an absent
idsis the second, and an empty list is refused, so no id can never mean every row.read_atkeeps its first value. No operation answers 403 or 404 for an id. -
Preferences are all five switches for every role. GET fills
inApp: true, email: falsefor a switch never saved; PUT takes each event exactly once in any order (the schema refuses an unknown event and any count but five, the handler a repeated one) and answers them in the enum's order. -
updated_atmoves by the trigger on both tables (spec §13.2), so a mark-read moves it too;created_at, the id and the place in the list never move on a merge. -
Retention:
jobs.PruneNotificationsruns at start-up and daily under its own one-minute budget and deletes rows first written more than 180 days ago, strictly. -
The isolation suite's case for mark-read is excused and blind, as planned, so the proof
that one user cannot read or mark another's is elsewhere: the two-user integration tests in
notifications/repositories/testsand the HTTP flow inserver/tests/notifications_flow_test.go. The suite's snapshot of the other teacher now includes their notifications. -
The migrations are named
NNNNN_…and must be numbered before CI: goose refuses a file whose prefix is not a number. Neither depends on the other. Done when: -
create_notifications:id uuid PK DEFAULT uuidv7(),user_idFK usersON DELETE CASCADE,kind text(dotted key, ≤64),params jsonb NOT NULL DEFAULT '{}'(object, ≤4 KB),target jsonb(object, ≤1 KB),dedupe_key text NOT NULL(≤200),created_at,updated_at,read_at;UNIQUE (user_id, dedupe_key); indexes(user_id, id DESC),(user_id) WHERE read_at IS NULLand(created_at). The app role gets SELECT, INSERT, UPDATE and DELETE by migration 00009's default privileges; no statement. Down drops the table -
create_notification_preferences:(user_id FK CASCADE, event text, in_app boolean NOT NULL, email boolean NOT NULL DEFAULT false, updated_at, PRIMARY KEY (user_id, event))withCONSTRAINT notification_preferences_event_check CHECK (event IN ('attempt.submitted','attempt.flagged','assignment.closing','assignment.due_soon', 'result.ready')), one key per switch of T-R4.44 and T-R4.45 (assignment.due_soonis the student's "Test due soon", which also governsassignment.openedandassignment.extended). R7 and R10 replace this constraint by name. A missing row means the deck's default.emailexists now so R7 adds no column. Down drops the table - Contract, all
x-permission: self:GET /me/notifications?before=&limit=→{items: [{id, kind, params, target, createdAt, readAt}], nextBefore};POST /me/notifications/read {ids?}(≤100; absent means all);GET /me/summary→{unreadNotifications};GETandPUT /me/notification-preferences→[{event, inApp, email}].NotificationKindand the event keys are enums;targetis a typed route (assignment,attempt,grading,classes,result,studentAssignment) -
SecurityHeadersadds/me/to itsCache-Control: no-storeprefixes;TestSecurityHeadersOnSuccessFailureAndPreflightasserts no-store on one path per private tree (/auth,/app,/teacher,/admin,/me) and that/public/statuskeeps its own publicmax-age - Text is rendered by the web from
kindandparamsin the reader's locale;paramsare a Go struct per kind, never markup. A name inparamsis a copy: R5's anonymisation (T-R5.17a) deletes or scrubs notifications that name an anonymised user -
Notifyupserts on(user_id, dedupe_key)and can merge counts intoparams; a merge of counts is computed inside the oneINSERT … ON CONFLICT DO UPDATEstatement, never from a row read first; an integration test of N concurrent calls on one dedupe key pins it - The daily prune in
core/jobs/prune.godeletes notifications older than 180 days - The student-payload walk (R3's leak test) covers
/me/notifications; the operations join the isolation suite's two-user/me/*pair (user A cannot read or mark user B's): thex-resourceenum gainsnotification, andPOST /me/notifications/readdeclares it for itsidsbody field - Tests: unit (upsert merge), integration (cursor paging, mark one and all, cap of 100 ids,
preferences default), contract;
20-data-model.mdrecords both tables and a new deviation row forparams jsonb
Depends on: T-R4.10a, T-R4.11, T-R4.12
Touches: server/internal/modules/{attempts,assignments,classes}/application/ports/ (new Notifier ports), server/internal/modules/attempts/application/{command/submit.go,command/finish.go,internal/support/integrity.go}, server/internal/modules/classes/application/command/enrol_new_member.go, server/internal/modules/classes/application/command/enrol_existing.go (the signed-in student's join), server/internal/modules/notifications/{application/command/materialise_due.go (new),repositories/due.go (new)}, server/internal/core/wiring/*.go, web/src/features/assignments/pages/AssignmentIntroPage.tsx, web/src/lib/i18n/locales/, web/tests/units/student/intro.test.tsx
Size: M
As built (2026-10-10):
-
No migration, no contract line. The unique key
(user_id, dedupe_key)and the indexesclass_members_user_idx,assignment_classes_class_idxandassignment_students_user_idxserve everything below;00102is not claimed. The contract changed in prose only: the descriptions ofextendAssignment,setStudentOverrides,listNotificationsandgetMySummarysay whatnotifyand the due-time write now do. -
Telling after commit is one helper.
shared/cqrs.Announceruns abuildthat reads what a notice says and returns theNotifycommands, then delivers them in order under a ten-second budget on a context that keeps the request's values and not its end. A failure is logged once with the count undelivered and never returned; a nil handler reads nothing. TheNotifierports in attempts, assignments and classes are the notificationsNotifyhandler, as classes' already was.WithNotifieris the only wiring (core/wiring), so an application built without one tells nobody and every existing test is unchanged. -
Who hears of a paper.
visibility.PaperReadersisvisibility.Papersread the other way: the assignment's creator, and the teacher of a target class when the student is a member of a class they teach, an account they created, or a named target of an assignment they created. The plan's "the teachers of its target classes" is read as the teachers of the target classes the student is in: otherwise a teacher of class Y would be told a paper they cannot open. A test holdsPaperReadersequal toPapersover a world with every route to a student. Disabled teachers are not written to. -
Attempts return what a write brought about.
Submit,Save,FlushandExpireIfDuereturndomain.Milestones{AttemptID, HandedIn, Flagged};Finishis announced by its command withGraded.deriveFocusLossreports the false-to-true move offlaggedwithRETURNING NOT old.flagged AND new.flagged, so the integrity policy flags a paper once and a later absence on a flagged paper tells nobody; the limit's ownUPDATE flagged = truereports the same way. A teacher's flag and awarnpolicy never reach the code at all. A paper closed by time (timed_out) is a hand-in. The command then asksBriefing(attemptID, now)for the title, the student's name, the focus-loss count, whether an answer still waits for a mark (toGradeis 0 or 1 per paper, summed bydomain.Addin SQL), whether the review policy shows the score under the student's own close, and the readers. A voided or missing attempt answersErrNotFoundand nothing is sent. -
attempt.submittedis keyedsubmitted:{assignmentId}:{unix / 900}withAdd; its target is the assignment (the web sends a positivetoGradeto Grading, T-R4.45a).attempt.flaggedis keyedflagged:{attemptId}withReplaceand targets the attempt.class.joinedis keyedjoined:{classId}:{studentId}:EnrolResultgainsStudentNameandTeacherID, read in the enrolment's transaction, and bothEnrolExistingandEnrolNewMembertell once it commits, not for a repeat join.classes.teacher_idisNOT NULL, so a class always has a teacher to tell. -
result.readyon finish is sent whenFinishsucceeds andreview_show_score AND (release = 'on_submit' OR now >= the student's close); a refused finish sends nothing and a second finish sends the same key again (the store keeps one row, unread again). The row holds the title; the target holds the attempt and the assignment; no score or band is read into it, and a test checks the stored JSON. -
assignment.extended.extendAssignmentwithnotifytells every enabled, student-like student of a published assignment whose own close the extension changed: those without an override and those whose override closes before the new close (an override at or after it keeps its close and hears nothing).setStudentOverrideswithnotifytells the students named when the request setsextendByorclosesAtand their override now closes after the assignment's own; attempts and time limit move no close and tell nobody. The key isextended:{assignmentId}withReplace, so the latest close replaces the earlier notice. The recipients are read after the commit, so a student is told the close in force then. -
MaterialiseDue(userID, now)runs first inGET /me/summaryandGET /me/notifications, behind an in-process throttle of five minutes per user (a failed run forgets its slot; a map above 4,096 entries drops the expired). It reads the due notices in one statement (Postgres.Due) and writes them in one (InsertAbsent,ON CONFLICT DO NOTHINGunder the same switchUpsertapplies), so a row the user already holds stays as it is, read or not. A failure is logged by the transport and does not fail the read. Moments lie in (now − 7 days, now]:-
opened:
greatest(opens_at, published_at), while the student's close is ahead; -
due soon: the student's close − 24 h and − 1 h (
schedule.CloseOfwith their override and the early close), the day's only while the hour's moment is still ahead, so a first read inside the last hour yields the hour's alone (decided at review, Q1), while the close is ahead, they have no hand-in (a submitted, timed-out or graded attempt, or one left in progress past its deadline) and have an attempt left (non-voided attempts belowmax_attemptsplus their extra attempts), and the test was available at that moment; -
closing (teachers): the assignment's close − 1 h, with the number of enabled student-like members
of its roster that
visibility.Papersshows the user, who have no hand-in and whose own close is the assignment's (an override that reaches later leaves a student out); nothing when the count is 0; -
ready (students): the student's close for an
after_closerelease that shows the score, for each handed-in attempt with no answer waiting for a mark. The keys of the reminders and of the closing notice name the close (DueSoonKey,ClosingKey), so a close that moves earns them again. A student is a target only if enabled and inapp.student_like_roles. The read skips an assignment whose close is before the lookback, since no kind can still be earned from it; on 12,100 users, 3,000 assignments and 45,000 attempts a student's read runs in about 1.1 ms and touches about 150 buffers, a teacher's in about 1.2 ms (EXPLAIN at the size production will reach is T-R4.52's).
-
opened:
-
The Test intro reads the "Test due soon" switch through
useDueSoonOn, only in the scheduled state; the line stays T-R3.8's while the switches are unread, unreadable or off. -
Decisions the plan did not make (build the default; each is one line to change):
-
result.readyat a close is sent for a paper that needs no marking, even if the teacher never "finishes" it; a paper waiting for marks is announced byFinishinstead; -
attempt.submittedcounts a timed-out paper, and a paper closed by the focus limit, as a hand-in; - the audience of a paper is read after the commit, so a teacher who stopped teaching a class between the two is not told. Done when:
-
- Each producing module declares a
Notifierport typed as the notificationsNotifyhandler (AGENTS: a cross-module need is a port typed as the other module's handler); calls run after the producing transaction commits, and a failure is logged, never returned - Teacher recipients are the assignment's creator and the teachers of its target classes,
deduplicated, each subject to their preferences:
-
attempt.submitted(default on): dedupesubmitted:{assignmentId}:{15-minute bucket}, params{title, count, toGrade}, target the assignment; the web sends a positivetoGradeto Grading for a reader who can grade (T-R4.45a, "As built") -
attempt.flagged(default on): when the integrity policy flags an attempt, dedupeflagged:{attemptId}, params{studentName, title, focusLost}, target the attempt sheet -
assignment.closing(default on): a due-time item at close − 1 h while someone has not handed in, params{title, notSubmitted} -
class.joined(no switch; the deck draws none): on a join by code
-
- Student recipients:
-
assignment.opened,assignment.due_soon(close − 24 h and − 1 h, while the student has not handed in and has attempts left) andassignment.extended(T-R4.12 with notify on), all under the "Test due soon" switch -
result.readyunder "Result ready": on finish grading when the review policy shows the score now, or as a due-time item at close forafter_close. It carries the attempt id and the title, never a score or band: the web shows the score from the result query, which applies the policy in force when it is read
-
-
MaterialiseDue(userID, now)runs insideGET /me/summaryandGET /me/notifications, at most once per five minutes per user (in-process map); it inserts items whose trigger time lies in (now − 7 days, now] withON CONFLICT DO NOTHING; there is no timer and no background job - Effective close times include per-student overrides (T-R4.12) and early closes
- The Test intro's line under "Opens …" (
student.intro.opensHint) becomes the deck's "We will notify you when it opens." for a student whose "Test due soon" switch is on, now thatassignment.openedexists; with the switch off it stays T-R3.8's "You can start once it opens." (web/src/features/assignments/pages/AssignmentIntroPage.tsx, locales,web/tests/units/student/intro.test.tsx) - Tests: every producer (unit and integration); materialisation boundaries (exactly
T − 24 h, closed early, extended, an override for one student); preferences off writes
nothing; a teacher of class X receives nothing about class Y; no
result.readyrow holds a score -
attempts/application/tests/events_test.gois green and untouched; attempts unit tests run before and after (high-risk area)
Depends on: —
Touches: migrations/NNNNN_add_assignment_review_options.sql, migrations/NNNNN_add_assignments_student_note.sql, api/openapi.yaml (ReviewPolicy, Assignment, AssignmentInput, StudentAssignmentDetail, ErrorCode, the student result schema), generated code, server/internal/modules/assignments/{domain/assignment.go,application/command/update.go,repositories/write.go,repositories/student.go}, server/internal/modules/attempts/{application/query/result.go,repositories/result.go}, the R3 student result page in web/src/features/results/, web/src/features/assignments/pages/AssignmentIntroPage.tsx (the note block), web/tests/units/student/intro.test.tsx
Size: M
As built (2026-10-08):
-
The migrations are
00099and00100, the next numbers after00098onwork/redesign-r4at9ca8d64f. They were00093and00094when cut fromd6c5f8ccand were renumbered when T-R4.62a landed00097and00098(T-5); renumber them again if another file lands first.00099addsreview_release text NOT NULL DEFAULT 'on_submit'with the named checkassignments_review_release_checkandreview_show_class_average boolean NOT NULL DEFAULT false;00100adds the nullablestudent_notewithassignments_student_note_check(char_length(btrim(student_note, <the 25 whitespace code points of shared/answered>)) BETWEEN 1 AND 500, QA-11-2:btrimalone strips only the space, so a note of a tab or a newline passed the check while the command dropped it), added with the column.StudentNoteOftrims that same set, so the check, the answered rule and the Go trim agree; a test pins the three. Every default is a constant, so nothing is rewritten and the previous release's insert keeps its meaning. D-34 and D-35 record them (D-33 is T-R4.62a's). -
Contract.
ReviewRelease(on_submit,after_close);ReviewPolicy(responses) requiresreleaseandshowClassAverage, andReviewPolicyInput(AssignmentInput.review) makes both optional;AssignmentInput.maxAttemptshasmaximum: 99;studentNoteis optional and nullable onAssignmentInputand onStudentAssignmentDetail, and required and nullable onAssignment; the result gains optionalreleasesAtandclassAverage(0 to 100);ErrorCodegainsASSIGNMENT_LOCKED, the 409 ofupdateAssignment. No operation was added, so the permission golden, the operation count and the isolation table are unchanged. -
The update is partial for the three new fields.
release,showClassAverageandstudentNotearejson.RawMessageor optional on the wire;WriteInputcarriesReleaseSet,ClassAverageSetandStudentNoteSet, and theUPDATEreadsCASE WHEN $set THEN $value ELSE column END. The SPA's full-replace body therefore keeps the stored values, andstudentNote: nullclears the note. A create takes the defaults. The 500 characters are counted before trimming at the contract and after it inWriteInput.Validate; the command stores NULL for a note that is blank once trimmed of the same whitespace. -
The lock is
requireUnlocked, called right afterlockForUpdate, beforepublishedTestForandversionStillFree. While the stored row's derived status atin.Nowisopen, a change totestVersionId,durationMinutesormaxAttemptsanswersASSIGNMENT_LOCKED, with or without attempts, whichever version it names; sending the stored value is not a change. A scheduled or closed assignment with attempts still answersVERSION_LOCKED, and its timing stays editable. The status flips exactly atopens_at(open) andcloses_at(closed), andTestTheLockFlipsExactlyAtTheEdgesOfTheWindowpins both. Two existing tests re-pointed an open assignment; they now do it on a scheduled one. -
after_closereads, inLoadResult(ctx, attempt, now), the stored policy and the close. Untilnowreaches it, the effective flags are all false, so the existing gates select no score, mark, key or explanation, and the grader's comment is not selected either (default for Thuong).releasesAtcarries the close;review.releaseandreview.showClassAverageare echoed as stored. The student card's score flag isreview_show_score AND (review_release = 'on_submit' OR now() >= close); the intro's detail carries the stored flags, the release and the note. Pending manual answers and transcripts keep their own gates. -
The close is
least(closed_at, closes_at), notcoalesce(closed_at, closes_at)as the approach wrote it: closing an assignment already past its window setsclosed_ataftercloses_at, andDerivedStatuscalls that closed atcloses_at.shared/schedulegainsCloseOf(override)(SQL),Close(Go),OverrideandWindow.WithOverride; T-R4.11 passes no override and T-R4.12 fills it. -
classAverageis the mean over targeted, enabled students (assignment_classesthroughclass_members, orassignment_students, the rosterrulesQueryuses) of each student's bestscore_earned / score_totalamonggradedattempts, as a percent rounded to two decimals.voided,submitted,timed_outand not-started students count nothing; a student who left the class or was disabled counts nothing. It is absent belowClassAverageFloor(3) qualifying students, while the reader's result is withheld, before the assignment's close and with the switch off. The page prints at most one decimal. -
LoadResultnames two closes,readerCloseandassignmentClose, bothschedule.Close(closesAt, closedAt, nil)today.WithheldandreleasesAtreadreaderClose;classAverageOfis told whetherassignmentClosehas passed. T-R4.12 passes a student's override toreaderClosealone (R11-2). -
The class average's plan, measured on a seeded scratch database (
EXPLAIN (ANALYZE, BUFFERS)ofclassAverageOf's statement for an assignment that targets three classes and five named students, 125 students, 194 graded attempts; the database was dropped afterwards). No index was added.-
3,000 students in 75 classes of 40, 300 assignments, 24,250 attempts (about 8 per student): the planner reads each of the 125 roster students through
attempts_student_started_idxand filters the assignment and status. 2.7 ms, 1,604 buffers, all shared hits. -
The same with 3,000 assignments and 240,250 attempts (89 MB):
attempts_assignment_status_idx (assignment_id, status)is the access path, one search that returns the 194 rows from 10 buffers. 1.8 ms, 634 buffers.Aggregate actual time=1.5 Buffers: shared hit=634 GroupAggregate on at.student_id rows=122 Hash Join (at.student_id = m.user_id) rows=194 Index Scan using attempts_assignment_status_idx on attempts rows=194 Index Searches: 1 shared hit=10 HashAggregate over the roster rows=125 shared hit=39 Hash Join: Bitmap Index Scan on assignment_classes_pkey x Seq Scan on class_members (shared hit=35) Seq Scan on assignment_students shared hit=1 Index Scan using users_pkey rows=1 loops=194 Execution Time: 1.765 ms -
class_membersis read by a sequential scan, because 3,000 rows are 35 buffers. With sequential scans disabled the roster goes throughassignment_classes_pkeyandclass_members_pkey(class_idleads) with a Bitmap Index Scan of 40 rows per class, and the statement runs in 1.0 ms, so the primary key is the path once the table outgrows a page scan. That one is a diagnostic and not what the planner picks at this size.
-
-
Web. The intro draws the teacher's note between "Before you start" and the action, as the
Student deck does (initials circle, "Note from {name}", the text), only when present, as
plain text with its line breaks. The score sentence follows
release("after the test closes"), which T-R4.27a had listed; its preview sharesstudentRules.ts, and the rules input takes a release that may be absent. The result page says when a withheld result is released, in the lock line and the summary, and draws one muted line under its sentence, "Class average: 62%." The Student deck draws no class-average line; the Tech Lead was told. Fourteen test files that carry a review policy were swept in their own commit. -
Review REV-11 (APPROVE WITH NITS) found no student leak, withholding at selection time, the
lock under the row lock, and DDL, scoping and isolation unchanged. Its fixes: R11-2 above;
R11-3, the plan above; R11-4,
StudentAssignmentDetail.reviewsays in the contract that the intro's flags are the stored ones and take effect only on the result; R11-1, the floor of 3, is recorded as an exposure in DG-137 and listed for Thuong's sign-off; R11-5, the Downs stay. -
Verification VER-11 (PASS WITH FINDINGS) found withholding, the class average, the lock, the
note, the web at 360 and 1280 in vi and en, the migrations and three mutants sound. Fixed here:
QA-11-2, above (the check's whitespace); QA-11-3,
clockTimetakes the locale and writes the day as "12 Oct" in English, so the withheld result's "at 02:34, 12/10" no longer reads as December 10th (Vietnamese keeps "12/10"). Logged outside this task: QA-11-1, a NUL in any free-text field answers 500, which predates it; O1, the average shows withshowScoreoff (T-R4.27a's brief); O2, the 500 characters are counted before the trim, which the contract says. - Not built here: the teacher's field for the note and the release switches (DG-71, T-R4.27a and the Settings tab), and the extension of the close by an override (T-R4.12). Done when:
-
add_assignment_review_options:review_release text NOT NULL DEFAULT 'on_submit' CHECK IN ('on_submit','after_close')andreview_show_class_average boolean NOT NULL DEFAULT false; constant defaults, so no rewrite and no fill trigger. Down drops both -
ReviewPolicygainsreleaseandshowClassAverage: a response schema with both required and a separate input schema with both optional; the MSW fixtures that carry a review policy (about 14 files) are swept in the same pull request as a mechanical commit.AssignmentInput.maxAttemptsgainsmaximum: 99 - While the derived status is
open, a PATCH changingtestVersionId,durationMinutesormaxAttemptsanswers 409ASSIGNMENT_LOCKED(DG-65: only "Test & timing" locks) - With
after_close, the result withholds score and answers until the effective close, using the existing withheld path - The student result gains an optional
classAveragewhenshowClassAverageis on, the window has closed and at least three target students have a graded attempt; it is an aggregate of best graded attempts. Leak review in the PR; the payload-leak key list is unchanged -
add_assignments_student_note:student_note text(1–500 after trim), nullable, so the R3 binary keeps inserting. Down drops it.AssignmentandAssignmentInputgain nullablestudentNote;StudentAssignmentDetailgains it, and this task draws it on R3's Test intro between "Before you start" and the action, only when present (the Student deck's "Note from …": the teacher's initials, "Note from {teacherName}", the text). T-R3.8 left it out: the contract had no field to read. Leak review in the PR: text the teacher wrote for students, no new key on the payload-leak list. The authoring field is placed by a new DG entry (open item) - Tests: unit for the release rule and the three-student floor; integration for the lock and for the note round trip (teacher write, student read); a web unit test for the class average line (vi first)
- The five canaries and the attempts module's unit tests run on the branch point and on the last commit, both runs in the PR (high-risk area)
Depends on: —
Touches: migrations/00101_create_assignment_student_overrides.sql, api/openapi.yaml, generated code, server/internal/shared/schedule/close.go, server/internal/modules/assignments/{domain/{overrides,errors,repository}.go,application/{app.go,command/{extend,set_overrides,delete_override}.go,query/overrides.go},repositories/{overrides,student}.go,http/overrides.go}, server/internal/modules/attempts/{domain/repository.go,application/query/get.go,repositories/{store,result,review_review}.go} (12a); server/internal/shared/schedule/deadlines.go (new), server/internal/modules/assignments/{domain/overrides.go,repositories/{overrides,reopen,student,write}.go}, server/internal/modules/attempts/{domain/{attempt,commands,monitor,repository}.go,application/internal/support/service.go,http/admin_attempts.go,repositories/{store,monitor}.go}, server/internal/core/maintenance/windows.go (12b); server/tests/{join_and_take_flow,isolation_cases,isolation_world}_test.go, server/internal/core/router/tests/{permissions,resource_contract}_test.go and testdata/permissions.golden, docs/quizzivy-spec-v0.3.md, docs/plan/{20-data-model,60-backend-architecture}.md
Size: L, shipped as two pull requests (decided when it started, 2026-10-10).
Split:
-
12a (
feature/t-r4-12a-overrides, migration00101): the table,extendAssignment,setStudentOverrides,listStudentOverridesanddeleteStudentOverride; the student's card and intro,startOrResumeAttemptand the result's reader close and attempt count under the student's override; the isolation entries, the permission golden and thejoin_and_take_flow_test.gocase. Nothing in 12a writesapp.attempts, so an attempt already in progress keeps the deadline it has until 12b. -
12b: the recompute of in-progress deadlines under the assignment's lock and each attempt's row
lock, the monitor's "Extended to …" and R1's maintenance extension over override close times. It
is the part that writes
attemptswhile a student is sitting, so it is reviewed on its own, with the concurrency tests the Senior Tester named.
Each "Done when" line below carries the pull request that closes it. As built (12a, 2026-10-10):
-
The migration is
00101, the next number after00100onwork/redesign-r4(T-R4.11's last). D-36 and20-data-model.md§38 record it. The four checks are namedassignment_student_overrides_{duration,extra_attempts,reason,changes}_check, and the file addsassignment_student_overrides_student_idxand theupdated_attrigger. The reason check reads the length of the trimmed reason the command stores, so a reason of only whitespace is refused by the command and never reaches it. -
Contract. Four operations:
extendAssignment(POST …/extend,teaching.assignments.write), andlistStudentOverrides,setStudentOverridesanddeleteStudentOverride(teaching.attempts.intervene).ErrorCodegainsASSIGNMENT_CLOSED; thex-resourcekinds gainoverride, the id of the student an override is on, read beside the assignment in the path. The list is unpaged ({items}) and declaresx-resource-list: override, which makes 14 teacher lists. The permission golden gains the four, the operation pin goes from 109 to 113, and the Assistant, who holdsteaching.assignments.writeand notteaching.attempts.intervene, is refused 38.StudentAssignmentCardandStudentAssignmentDetailsay their window is the student's own. -
Decisions that were open questions.
-
Merge or replace: a field the request names replaces the stored one and one it leaves out is
kept, so a longer time limit does not take away a student's added attempts. The reason always
replaces, and the audit log keeps every earlier one. No field is cleared singly:
extraAttempts: 0clears the attempts, anddeleteStudentOverridetakes the whole row. A request that would leave a row changing nothing is a 400 onextraAttempts. -
extendBy: minutes (1–10080) added to the student's current close, which is the later of their override's and the assignment's own. It is refused with 409ASSIGNMENT_CLOSEDfor a student whose close has passed, because that is a reopening andclosesAtdoes it.extendByandclosesAttogether are a 400, andclosesAtmust be ahead. -
Early close: the effective close is
greatest(least(closed_at, closes_at), override.closes_at)(T-R4.11). An override that reaches past the assignment's close lifts its early close for that student alone, and closing early later does not remove an override;deleteStudentOverridedoes. -
extendon a draft or a scheduled assignment moves itscloses_atlike an open one. Onlyclosed, by the same database-clock rulereopenAssignmentuses, answers 409; the two operations take complementary sets. -
Reach: every id in
studentIdsmust be an enabled student-like target of the assignment (a member of a target class or named on it) whom the caller reaches (visibility.StudentIDs, or anyone underscope.all). One that is not answers 422VALIDATION_FAILEDfor the whole request,details.studentIdsnaming the ids, and nothing is written. A missing student, a teacher's student and a disabled one answer alike. The list and the delete read only the students the caller reaches, as the monitor does. -
notifyis recorded in the audit entry ofextendand of each override. T-R4.10b is its consumer: it tells the students (see that task's "As built"). - The monitor's contract field for "Extended to …" and the maintenance extension are 12b's.
-
Merge or replace: a field the request names replaces the stored one and one it leaves out is
kept, so a longer time limit does not take away a student's added attempts. The reason always
replaces, and the audit log keeps every earlier one. No field is cleared singly:
-
Reads of one student's window.
shared/schedulegainsOverrideJoin,OverrideSelectandOverrideColumns;WindowcarriesClosedAt, andWithOverridelifts it when the override reaches past it.attempts.RulesandRulesFor(nowRulesFor(assignmentID, studentID)) return the student's window, time limit and attempts, so a start, a resume,Deadlineand the attempt limit follow them without a change to the service.LoadResultpasses the override's close toreaderCloseand its added attempts tomaxAttempts, andassignmentClosestays the assignment's own (R11-2). The teacher's review of a paper counts the added attempts too, so "attempt 2 of 1" cannot be drawn.ForStudentandStudentDetailapply the override at the scan, and the home order follows the student's own close. The class and dashboard counts stay the assignment's. -
Concurrency.
setStudentOverridesholds the assignment rowFOR NO KEY UPDATE, which serialises writers of one assignment and does not block an attempt's insert. The upsert inserts the merged row (§38), because PostgreSQL checksCHECKs on the proposed row before it finds the conflict. -
Deadlines in progress. Nothing here writes
attempts: an attempt running when a teacher extends keeps its deadline until 12b.extendAssignment's description said so; 12b removes the sentence. As built (12b, 2026-10-10): -
No migration, one contract field.
MonitorRow.extendedTo(nullabledate-time, optional) is the only schema change; the descriptions ofextendAssignment,setStudentOverridesanddeleteStudentOverridenow say what they do to an attempt in progress, andclosesAtis "ahead of the database's clock". The operation, permission and isolation pins do not move: no operation is new. -
Every writer of a window recomputes (R12b-1, found in review). The plan named
extendAssignmentandsetStudentOverrides, butupdateAssignmentleavesclosesAteditable on an open assignment (the live lock covers only the version, the duration and the attempts) andreopenAssignmentlifts an early close and sets a later one, so each also takesschedule.LockWindowsbefore the assignment row (forUpdate, beforelockForUpdate) and callsRecomputeDeadlinesfor the whole assignment after its write, with the causesassignment_updatedandassignment_reopened. The recompute only lengthens, so it runs unconditionally: a shorter close, or Close now throughUpdate, moves nothing.window-scheduleis the only other writer ofcloses_at,closed_ator an override.TestEveryWriterOfAnAssignmentsWindowWaitsForTheMaintenanceLockholds(73819, 40)exclusively from a second connection and shows each of the five writers wait for it by its application name inpg_stat_activity;TestEveryWriterOfAWindowTakesTheSameLockstill pins that the three packages name one key. Mutants: no recompute inUpdate, none inReopen, a recompute that also shortens, and no lock inReopeneach fail the test for it. -
The recompute is in the kernel,
shared/schedule.RecomputeDeadlines, because assignments writes the window and the rows it moves are attempts'. One statement: aliveCTE selects the assignment's attempts withstatus = 'in_progress'(all of them, or the named students'),ORDER BY id FOR UPDATE OF at, withleast(started_at + coalesce(o.duration_minutes, a.duration_minutes), CloseOf("o"));movedupdates only where that is later thandeadline_at;loggedinserts oneattempt.extendedaudit entry per attempt moved (deadline_atold and new, the cause, the assignment) frommoved'sRETURNING old.…, new.…. It usesschedule.CloseOf("o"), nevera.closes_atalone (R12a-6), so a student whose override closes later keeps it, and a deadline a maintenance window pushed past the rule stays.status = 'in_progress'is the filter, notsubmitted_at IS NULL: an attempt handed in while the statement waited for its row lock is skipped. An attempt whose deadline has passed is over, swept or not (R12b-2):livealso requiresdeadline_at > now(), on the database's clock as R12a-3 settled, so no writer of a window revives one, and the outcome never depends on whether the sweeper or the student's client got to it first (autosave already answers such an attemptErrDeadlinePassed).TestAnAttemptPastItsDeadlineIsNotRevivedByAnyWriterOfTheWindowputs an in-progress attempt a minute past its deadline beside one still ahead and runsExtend,Update,SetOverridesandReopen; without the filter all four move it.Extendruns it for everyone andSetOverridesfor the named students.DeleteOverridetakes the same locks and does not run it: removing an override can only shorten a student's window, and a deadline never shortens, so a running attempt is left where it is. -
Lock order, always: the maintenance-windows advisory lock
(73819, 40)shared, the assignment row, then the attempt rows in id order (20-data-model.md§28, §38). The five writers take the advisory lock first (schedule.LockWindows) and the assignment rowFOR NO KEY UPDATE(the update keeps theFOR UPDATEit always took); an attempt start takes the same two, the rowFOR SHARE. The advisory lock is the reason:window-scheduletakes it exclusively and then updates assignments and attempts in one statement, so a writer that held the assignment row and then asked for the lock could wait on a schedule that waits on the row.TestEveryWriterOfAWindowTakesTheSameLockpins that the maintenance, availability and schedule packages name one key. -
R12a-5, the start race.
attempts.Store.Createno longer trusts the rules the service read before its transaction. The maintenance guard goes first, as before; the transaction then reads the student's rules again (rulesQuery … FOR SHARE OF a), refuses a student who is not targeted (ErrForbidden) or a window that is not open (Rules.CanStartAt, the service'sCanStartrule), and storesdeadline_atand the test version from that read. The service'sDeadlineonly tells the first guard how far to look; when the in-transaction deadline is later, the guard runs again over the longer span.Createreturns the rules it committed under and the session is built from them. A start that read before an extension committed therefore either waits for it on the share lock and reads the new close, or inserts first, and the extension's recompute finds the attempt.FOR SHARE OF ois not taken: PostgreSQL refuses a row lock on the nullable side of an outer join, and the override is only written under the assignment lock the start already holdsFOR SHARE. -
Evidence for the race.
start_window_race_test.go: a pausable guard stops a start after it passed the guard, an extension commits, the start resumes, and its deadline is the extended one (deterministic); a test-owned transaction holdsFOR SHAREand inserts an attempt while a goroutine'sExtendblocks onassignment.extendeduntil it commits, then the attempt carries the extended deadline; two 16-round loops run start with extend, and extend with resume and submit of one attempt, neither deadlocking nor shortening. Two mutants were run and each fails its test: storing the caller's deadline inCreatefails the deterministic test ("it was cut off by the close it read before"), and a recompute that names nobody onExtendfails the blocked-extension test ("the attempt inserted before the extension committed has deadline … want …"). -
R12a-3. The "closes ahead" check is
SELECT $1::timestamptz > now()in the transaction, so the database's clock judges it;Overrides.Validateno longer compares toNow, which is the audit stamp. R12a-4.student.goorders the home list byschedule.CloseOf("o"). -
R12a-7, decision 3 kept. An override survives a later early close: closing early never touches
assignment_student_overrides, and the student's effective close is stillgreatest(least(closed_at, closes_at), o.closes_at).recompute_test.gopins that a close later than the override's does not take it back. The "Close early" dialog's obligation is in T-R4.26 below. -
The monitor.
MonitorRow.extendedTois the override'scloses_atwhen it is later than the assignment's own effective close (the earlier of its early close and itscloses_at), else absent, so a student whose override closes sooner than the assignment reads nothing. For an assignment closed early it is the time a student kept open by an override has until, which is how the monitor marks them as still open (R12a-7). -
The maintenance extension.
window-schedulealso moves, by the window's length, thecloses_atof each override of a published assignment that falls in the window (closes_at >= starts_at AND closes_at < ends_at), as one more CTE in the same statement, under the same exclusive lock, and auditsassignment.override_extendedper override as System (studentId,closes_atold and new, reasonmaintenance,windowId,databaseRole). The report gainsOverrides, and the dry run counts them without moving any. The restricted role needsSELECT, UPDATE (closes_at)on the table, which R5'sgrant_maintenance_roleadds; until then the extension runs underMAINTENANCE_DATABASE_URLas today. The maintenance tests run as the migrate role, which owns the table, so the restricted-role run is not run here; the statement's column list is the one the grant covers (closes_atis the only column it sets). Done when: - (12a)
create_assignment_student_overrides:assignment_idFKON DELETE CASCADE,student_idFK usersON DELETE CASCADE,closes_at timestamptz,duration_minutes integer(1–600),extra_attempts smallint NOT NULL DEFAULT 0(0–10),reason text NOT NULL(1–500),created_byFK usersON DELETE SET NULL,created_at,updated_at,PRIMARY KEY (assignment_id, student_id), a CHECK that at least one of the three is set, and an index on(student_id). App role: SELECT, INSERT, UPDATE, DELETE. Down drops the table - (12a)
POST /teacher/assignments/{id}/extend(teaching.assignments.write):{minutes (1–10080), notify}movescloses_at; a closed assignment goes through the existingreopenAssignmentinstead (409ASSIGNMENT_CLOSEDhere, a newErrorCodevalue). ReturnsAssignment - (12a)
PUT /teacher/assignments/{id}/student-overrides(teaching.attempts.intervene):{studentIds (1–100), extendBy?, closesAt?, durationMinutes?, extraAttempts?, reason, notify}upserts one row per student; a student who is not a target answers 422 naming them.GETon the same path returns the overrides and declaresx-resource-list;DELETE /teacher/assignments/{id}/student-overrides/{studentId}removes one (bothteaching.attempts.intervene). The dialogs carry a Reason field (T-R4.26) - (12a) The
x-resourceenum gainsoverride.{id}declaresassignment, the DELETE's{studentId}declaresoverrideand the PUT'sstudentIdsdeclaresstudent, so the isolation suite substitutes A's id into one of them at a time - (12b) Both operations recompute, under each attempt's row lock and in the same transaction,
deadline_at = least(started_at + effective duration, effective close)for attempts in progress, only where the new value is later; each change is audited through the data-modifying CTE pattern - (12a) Start and resume use the student's effective window, duration and attempt allowance; the student card and intro show the effective close; for that student alone, a closed assignment with an override still ahead reads as open
- (12a) The override feeds
readerCloseonly (attempts/repositories/result.go, which decides whether a student's result is withheld and when it is released); the class average stays onassignmentClose, the assignment's own close, because it is assignment-level and ignores overrides (T-R4.11, decision 3) - (12b) Monitor rows carry the override ("Extended to …"); R1's maintenance extension also moves
override close times that fall inside the window. In R4 it runs under
MAINTENANCE_DATABASE_URLas today; R5'sgrant_maintenance_rolegrantsquizzivy_maintenanceSELECT, UPDATE (closes_at)onassignment_student_overrides(release-checklist hand-off) - (12a) Tests: unit tests for the effective window (
shared/schedule), integration for a start with an override (duration, close, extra attempt), a non-target student, isolation (the four operations, kindoverride);server/tests/join_and_take_flow_test.gocovers a closed assignment reopened for one student - (12b) Tests: a deadline recomputed for an attempt in progress, under concurrent
extend,startOrResumeandsubmiton one attempt row; the monitor row; the maintenance extension over an override's close under the restricted role (or "not run" where the test database lacks it) - (12a)
attempts/application/tests/events_test.goandpublish_snapshot_test.gogreen and untouched; attempts unit tests run before (at99860300) and after - (12b) The same canaries green and untouched on 12b's branch point and last commit
As built, the closed guard (F2 of #493's review, DG-160). updateAssignment could move the close of
a window-closed assignment ahead, which made it open again as assignment.updated, without
reopenAssignment's required reason and its assignment.reopened audit row. The web locks the Window
card of a closed assignment (T-R4.26); the API now refuses it as well.
-
The rule. An assignment that is closed at the request's clock (
in.Now, as the open lock judges it), by its window or by an early close, stays closed.schedule.Reopensis true, and the update answers 409ASSIGNMENT_CLOSED, when the saved window would make itscheduledoropen, or would start after the early close the row keeps. That last pair is one the schema refuses (closed_at >= opens_at), which answered a 500 before. -
The code.
ASSIGNMENT_CLOSEDis the oneextendAssignmentandsetStudentOverridesalready answer for a closed assignment ("reopened, not extended"), soupdateAssignmentandreopenAssignmenttake complementary sets asextendandreopendo.ASSIGNMENT_LOCKEDsays the assignment is open and its test and timing are locked, which is the opposite case. No new code, so the contract change is text only (the description and the 409), and no operation, permission or isolation entry moves. -
What stays allowed, and why. Everything that leaves it closed: the stored window (a review-options
or note edit re-sends it), an earlier close, a close still in the past,
closeNow, and every other field, which is how the plan has it (DG-65 locks only the open case). A later close on an assignment closed early also stays allowed: the early close is kept and never lifted by a save (closeNowis an action,TestClosingEarlyIsRecordedAndDoesNotReopen), so the assignment is still closed, nothing opens, and the review-options flow already saves that shape. -
Found, and fixed below (F-63).
closeNowbeside a window that opens ahead answered a 500 from the sameclosed_at >= opens_atcheck on every status but a closed one. -
Tests.
domain/tests/reopens_test.go(the rule, edge by edge),repositories/tests/closed_guard_test.go(integration: the refusals write nothing and no audit row after the creation, the permitted saves, early close, and the instant the close passes; mutating the guard away fails three of the four),http/tests/language_test.go(the 409, in both languages) andserver/tests/assignment_closed_guard_flow_test.go(e2e: refusal, permitted edits, reopen, then a later close on the open assignment).
As built, closeNow before the window opens (F-63). closeNow sets closed_at to the request's
Now, and the schema requires closed_at >= opens_at (migration 00018), so three shapes answered a 500:
a PATCH of a scheduled assignment with closeNow, a PATCH of an open one with closeNow and opensAt
moved ahead, and a create with closeNow and a future opening.
-
Decision. The contract stands, so the code changes. A create ignores
closeNow("PATCH only"):Createclears it before validating and no longer writesclosed_atat all (the column is gone from itsINSERT, and so isSchedule.ClosedAtOf).WriteInput.Validategains a pure rule,CloseNow && OpensAt after Now, answering 400VALIDATION_FAILEDwithdetails.closeNow, which covers every status. The check is on the request's clock, the same value the update stores asclosed_at, so it is exact. -
What moved in the closed guard. On a closed assignment, "closed now beside a window that opens
later" is the 400 and no longer the 409, because validation runs before the transaction. The 409 stays
for a window that opens after an early close the row already keeps, with no
closeNow. -
Tests.
domain/tests/close_now_test.go(the rule at a nanosecond ahead, now and behind),repositories/tests/close_now_test.go(the three PATCH shapes, the permitted close, a create that ignorescloseNowfor a scheduled, open, closed and draft window; with the rule removed the first test sees the 23514), andserver/tests/assignment_closed_guard_flow_test.go(e2e: the 400 withdetails.closeNow, the create answering 201 and scheduled). The guard's test counts every audit row after the creation, so a refusal that wrote any is caught (F-63's nit).
Depends on: T-R4.7
Touches: server/internal/modules/assignments/{application/command/duplicate.go (new),domain/{assignment,commands,repository}.go,http/{assignments,duplicate}.go,repositories/{assignments,duplicate}.go}, server/internal/modules/attempts/{application/{app.go,ports/zones.go,query/{item_analysis,results_export}.go,internal/support/service.go},domain/{analysis,repository}.go,http/{item_analysis,results_export}.go,repositories/analysis.go}, server/internal/platform/tabular/csv.go (new), server/internal/core/{wiring/wiring.go,router/ratelimits.go}, api/openapi.yaml, generated code, server/internal/core/router/tests/testdata/permissions.golden, server/tests/{isolation_cases,results_flow}_test.go
Size: M
As built (2026-10-10):
-
No migration, no new
ErrorCode. The contract gains three operations,DuplicateAssignmentInput,ItemAnalysisandItemAnalysisItem, an optionalAssignment.questionCount, and onlistAssignmentsthe sharedqparameter andclassIdas a list. The pins move: 113 operations to 116, 14 teacher lists to 15 (item analysis, which declaresx-resource-list: version-question), the Assistant's refusals stay 38 (it holds both permissions), andPrincipalRateLimits()gainsGET /teacher/assignments/results.csv, 10 a minute and 60 an hour per actor (a heavy read that the plan did not limit; the 20000-row cap bounds one call, the limit bounds the calls). The permission golden gains the three lines and no other. -
Duplicate is one
INSERT … SELECTfrom the original, so a column the copy must carry is named once. The copy pins the same test and version, keeps the time limit, attempts, both shuffle flags, the five review options (releaseandshowClassAverageincluded), the integrity policy and the note to students, opens at the caller'snowand lastscloses_at - opens_atof the original, is a draft (published_atNULL,closed_atNULL) owned by the caller, and is assigned toclassIdsand to no student by name. An early close, the original's overrides and its attempts are not copied.classIdsmay be empty. The original must be reachable (visibility.AssignmentIDs, or any underscope.all) or the answer is 404; its version must be one the caller may assign, bypublishedTestForascreateAssignmentapplies it, so a teacher who reaches an assignment through a class and does not own its test answers 409TEST_NOT_PUBLISHED(open question); a class the caller does not teach answers 400 onclassIds, as an unknown one does. Audited asassignment.duplicatedwithsourceId. -
Item analysis ranges over papers, one per student the caller reaches (
visibility.Papers): the student's latest attempt that is handed in (submitted,timed_out,graded) and not voided, so a retake replaces the first sitting, a voided retake changes nothing, and a paper still in progress is not counted.handedIncounts them. For each question of the pinned version, numbered across sections as the grading queue numbers them,answeredcounts the papers whose saved answer says something (answered.SaysSomething), andcorrectRateis the papers that earned the question's full points (final_score >= points, for an auto-marked and a manually marked answer alike) over the papers that have a mark for it. A question left unanswered counts against it; a manual answer with no mark yet counts for neither side; a question with no marked paper has a null rate and sorts last. Order: rate ascending, nulls last, then number. The prompt excerpt is the plain prompt with its whitespace collapsed, cut at 140 characters. Overdue attempts are closed before the read, as the monitor does. -
The export writes the monitor's roster, not only the papers: one row per target student the caller
reaches (the classes they teach, the individual targets they reach, everything under
scope.all; a disabled student has none), so a student who has not started has a row with status "not started". A member who is not student-like (an Admin who takes tests and joined by code) has a row only once they have an attempt on the assignment, which is the grading queue's rule (a strict student, or an existing paper under the teacher's reach); the monitor draws its roster by the same rule since F-69, so the two list the same accounts. The attempt that stands for a student is the monitor's: the latest that is not voided, else the latest. Class is the names of the target classes the caller reaches that the student belongs to, sorted and joined by;(empty for a student named alone). Version is the number. Score, Max and Percent (one decimal) are filled when the attempt is handed in and no manual mark is outstanding, so a paper awaiting its teacher has none; Submitted is in the caller's own zone (identityEffectiveZonethrough a newports.Zones, wired withWithZonesas the dashboard's is), the header names the zone, and a zone that cannot be resolved falls back toAsia/Ho_Chi_Minh(a fixed +07:00 when tzdata is missing) instead of failing the download. Headers, status words and Có/Không followAccept-Language, Vietnamese by default. Overdue attempts are closed first. -
Limits and refusals. Departure from the plan's "streams": the file is built in memory and written
once, so the status is decided before the first byte; the bound that makes that safe is 20000 rows (the
query reads one more to tell), a few megabytes at most. Over it is
422
VALIDATION_FAILEDwithdetails.ids.idsis 1 to 50 unique uuids (the schema, so 400 otherwise); one id the caller does not reach, or that does not exist, is 404 for the whole request, as a missing one answers, and nothing is read.Content-Dispositionis not in the CORS exposed headers (httpxis outside this task), so the SPA names the file itself. -
platform/tabularholdsEscapeCelland aCSVwriter (byte-order mark, CRLF, every cell escaped), with no dependency beyond the standard library. A cell whose first character after any leading spaces (space, no-break space, ideographic space) is=,+,-,@, a tab or a carriage return, or one of the four full-width forms= + - @, gets a leading'; the first two columns of that list are the plan's, the rest the verification strategy's. R5 extends the package with XLSX. -
listAssignments.classIdrepeats (?classId=a&classId=b, at most 50) and keeps the assignments that target any of them; a class the caller does not teach contributes nothing, as before, andEveryTargetlifts that for a caller who holdsscope.alland lists their own (T-R4.54'sOwn()is unchanged: it narrows who the rows belong to, the class filter narrows within them).qkeeps the assignments whose test title, or the name of a target class the caller reaches, contains the text, accents and case ignored, with%,_and\literal; the facets followqand the classes, as they followedclassId. Every row, and every write's answer, carriesquestionCount, counted asTestVersion.questionCountcounts. -
Evidence. Transport tests on the stubbed queries pin the file (BOM, CRLF, headers, both languages,
escaping, zones, the 404 and 422 answers, the null rate); integration tests drive the real queries,
including exactly 20000 rows and 20001; an end-to-end flow exports, analyses and duplicates through the
router. 25 mutants were run, each killed by a named test: in
tabulara leading-unescaped, leading spaces not skipped, a row written unescaped; UTC times in the export; inDuplicatea published copy, the note not copied, the original's dates, a fixed window length, an unreached original readable; in the listqunescaped and only the first class; in the analysis pending marks in the denominator, the oldest attempt, "more than full", a voided paper, every student's paper, an unmarked question first; in the export an unreached assignment skipped, the cap off by one in each direction, a voided attempt standing, a score on a paper with marks outstanding, a disabled student, overdue attempts not closed, and the export unlimited per actor. The canaries that apply,publish_snapshot_test.goandTestTheSameClientSeqFromTwoSessionsBothPersistwith the rest of their packages, and the attempts unit tests, passed on the branch point63b05880and on the head; no web source changed, so the three web canaries were not run. Done when: -
POST /teacher/assignments/{id}/duplicate(teaching.assignments.write){classIds}creates a draft with the same version, window length and rules; a duplicated draft opens now for the original's length; returns 201Assignment -
GET /teacher/assignments/{id}/item-analysis(teaching.grading) →{handedIn, items: [{questionId, number, type, promptExcerpt (≤140), answered, correctRate|null}]}sorted bycorrectRateascending, nulls last. An auto-marked answer counts when correct; a manual one counts when it earned full points; an ungraded manual answer does not count. Item analysis declaresx-resource-list -
GET /teacher/assignments/results.csv?ids=(teaching.grading, ≤50 ids, ≤20000 rows) streams UTF-8 with a BOM andContent-Disposition: attachment; columns Assignment, Version, Class, Student, Email, Status, Score, Max, Percent, Submitted (actor's zone), Focus lost, Flagged -
listAssignmentsgainsq,classIdas a list andquestionCounton its items -
x-resource: the duplicate'sclassIdsbody field declaresclass; the export'sidsquery declaresassignment - Every cell goes through
tabular.EscapeCell, which prefixes'to a value starting with=,+,-,@, tab or CR; R5 extends the package with XLSX - Tests: escape cases (
=HYPERLINK(…),+1,-2+3,@SUM(A1)), CSV integration, item analysis ordering, isolation for all three - The five canaries and the attempts module's unit tests run on the branch point and on the last commit, both runs in the PR (high-risk area)
Depends on: —
Touches: api/openapi.yaml, generated code, server/internal/modules/attempts/domain/{repository,grading_queue}.go, application/{app.go,query/grading_queue.go,internal/support/grading_queue.go}, repositories/grading_queue.go, http/grading_queue.go, their new external public test siblings, the permission/resource/isolation inventories and web/tests/units/contract/grading-queue.test.ts. Existing Grade, Finish, Submit, Void, by-question reads and teacher serializers stay unchanged.
Size: M
As built:
- One read-only SQL statement applies both existing reach predicates, counts the full filtered set, orders groups and takes the exact first200 items. Frozen teacher projections enrich that prefix without changing student delivery or Grade/Finish.
- New public tests cover historical role reach, filters, both full-count/cap modes, frozen rich keys and media/context, checked rollback/failure cleanup, and actual concurrent Grade plus a fresh queue statement. Committed race fixtures use a second purpose database and retain their owned rows for whole-purpose disposal.
- Server CI provisions that second database, binds its app role only to integration, and checks owner/OID and zero connections before ordinary disposal. The required integration command remains unchanged. This task adds no migration or dependency.
- Local PG18.6 app-role queue/lifecycle and committed-race verification passed after correcting the audit-absence fixture column; the initial failure remains recorded. Exact-head hosted CI passed the whole regression, isolation, migration roundtrip and final canaries. A separately reviewed real-PG campaign killed 15 SQL variants under both app and migrate roles (30 observations); restored source and retained controls remained exact, and both fresh purposes were normally disposed. The rebuilt grading screen and Finish-recovery browser flow remain T-R4.28.
Done when:
-
GET /teacher/grading/queue?mode=student|question&assignmentId=&studentId=is a read-onlyteaching.gradingoperation; omitted mode defaults to student. Closed result schemas require non-nullgroups,items,answersRemainingandstudentsWaiting. - Eligible work is every reachable submitted or timed_out paper's saved answer with
requires_manual = trueandmanual_score IS NULL. In-progress, voided, graded (including inconsistent graded plus pending), missing saved answers and marked zero scores are excluded. Preserve normal AssignmentIDs and Papers reach;scope.allwidens the existing read. Do not apply a new current-role or roster filter to historical papers. - Student groups key by student UUID. Question groups key by the canonical lowercase
assignmentUUID:versionQuestionUUIDpair, so reuse across assignments stays separate. Groups order by earliest eligiblesubmitted_at ASC NULLS LAST, then student UUID or the assignment/question UUID tuple. Items within a group order by the same hand-in time, attempt UUID, global paper ordinal and frozen question UUID. Question number is one-based across section/question paper order, independent of student shuffle order. - Labels are raw current student full names or decimal question-number strings, which the client localizes. Sub is the first returned item's assignment title. Names and titles are live text; frozen fidelity applies to published question/group/key content.
- Count the complete filtered eligible set before grouping and the deterministic first
200-item cap.
studentsWaitingcounts distinct student UUIDs. Return only represented groups, each with its complete filteredremaining; a split group may have more work than returned items. One SQL statement makes reference flags, membership, counts, groups and prefix coherent; later immutable-content enrichment does not imply a live whole-response transaction snapshot. - Supplied assignment and student references are independently reached; foreign and
missing IDs return the same 404, and reachable empty intersections return 200 with
empty arrays and zero counts. Here
studentIdnarrows an already authorized historical paper-taker population, without adding student-account read/write authority. Reach is either strict current StudentIDs plusstudent_like_roles, or an existing paper under BOTH normal AssignmentIDs and Papers reach, independent of pending work or paper status. A privileged account without reached history cannot use the strict-student branch. - Flat items require
attemptId,questionId,assignmentId,assignmentTitle,studentId,studentName,questionNumber,type,prompt,answer,points, nullablescoreand nullablecomment. Reuse teacher rich prompt, media, audio policy, shared context,options[].isCorrect,blanks[].acceptedAnswersandsampleAnswercontracts. Never flatten multiple blanks into a new top-level accepted-answer list. - The path is
/teacher/*; no answer-key or private account field reaches/app/*. Optional group/media services retain honest 501 behavior. The existing contract leak sweep, write contracts and review/transcript policies remain unchanged. No migration, dependency, notification producer or completion-candidate API is added. -
x-resourcedeclares assignment and student query references, documenting the latter's historical paper-filter meaning;x-resource-listis attempt. Add the exact operation to permission, resource and teacher-isolation inventories derived from the assigned base. - Public tests cover both modes/default, equal names/titles, repeated assignments/versions, NULL and tied hand-in times, absent/manual-zero/non-short pending answers, historical reach and foreign/missing parity, independently empty intersections, filters beyond the global 200 prefix, split and unrepresented groups, full counts, frozen rich keys/context, and genuine queue-versus-Grade refetch. Preserve existing Grade/Finish contention.
- The five canaries and attempts units execute before source edits and on the final candidate, with real integration-tagged canaries and the actual router-chunks build. Use invocation-owned verification resources, preserve failures and prove fixture cleanup; retained purposes and legacy ignored row cleanup are not authorized fixture shortcuts.
Depends on: —
Touches: migrations/00087_add_questions_level_skill.sql, migrations/00088_add_test_version_questions_level_skill.sql, api/openapi.yaml, generated code, server/internal/modules/questions/{domain/question.go,domain/input.go,repositories/store.go,repositories/search.go,repositories/facets.go}, server/internal/modules/tests/repositories/{publish_snapshot.go,publish_group_snapshot.go,list.go}, the copy paths server/internal/modules/tests/repositories/{version_draft,duplicate,duplicate_graph,group_copy}.go and the questions duplicate command
Size: M
Done when:
-
add_questions_level_skill:level text CHECK IN ('pre_a1','a1','a2','b1','b2','c1','c2')andskill text CHECK IN ('grammar','vocabulary','reading','listening','writing', 'speaking')onquestions, nullable (DG-62). Down drops both -
add_test_version_questions_level_skill: the same two columns ontest_version_questions, nullable, no backfill. Down drops both - Contract:
QuestionLevelandQuestionSkillenums;QuestionInputandAdminQuestiongain nullablelevelandskill;listQuestionsgainslevel[],skill[]andtagMatch=any|all(the default keeps today's meaning) and facet counts for levels and skills;optionsgainsmaxItems: 8(DG-63) - Publish copies
levelandskillinto the version rows, keeping themedia.LockForVersionUseandquestions.LockForDraftUseordering unchanged - Test list rows gain
skills, the distinct skills of the draft's questions - Rehearsal on a Neon branch reports questions with more than eight options; the cap applies to writes only
- Tests:
publish_snapshot_test.gogreen and untouched; a new test beside it shows that changing a bank question's level after publish leaves the version's level unchanged; facet andtagMatchintegration; nine options answer 400 - High-risk area: tests module unit tests run before and after
As built (2026-10-07): Nullable question and frozen-version metadata,
self-excluding level/skill facets, tagMatch, draft skill summaries and
copy/restore/publication propagation are complete. The eight-option cap applies
to new authoring requests and included imported questions; stored reads and
bodyless copies, restoration and publication preserve legacy longer arrays.
Migrations 00087 and 00088 add nullable columns without backfill.
F-43 (2026-10-10): TestVersion gains a required skills, the version's counterpart
of Test.skills: the sorted distinct non-null skill of the rows in
test_version_questions, a group's members included. It is read with the rest of the version
(versionColumns), so publishTest, listTestVersions and the publish's own read-back all
answer it, and no migration is needed because 00088 already froze the column. Editing the
draft or the bank after a publish does not move it (version_skills_test.go, beside the
untouched publish_snapshot_test.go).
A fresh local PG18.6 purpose rehearsed up87/up88/down88/down87/re-up and the complete migration universe through 91. Synthetic nine-option bank and published rows retain their content; metadata starts NULL and enum checks reject invalid values. Twelve named public app/migrate integration parents and eight unchanged app-role snapshot/lock/event canaries pass. The legacy canaries' ignored cleanup residue is disclosed; normal disposal of their whole owned purpose is the actual cleanup. No append-only privilege was granted and no test was skipped or weakened. All six retained controls match their before/after full censuses.
A separate read-only Neon child of production actually reports 64 bank questions and 23 frozen questions, with zero oversized-option records in both populations. The complete oversized identity arrays are empty; this is a genuine historical limitation, not seeded production evidence. The nonsuper app role executes only repeatable-read read-only SQL on that child. Its 17 processes joined and its normal exact-ID deletion, absence and other provider metadata equality are independently verified. Cloud migration execution/timing remains the separate release gate; this task does not claim it. The first failed connection-purpose child also has separately guarded normal cleanup and verified absence; its original failure is preserved.
The accepted metadata code at 2659cf56773895d85767c9921c59e764bb13a7ff passed required CI 37590704418. The incoming accepted processing work changes no server, contract or migration input from that verified candidate. Combined exact-head CI is required before integration. This completion record is not a production release.
Split (the Principal, 2026-10-08; recorded by T-R4.16a's pull request): T-R4.16a is the
publish base, the usage counts and the change note, with the draft loader split the diff needs;
T-R4.16b is the diff, its endpoint and unpublishedChanges. A task that depends on T-R4.16
waits for both halves.
Depends on: —
Touches: migrations/00092_add_test_versions_change_note.sql, api/openapi.yaml (TestVersion, Test, publishTest), generated code, server/internal/modules/tests/{domain/{publish,test}.go,application/command/publish.go,repositories/{publish_publisher,publish_load,publish_groups,group_store,group_mutation,duplicate,store,versions,usage (new)}.go,http/{publish,tests}.go}, server/internal/shared/schedule/schedule.go (new), server/internal/modules/assignments/repositories/{assignments,reopen}.go, docs/plan/20-data-model.md (D-32), the web fixtures the contract change forces
Size: M
As built (2026-10-08):
-
The migration is
00092, the next number after00091onwork/redesign-r4at5821b264; renumber it if another file lands first.change_note textis nullable with no default, so the previous release's insert keeps working, and no row is backfilled. The check,test_versions_change_note_check, holds 1 to 200 characters and is part of theADD COLUMN, so there is nothing to validate. The command trims the note and stores NULL for a blank one (PublishManager.ChangeNote), so the table never meets an empty string. D-32 records it. -
testUpdatedAtis a field ofTestVersion, becauseadditionalProperties: falserules out a wrapper, and onlypublishTest's 201 carries it. It is theRETURNING updated_atof theUPDATE app.teststhe publish already runs, so it is the value the00015trigger wrote inside the publish transaction. A save sent with it succeeds; a save sent with the pre-publish time answersSTALE_WRITE(both are inpublish_version_metadata_test.go).domain.Version.TestUpdatedAtis a pointer set only byPublish. -
publishTest's body is optional,{changeNote}of at most 200 characters, null allowed, and the operation answers 400VALIDATION_FAILEDfor a longer note, a wrong type or an unknown field. No operation was added, so the permission golden, the operation count and the isolation table are unchanged. -
assignmentCountcounts everyapp.assignmentsrow that names the version, drafts included and whoever created them, throughassignments_version_idx.DeleteVersionstill relies on the foreign key's RESTRICT. -
Test.assignments {live, scheduled, closed}is required on everyTest: the list rows andgetTest, and so every write's answer. It counts the non-draft assignments that name any version of the test, by the derived status, withliveasopen. The status rule is one constant,schedule.DerivedStatusininternal/shared/schedule, moved there fromassignments/repositories(its list, facets and reopen) and read bytests/repositories/usage.goas well, because a repository may not import another module's repository. T-R4.11 and T-R4.12 change that window rule in that one place.TestATestCountsItsAssignmentsByDerivedStatuscovers an early close that has passed, one that is still ahead, a passed window, a scheduled one and a draft. -
The loader is split.
loadDraftis gone. Publish and Duplicate calllockDraftContent(the row locks, in the orderloadDrafttook them) and thenloadDraftSnapshot(ctx, tx, testID), which only reads.readLockedGrouptakes the lock clause ("FOR SHARE OF g","FOR UPDATE OF g"or none); the snapshot reads groups with none (GroupsPostgres.readUnlocked) and skipslockGroupReadOwner, becauselockDraftContenthas already share-locked every group of the test, the caller holds the test row, andappendDraftGroupstill refuses a group of another section.GroupsPostgres.Getis unchanged. T-R4.16b addsloadVersionand the read-only diff transaction on top of it. -
One existing test changed:
publish_history_test.gocompared the publish answer with the history row by value; it now clearsTestUpdatedAton its copy first.publish_snapshot_test.goandpublish_lockorder_test.goare untouched. Done when: -
add_test_versions_change_note:change_note text(≤200), nullable. Down drops it -
publishTesttakes an optional{changeNote}and its response gainstestUpdatedAt, the test row'supdated_atafter the publish transaction, so the builder can keep saving withoutSTALE_WRITE -
TestVersiongainsassignmentCountandchangeNote; list rows gainassignments {live, scheduled, closed}counts for the card's "2 live" line -
loadDraftis split intolockDraftContentandloadDraftSnapshot, with the lock clause an argument ofreadLockedGroup; Publish and Duplicate keep their locks and their order - Tests: the change note (trim, blank, absent, the history),
testUpdatedAtagainst a following save, both counts per derived status, the constraint at its bounds with the previous release's insert still valid, the transport and the router contract;publish_snapshot_test.gogreen and untouched
Part 2 of 6 of R4 — Teacher workspace (v0.10.0) · Previous · Next
Generated from docs/plan/74-r4.md. Edit that file in the repository; changes made on this page are overwritten.
Generated from the repository's documentation on every push to develop.
Start here
Product
Implementation plan
- 00 Architecture overview
- 10 Phase 0: Scaffold and contract
- 11 Phase 1: Auth, join, shells
- 12 Phase 2: Admin authoring
- 13 Phase 3: Assign and take
- 14 Phase 4: Grade and results
- 15 Phase 5: Hardening
- 16 Phase 4.5: The student at a desk
- 17 Word import: milestone plan
- 18 Word import: teacher workflow
- 19 Versioned content contract
- 20 Data model
- 30 Risk register
- 40 Open items
- 50 Non-functional requirements
- 60 Backend architecture
- 91 September 2026 issue remediation
- 92 Student layouts and interaction rules
Redesign (Phase R)
- 70 Redesign overview
- 71 R1: Foundations (v0.7.0)
- 72 R2: Access (v0.8.0)
- 73 R3: Student console (v0.9.0)
- 74 R4: Teacher workspace (v0.10.0)
- 74d D4: Deck update (v0.11.0)
- 74e E1: Moving a question across a group
- 75 R5: Admin console and email (v0.12.0)
- 76 R6: Question types and scoring (v0.13.0)
- 77 R7: Collaboration (v0.14.0)
- 78 R8: Schedule (v0.15.0)
- 79 R9: Insights (v0.16.0)
- 80 R10: Learn (v0.17.0)
- 81 R11: Landing, leads, v1.0 (v1.0.0)
Setup and operations
- CI
- Deploying
- DNS for quizzivy.com
- Google OAuth client
- Production operations
- Cloudflare R2 bucket
- Word import worker
- R4 rehearsal on a Neon branch
Design
Team
- Agent team
- Verification environment
- Team ledger
- R4 readiness
- R4 verification strategy
- T-R4.51 browser verification
Code READMEs