Spec v0 3 - SekiroKenjii/Quizzivy GitHub Wiki

Quizzivy — Frontend Portal & Data Model Specification

Sections

About this document

Version: 0.111 · Owner: Thuong · Audience: AI coding agent + future contributors Scope: web frontend (admin + student portals) and the PostgreSQL data model. Go backend implementation is a separate spec; the API surface in §15 is the contract both sides implement.

Changes pending (develop)

  • H-04b §9 A tab that learns its session was taken over (a 409 on a save or a play, or a read that answers superseded) sends the events it holds once, under that session, and records nothing until it holds a session again. "Tiếp tục làm bài" keeps the tab's held session until the resume succeeds. §10.6 The pagehide beacon carries at most about 60 kB and leaves what it could not take buffered; submitting sends every waiting event; a reload is not an away episode.
  • H-05 §7 An option or a blank keeps its id when its question is saved. In a write, one sent with an id the question holds is updated in place (ordinal, text, correctness and formatting, or a blank's accepted answers replaced under the same id); one sent without an id, or with an id the question does not hold, is created with a new one; one not sent is deleted. A held AI check finding therefore still names the same option or blank after the teacher saves the question, and the check's "Apply fix" no longer refuses for that reason alone.
  • H-05 §15 POST and PATCH /teacher/questions, and the group member writes, follow the same rule for options[].id and blanks[].id. A create ignores every id it is sent. An id sent twice in one write updates once and creates the second. No migration, no new operation, no new permission, and no change to the contract's shape.

Changes since v0.110

The next patch release (v0.11.8), a hotfix after a teacher's report (2026-10-14): closing an AI panel threw its results away, and getting them back meant running the assistant, and paying for it, again.

  • §8 An AI panel keeps its session while the page that opened it stays open. Closing the drawer by Esc, the backdrop, Close, Cancel or "Open question" does not end it: the setup fields, a run still in flight and the suggestions a run answered are there when the entry point is pressed again, and a run that finishes while the drawer is closed is waiting in it, and says so in a toast ("Finished. Open it to see the results.", or "It didn’t finish. Open it to see why.") whose Open reopens that drawer; a run the teacher stopped, or whose page was left, says nothing, and the toast goes once the drawer is open again, and when the page is left. While another drawer is showing it waits for that one to close. Done, a primary's successful write and Start over end it, and so does leaving the page or opening another test, which also aborts a run that has not answered. The bank's "Open" on a near-duplicate still leaves the page and so ends the clean-up.
  • §8 A check's finding whose question is no longer in the test is left out of the list and its count. Generate's "Add {n} to {section}" names, and writes to, the section the builder is on when the questions are kept, not the one it was on when they were made; a group the passage came from stays the destination.
  • §8 A check's one-click fix names an option or a blank by id, and the server gives a question's options new ids whenever it is saved, so a finding held across a save can no longer be applied. "Apply fix" then fails in the panel with a sentence ("This fix no longer matches the question ..."), writes nothing, keeps the finding and does not report success; "Open question" and "Dismiss" are still there. The bank clean-up's "Tags added to {n} questions" counts the questions the write changed, and says so when it changed none.

Changes since v0.109

A clarification, no behaviour change (ledger F-33).

  • §7 The true_false answer form is legacy input. The engine writes choice for a true/false question, and the server grades both.

Changes since v0.108

v0.11.7, a hotfix after the owner's report (2026-10-14): dragging a question into a group and out again left a second bank question each time (docs/plan/74e-e1-group-move.md, "After release").

  • §7.3 A member copy remembers the bank question it was made from. Dragged out of its group with its content as it was copied, it goes back as that original: the copy is removed, the original takes its place in the section at the drop position, and no bank question is added. A copy that was edited since stays a new bank question, as before, and the builder says it was saved as a new question because it had been edited. A copy whose original is deleted, is another teacher's or is already in the test leaves as a bank question without a word. A move between groups or within one keeps the memory.
  • §15 POST /teacher/tests/:id/questions/move answers restored and editedCopy beside copied. No new operation or permission. One nullable column, questions.copied_from_id, and its index (docs/plan/20-data-model.md D-50, §48).

Changes since v0.107

E1: v0.11.6, after a teacher's escalation the owner decided on 2026-10-14 that a question can be dragged into a shared-context group in the builder and out of one (docs/plan/74e-e1-group-move.md). It reverses D4's default, which kept a bank question out of a shared group (docs/plan/74d-d4-deck-update.md Q8, DG-186).

  • §7.3 A standalone question dragged into a section's group becomes a member copy and the bank question stays. A member dragged out keeps its id and becomes a bank question and a unit of its section. A member moves between a test's groups, or within its own, in place. A member that a material gap is bound to does not leave its group. The test revision and the revision of each touched group guard the move. The next publish freezes the new structure, and a version diff against the draft reads a copy into a group as one question removed and one added.
  • §8 The builder's outline: a grip on each member row, the group's header as a drop target, and a menu and a keyboard path for every move.
  • §15 POST /teacher/tests/:id/questions/move, the only operation added. No migration, no new permission, and no change to a published version, a deal or an attempt.

Changes since v0.106

v0.11.4, a hotfix after the owner's report (2026-10-14).

  • §8, §15 "AI check" reads the members of a test's shared-context groups as well as its standalone questions, in the builder's order and each with its group's passage; a question's number is the builder's, one per question counting across the sections. It answered "There is nothing for the assistant to read yet." for a test whose questions were all members of a group, because it read only the standalone questions of a section.
  • §8, §15 "Make a variant" still covers standalone questions only. A variant duplicates bank questions and writes each change to the duplicate, and a group's members cannot be duplicated that way: the members are copied as they are, and a test whose questions are all members answers the same message.

Changes since v0.105

v0.11.3, a hotfix after a teacher's report (2026-10-14).

  • §12 The builder's Generate adds its questions where their source belongs: from a passage a shared-context group holds, to that group as new members; from pasted text, a topic or nothing, to the section, as before. The builder used to append every generated question to the section, so questions written from a group's passage landed outside the group that shows it.

Changes since v0.104

D4, T-D4.18: the release review of listening (#602).

  • §11.3 Leaving the test page forgets every remembered place, as a reload does, so coming back to the same attempt or starting another spends a play. The text said "cleared with the attempt"; the player restored a place after the page had cleared it, and a new attempt in the same tab could resume one for free.

Changes since v0.103

D4, T-D4.12: the engine and the teacher's AI surfaces described as D4 built them. No behaviour changed in this version; the text caught up with T-D4.4 to T-D4.10a, T-D4.22 and T-D4.36 to T-D4.38.

  • §9 The engine shows one question at a time, except a run of a cloze's gaps, which is one screen with its own line, chips and gap card, stepped over as a whole; every gap keeps its number, square, flag and points (T-D4.10a). A section's title and instructions stand above each of its questions, not only the first (T-D4.5). The stimulus pane's kicker and heading and the question pane's surface and widths are stated. "Navigation" replaces "Footer": the sections rail from 900px, or 1180px beside shared content, with the key hint in the footer; the grid button and the question sheet everywhere else, a bottom sheet below 768 and a 560px card from it; three square states; no strip (T-D4.4). The arrows move by screen, and Esc also closes the drawer.
  • §12 "One breakpoint, 768px" is the shell's and the engine's chrome; a new bullet records the engine's other thresholds (420, 900, 1180, the drawer's 88% up to 420px, and the 1024px sizes) and their one width source. The touch-target bullet names the rail's 34px squares, the drawer's handle and close button and the sheet's 44px and 42px squares in place of the strip; the engine's question column is 720px alone. The recording card's lime track is the audio player rule's one exception.
  • §10.4 The assignment's Flagged count leaves out voided attempts, as T-D4.22 built it.
  • §8 The grading card and Assignment detail › Questions draw a line, not nothing, while "Let AI read student answers" is off; Settings › AI assistant says the date the allowance resets and shows a waiting request. No "Availability: D4" marker is left in §8 or §12.

Changes since v0.102

D4, T-D4.21b: Grading when nothing is left to mark, and papers ready to finish.

  • §8 The grading page's empty states and its "Sẵn sàng hoàn tất" list: "Đã chấm xong tất cả" only after a complete check of the papers, each paper's check read fresh before Finish, the next paper's check after a Finish, and the list folded below the panes while answers wait. Finish stays explicit.

Changes since v0.101

D4, T-D4.16: the teacher's three previews draw the take-test engine in their frames.

  • §7.3 and §12 The builder's preview, Test detail and the import's "Preview and create" hold the take-test engine, answerable, with what is chosen kept only while the preview is open and nothing saved, recorded for integrity or counted as a play. The header has the "Student view" chip in Submit's place and no leave button; the timer's place holds a pill reading "––:––", named "The time limit is set when the test is assigned"; the save line reads "Answers here are not saved."; Finish says "This is a preview. Students submit here." (DG-188).
  • §12 The engine's 1024px sizes and its 44px floor follow a preview frame's width, so the phone frame on a wide window draws a phone's controls.

Changes since v0.100

The bank clean-up is a bounded pass, not one call (T-D4.32 follow-up 1).

  • §15 One cleanUpQuestionBank request reserves its 200 credits once, makes at most five calls to the provider at the same time inside 45 seconds, and ends with one settlement of what the calls were billed. Tags are suggested for the first 200 questions without any, 50 to a call; duplicates are looked for by comparing every pair of the questions read by their words, and one call judges the closest pairs, cut to the best that fit 100,000 bytes, at most 20 returned. The 300,000-character cap and the first-60 cap are gone; truncated also covers a part of the pass that failed or ran out of time, a duplicates call cut to fit its size, and a search for look-alikes that stopped at its work limit. The shapes of the request and the response did not change.

Changes since v0.99

D4 follow-ups: the grading leave guard counts a filled score, and the phone's shared content is described as T-D4.7 built it.

  • §12 On the grading card, leaving the page with a score filled from the suggestion and not yet saved asks first ("Rời trang khi điểm chưa lưu?"), as a typed comment already did.
  • §7.3, §9 and §12 Below 768px a group's shared content is a drawer from the left over the question, opened from a handle at the screen's edge or by a swipe, not the "Ngữ liệu | Câu n" switcher T-D4.7 removed. The Panes bullet says how it opens, closes, nudges the first time and keeps its place; nothing it describes changed in the product.

Changes since v0.98

D4, T-D4.40: the AI assistant (D19 to D22, decided by Thuong on 2026-10-10). The server side is built (T-D4.30 to T-D4.33); the web surfaces are T-D4.34 to T-D4.39, and all have shipped: T-D4.34, the panel and the builder, bank and Test detail entry points; T-D4.35, the question editor's "Write with AI" and "Suggest wrong options"; T-D4.36, the grading card's "Suggest a score"; T-D4.37, Explain results; T-D4.38, Settings › AI assistant; and T-D4.39, the import card.

  • §1.3 The assistant suggests and never decides, and its credits are a budget, not a payment.
  • §2 The provider is Anthropic's API, called through platform/anthropic behind the ai module's Completer port. Two tiers, their models and their prices are configuration, and no model identifier is committed. With ANTHROPIC_API_KEY unset the assistant is off.
  • §8 The teacher's AI surfaces, Settings › AI assistant, and the Admin "AI credits" page (Availability: R5; until then cmd/maintenance ai-credits).
  • §12 The panels' rules: the acceptance rule, the disabled-with-reason rule, the credit note and the failure states.
  • §13.10 The six AI tables, the two constraints that make the pool a hard stop, and the append-only ai_usage_ledger.
  • §15 Sixteen operations under /teacher/ai/* and /admin/ai/*, PublicStatus.ai, UserPreferences.ai, readWithAi on the import, three notification kinds (twelve in all), the AI error codes and the rate limits.
  • §16 "Read with AI": a scanned PDF is read by the assistant when the teacher's import asks for it, and is refused as before when it does not. The "no OCR" and "text layer only" sentences say so.
  • §17 Decision 4: cloud AI processing is on for the assistant's features (D19), with per-feature minimisation (D20) and a pool-constrained budget (D21).
  • "Changes since v0.7" and "Changes since v0.41": the sentences that kept cloud processing a candidate and ruled out OCR are marked superseded for these features.

Changes since v0.97

D4, T-D4.9: a recording that cannot be paused (DG-183).

  • §11.3 With allowPause false the card's button is inert while the recording plays (aria-disabled, 45% opacity) and the status says the recording can't be paused, before the first play and while it plays. A pause from elsewhere (a headset button, another recording, leaving the question or the group, a lock) still happens, reads "Paused", and Resume continues it for no play; nothing new is recorded.

Changes since v0.96

D4, T-D4.8: the recording card in the stimulus pane, and what spends a play (DG-193).

  • §9 A shared recording's card stands at its audio node in the stimulus pane, the drawer below 768px, and no longer in the question pane; with several recordings each card is headed by its node's label.
  • §11.3 The engine's player is the deck's recording card: a 52px button (Play, Pause, Resume, Play again), the track, the time and a play counter, and a status line. A play in progress keeps its place for the tab's life when its question or group is left, and starting one player pauses the others.
  • §11.4 A play is spent when playback starts with no play in progress; a resume spends none and sends no audio_play. A place that cannot be put back counts as a play.

Changes since v0.95

The import workspace of the approved page-width design (DG-207, Decided).

  • §8 Upload and setup and Processing are one page per import: the Sources pane beside the status pane, stacked below 960px of content. /teacher/imports/new is the draft and starting it replaces the address with /teacher/imports/:id, which stays the same through every state; a committing or committed import redirects to its confirm page. Pasted text is read as it is typed in the status pane, listing only what needs checking.

Changes since v0.94

The page frame's follow-up (DG-205): the lists' columns follow the approved sketch, and Settings caps its columns.

  • §12 The lists follow the design's sketch: Assignments shows Assigned to from 1240px of content, Submitted from 760 and a To grade column from 900; the Imports history adds Source from 1240. Settings' fields take at most three columns.

Changes since v0.93

R4, F-68 web part: the media library says what becomes of a replaced or deleted file, before and after.

  • §11.2 The Replace and Delete dialogs say, before the action, that the old file is kept for the published tests that use it (each test counted once) and deleted seven days after none does, or, when none does, deleted for good seven days later and not restorable, and offer the file's download. The toast after it names the date from previous.deleteAt or deleteAt, or that the file is kept for the tests that hold it. The Storage card counts heldBytes in what is used and lists the held and the pending files with the earliest date. A replacement refused for room while published tests hold the file says the library needs room for both files, and a 429 says how long to wait. No bell, no restore (DG-206).

Changes since v0.92

R4, F-68 hardening: the sweep can be switched off, and its last step survives the end of its time.

  • §11.2 MEDIA_RETENTION_SWEEP (on, the default, or off) stops the media sweep on the machines that read it. Once the sweep has deleted an object it finishes the mark, the audit row and the commit under their own time, and logs the file at ERROR if it still cannot. A bank question's write that names a removed file in that instant answers as a missing file, not a 500.

Changes since v0.91

The design team's page-width design, approved by the owner on 2026-10-12 (DG-205, Decided). Numbered v0.92 to stay clear of v0.90, the deck update on work/deck-d4.

  • §8, §12 Every route in TeacherLayout renders in one page frame: the full content width up to --page-max (1600px), centred beyond it at every width, 2560 included, inside gutters of 14, 28 and 40px. A route's handle no longer declares a width (it was 1320 by default, 1080, 860, 720 or full); the import review alone keeps width: "full". Containers fill the frame; a field is at most 480px wide and prose at most 72ch. The student app's columns are unchanged.

Changes since v0.90

R4, F-68 (v0.90 is the deck update on work/deck-d4): a replaced or deleted media file is kept for the tests that use it, then removed.

  • §11.1 The quota counts the library and the replaced or deleted files that something still uses, so a replacement that published versions hold needs room for both files. replaceMedia answers previous (the old file, the versions that hold it, a URL to download it, and when its object goes), declares its 429, and deleteMedia answers 200 with the same shape where it answered 204. usage gains heldBytes, pendingBytes and pendingDeleteAt.
  • §11.2 A file that left the library stays in storage until seven days after nothing names it, then the API's sweep deletes its object and marks the row; nothing restores it and nothing notifies the teacher.
  • §13.3 media_assets gains unreferenced_at and purged_at; a trigger on the six tables that name a file refuses a purged one and stops the wait of a retired one (migrations 00111 to 00113, docs/plan/20-data-model.md §45).
  • §15 The media lines.

Changes since v0.89

D4, T-D4.13 (DG-183): a teacher can stop students pausing a recording.

  • §7 AudioPolicy gains allowPause: boolean, default true. A student receives StudentAudioPolicy, the same four fields all present, for a question's audio and for a group's recording.
  • §11.1 The audio controls are four: maxPlays, allowSeek, showTranscriptAfterSubmit and allowPause ("Students can pause", default true), on a question's audio and on a group's recording. A create that leaves it out stores true. An update that leaves it out keeps the stored value, of the same question by its id, of a group's member question by its id in the bundle and of a recording by its id, so a tab loaded before the release or a recovery draft from the device cannot switch pausing back on; an audio policy or a recording that is new in the request is a create. Publish, restore as a draft, duplicate and group copy carry the value, and a version freezes it.
  • §13 questions.audio_allow_pause and test_version_questions.audio_allow_pause are nullable and read as true when NULL; group_recordings.allow_pause and test_version_group_recordings.allow_pause are boolean NOT NULL DEFAULT true (migrations 00114 to 00117).
  • §15 createQuestion, updateQuestion, createQuestionGroup and updateQuestionGroup accept allowPause and answer with it; the student's paper, the preview and the result send StudentAudioPolicy.

Changes since v0.88

R4, M1 of the v0.10.0 release review: a device's choices from v0.9.0 move to the account once.

  • §5.4 After sign-in or a session restore, a theme, language or "Larger text in tests" the account does not hold, and that this browser stores as a choice made there (quizzivy.theme, quizzivy.locale, quizzivy.testText), is applied and saved to the account once, through the same writes Settings uses. A value the account holds always wins; nothing is written when no valid choice is stored, nor for a value the app copied there from an account; a failed save keeps the device's choice on screen, says nothing, and is tried again at the next sign-in.

Changes since v0.87

R4, F-67: a replaced legacy join code keeps its use count.

  • §6.1 The sealed code jobs.RotateLegacyJoinCodes issues keeps the old code's use count with its expiry, use cap and creator, and no longer starts at 0 uses. A class whose legacy code had reached its cap gets a code that is used up (JOIN_CODE_EXHAUSTED on the next join), not one that admits max_uses more students; its teacher issues a new code when they want to admit more. A code below its cap keeps what was left of it, and a stored NULL cap stays NULL with its count carried.

Changes since v0.86

R4, F-66: a sign-out finishes when the client has gone.

  • §5.4 POST /auth/logout revokes the refresh family under its own ten-second budget, which the end of the request does not cancel, so a tab closed during sign-out still ends the session. A refresh keeps running on the request's own context; the window it leaves (a rotation committed whose answer never arrives) is recorded under R-06 in docs/plan/30-risks.md.

Changes since v0.85

R4, the sections brought level with the release (T-R4.50, the Tech Lead's half). No behaviour changes: each line below is a body section that still described the console before R4.

  • §2, §3, §5.4 The teacher's tree is /teacher/* in TeacherLayout; TeacherWorkspace holds it, StudentArea and homePathFor send staff to /teacher, and a /admin/* address redirects to the same path under /teacher until R5 builds the Admin console at /admin. AdminLayout is gone.
  • §7.1 Prompts and explanations are written in the rich editor for every teacher; the opt-in VITE_RICH_QUESTION_EDITOR is gone. Options stay plain inputs unless VITE_RICH_OPTION_EDITOR is on.
  • §8 The teacher's routes and screens as R4 built them, with the shell: the sidebar, its stored state, the 768px drawer and the content-width thresholds.
  • §9, §12 The teacher console follows the theme; nothing forces light any more.
  • §10.4 The teacher's integrity view (D8): the roster's focus-loss count and flagged rows, the attempt sheet's compact timeline, the attempt page's Integrity tab, one danger tone for "Flagged".

Changes since v0.84

R4, the sections brought level with the release (T-R4.50, server half). No behaviour changes: each line below is a body section that still described an earlier state or never described an R4 change.

  • §6.1, §6.4, §6.5, §13.3, §13.5 The legacy rotation is done, not planned. The text now says what jobs.RotateLegacyJoinCodes does at start-up (per-class transaction under advisory lock (73819, 41), the two-minute budget, what the new code keeps, the audit, the teacher's notice) and what stays until R5 (the scheme-1 lookup for a revoked or expired row). The class screen shows a code in full, at /teacher/classes/:id.
  • §11.1 A question's media is one audio file or one image; an image carries mediaAlt (1 to 1000 characters), which belongs to the question, is frozen at publish, reaches students beside media, and is refused for any other media. Replacing a file with one of the other kind is 415 MEDIA_KIND_MISMATCH.
  • §13.3 The media library's columns and the size limit by kind (D-29), the question and frozen level and skill (D-31), test_versions.change_note (D-32), the assignment's review options and note (D-34, D-35), the rule that an attempt in progress follows its window and an attempt starts under the window as it stands when it commits, the notification tables (D-28) and the import draft counters and text source columns (D-40, D-41). §13.7 records a data-only migration with a Down that does nothing (00103, D-38).
  • §15 The test versions' operations and fields (changeNote, testUpdatedAt, assignmentCount, the diff), MonitorRow.extendedTo, the Word import operations including pasteImportSource, and the notifications' kinds, switches and producers. The teacher's lists are offset-paginated by page and limit; the cursor parameter the tests, questions and media lines named does not exist, and only GET /me/notifications is keyset.
  • §16 The pasted-text source as built: the paste mode of the upload page, plain text only with TEXT_MARKS_UNAVAILABLE, the 100,000 code-point and 20,000-line bounds, the conventions rules-v3 reads for text sources, and the one-line answer key read in Word and PDF files too (#233). Recognition is rules-v3, no longer rules-v2.

Changes since v0.83

R4, an archived class takes no new join code (F-64):

  • §6.4, §15 rotateJoinCode (POST /teacher/classes/:id/join-code) on an archived class is 409 CLASS_ARCHIVED, a new ErrorCode, and writes nothing: the class's current code, if it has one, stays as it was. Nobody can join an archived class (its code answers as an unknown code, in the preview and in both joins), so a code issued for one was a dead secret that read as live. The class is found for the caller first and checked for archiving after, inside the transaction that holds the class row, so another teacher's archived class still answers 404 as a missing class does, and an archive racing a rotation is ordered with it by the row lock: one that committed first is seen and refused, one that arrives later waits for the rotation. An Admin with scope.all reaches the class and is refused with 409. revokeJoinCode stays allowed on an archived class, and restoring the class (archived: false) lets it rotate again. The web app already hid the action (PR #509); the server now refuses it too.

Changes since v0.82

R4, a NUL character in a request (F-24):

  • §15 The character U+0000 is refused with 400 VALIDATION_FAILED, wherever the request carries text: a string or an object member name anywhere in a JSON body (the JSON a text/plain beacon carries too), a query parameter's name or value, and a decoded path segment. PostgreSQL stores it in no text or jsonb value, so every free-text field, every free-form meta and every q or tag search answered 500 before. The refusal names the field by its dotted path (options.1.text, tags.0; for a member name, the object that holds it) or the parameter, in the message, as the validator's other refusals do, and in details as { field: message }. It is decided in the one place the contract is enforced: it covers the seven open operations too, after the rate limiter has counted the request. A backslash followed by the characters u0000 (JSON \\u0000) is ordinary text and is not refused. The file name of a media upload or replacement is refused the same way when it holds U+0000 (400 VALIDATION_FAILED naming filename; an RFC 2231 filename* could carry one past the multipart parser, and POST /teacher/media answered 500). A Word import's file name with any control character was already 415 IMPORT_SOURCE_UNSUPPORTED.

Changes since v0.81

R4, closeNow before the window has opened (F-63):

  • §15 closeNow closes an assignment now, so it needs a window that has opened. A request with closeNow and window.opensAt still ahead of now is 400 VALIDATION_FAILED naming closeNow, whatever the assignment's status: a scheduled assignment closed now, an open one given an opening ahead, a closed one given an opening ahead. All three answered 500 before, because the schema requires closed_at >= opens_at. On a closed assignment this takes the case "closed now beside a window that opens later" away from 409 ASSIGNMENT_CLOSED (v0.81); a window that opens after an early close the assignment already keeps, with no closeNow, stays 409.
  • §15 closeNow is a field of a PATCH. A POST /teacher/assignments ignores it, as the contract said ("PATCH only"): a new assignment is never closed, and a create with closeNow and an opening ahead answered 500 before.

Changes since v0.80

R4, a closed assignment is opened again only by reopenAssignment (DG-160):

  • §15 PATCH /teacher/assignments/:id no longer reopens a closed assignment. Judged at the request's clock, an assignment that is closed, by its window or by an early close, stays closed: a window that would make it scheduled or open again, or start after the early close it keeps, answers 409 ASSIGNMENT_CLOSED, the code extendAssignment and the student overrides already answer for a closed assignment. Reopening it goes through POST /teacher/assignments/:id/reopen, which requires a reason and writes assignment.reopened to the audit log; a save that moved the close made it open again through assignment.updated, with neither. Nothing else about a closed assignment changes: the stored window, an earlier close, closeNow and every other field are saved as before, and so is a later close on an assignment closed early, which stays closed because a save never lifts an early close.

Changes since v0.79

R4, a bell counts, lists and clears only its own console's notifications (DG-170):

  • §15 getTeacherSummary (GET /teacher/summary), getMySummary (GET /me/summary) and listNotifications take an optional audience query parameter, and markNotificationsRead an optional audience in its body, each teacher or student. With one, the unread count, the page and the marking cover only the kinds that console shows. The teacher's are attempt.submitted, attempt.flagged, assignment.closing, class.joined and join_codes.rotated; the student's are assignment.opened, assignment.due_soon, assignment.extended and result.ready. Every kind belongs to one of the two. The audience narrows a marking by ids as it narrows "mark all": an id of the other console's kind is skipped, as another user's id is, and answers 204 all the same. Without an audience every kind is covered, as before. Any other value is 400 VALIDATION_FAILED.
  • §15 An account that holds both consoles (an Admin with "Take tests") has two bells that neither light nor clear each other. Both bells passing audience is the web's part of DG-170.

Changes since v0.78

R4, ownership is validated (T-R4.49, T-R3.2):

  • §13.3 Migration 00106 validated the NOT NULL of classes.teacher_id and of owner_id on tests, questions, question_groups and media_assets, and dropped the triggers and functions that filled an owner for the v0.7.0 binary's inserts. An insert that omits an owner now fails with 23502. The previous binary (v0.9.0) names the owner in every insert.

Changes since v0.77

R4, the legacy role goes (T-R4.49, T-R3.1):

  • §5.2 An access token carries the user, its times and the session epoch, and no role. A token minted before this release still names one, and it is ignored.
  • §7 Role, User.role and CurrentUser.role are gone. A caller's authority was already its permissions; a client that wants to know whether the signed-in user only learns reads workspaces.
  • §13.2, §13.3 app.user_role, users.role, its index and the trigger users_sync_legacy_role are dropped by 00105; users.role_id is NOT NULL, validated. The previous binary (v0.9.0) never names the column.

Changes since v0.76

R4, the bulk password reset and the student filters (T-R4.20):

  • §5.4 and §15 POST /teacher/students/reset-passwords (people.students.reset_password) takes studentIds (1 to 40, no repeat) and answers 200 with items (studentId, fullName, email, temporaryPassword) for the students it reset and failed (studentId, code) for the others, each list in the order asked, with Cache-Control: no-store. A failure is NOT_FOUND (another teacher's student, an account that is not a student, a disabled student and a missing id are one answer), FORBIDDEN (the subset rule), STUDENT_SHARED (someone else also reaches the student; a holder of people.users.manage resets them) or INTERNAL (a fault, logged with the student's id and the cause, never the password). The call answers 200 even when every student failed, and never fails once a student is reset, because its answer is the only place the passwords exist; a lost answer is recovered by listing mustChangePassword=true and resetting again.
  • §5.4 Each student is reset by the single reset's own write, after the same guards in the same order, in a transaction of its own: the hash, must_change_password, the session epoch, every refresh family and the audit row student.password_reset change together, and the student's cached principal is forgotten after the commit. Two students are worked on at a time, inside MAX_CONCURRENT_PASSWORD_HASHES. A cancelled request starts no further student and keeps those already reset.
  • §5.4 A temporary password, from createStudent, resetStudentPassword and resetStudentsPasswords alike, is three words and a two-digit number, word-word-word-NN, about 24.4 bits (it was word-word-NN, 18.45), so that guessing it at §6.5's sign-in limit (20 an hour per email) takes about 560,000 hours on average, where it took about 8,900. Nothing else about it changes.
  • §6.5 resetStudentsPasswords 2/min and 10/h per signed-in user.
  • §15 GET /teacher/students takes classId more than once (a student in any of those classes, counted once; a single value works as before) and mustChangePassword; its facets count the same students as its list.

Changes since v0.75

R4, the class schedule and room, the end-of-day expiry, class averages and the codes in the class list (T-R4.18):

  • §6.1 A code issued with expiresInDays n works through 23:59:59 on the day n days after today in the issuer's calendar zone: the profile preference, or Asia/Ho_Chi_Minh when there is none or it cannot be read. The day is counted on the wall clock of that zone, so a daylight-saving change cannot move it, and a code made at any hour of a day expires at the end of the same day. An Admin who rotates a teacher's class is the issuer, and the Admin's zone counts.
  • §6.4, §15 listClasses rows are ClassListItems, which repeat Class with joinCode read in full. withCodes=true opens each row's code, and only for a holder of teaching.classes.write; a code opens in the list exactly when getJoinCode would open it for the same caller and class (through the same read, so a legacy code, or one sealed under a key the server no longer holds, shows null, and a code that does not authenticate shows null and is logged by class without the code). A caller without the permission who asks gets null codes and no error. Every listClasses answer is Cache-Control: no-store, and the operation is limited to 60 a minute and 600 an hour per user, as getJoinCode is. Pickers do not ask for codes.
  • §7, §15 Class gains scheduleLabel (1 to 120 characters) and room (1 to 60), both nullable, and on teacher responses averageScore, an AttemptScore: the sum of earned over the sum of total points of the best graded attempt (the highest share, the later attempt on a tie) of each live member on each assignment that targets the class, with the unmarked manual answers of those attempts as pendingManual. A student who left, a disabled student, a voided attempt and an attempt not yet graded count for nothing; null when nothing is graded. createClass and updateClass take scheduleLabel and room: trimmed, blank is none, and on update an omitted field keeps the value while null or blank clears it.
  • §9, §15 A student's class (GET /app/classes) gains scheduleLabel, room and teacherAvatarUrl, each null when there is nothing to show, and still no count, member list, code or average. The class a student joins carries the schedule and room but never an average or a code.
  • §13.3 classes.schedule_label and classes.room, nullable, migration 00104.

Changes since v0.74

Composed text on the server (F-37, FONT-1):

  • §12 Typography: the server stores the text a person types composed to NFC, so "stored as typed" (v0.67) no longer holds for it. A write composes the plain fields (titles, descriptions, names, notes, reasons, tags, accepted answers, transcripts, alt text, file names), the text, label and alt of a prose document and the Markdown of a legacy one, and the text a student types in a text or fill-in answer when it is saved. A document and the plain text kept beside it are composed together, so the text still equals the document's projection. Emails, passwords, tokens, join codes, ids, links and the published versions are never composed. NFC shortens Vietnamese, but a few scripts grow when composed: a value that composing leaves over its limit is answered as any other invalid value, 400 VALIDATION_FAILED naming the field (an import commit answers a refused question the same way; a group answers 422 group_content), never as a server error. A name or a file name that comes from a provider or a file is kept as it came, and a student's save is never refused over it.
  • §13 Migration 00103 composes the rows written before, in the same plain fields, without moving updated_at. It leaves the published versions, a student's saved answers and every prose document, with the plain text beside it, as they are: those are composed when next saved. A view still composes what it draws, because those rows can stay decomposed.

Changes since v0.73

R4, the starter questions (T-R4.31b, T-R4.34):

  • §7.1 The builder's and the group composer's starter prompt is created as rich text, so a new question in either opens in Rich text; while its text is still the starter it opens empty under the placeholder, and the starter stays stored until the first edit.

Changes since v0.72

R4, the next version number (F-46):

  • §7 and §15 Test gains a required nextVersion: the number the next publish takes, one more than the highest version ever published, deleted or not (tests.last_published_version + 1), so it is 1 for a test never published. Deleting the newest version, which is allowed while it is not the default, does not lower it. The publish command and every read of a Test (getTest, listTests, the answer of a write) take it from the same function, so they cannot disagree. The test detail's draft banner and publish dialog name it in place of the highest listed version + 1, which named 3 where the server assigned 4 (VER-30).

Changes since v0.71

R4, profile photo (T-R4.8):

  • §15 PUT /me/avatar (self, multipart, a file of at most 2 MiB in a request of at most 2 MiB + 16 KiB) stores the caller's photo as a 256×256 PNG made from the centred square of a PNG or JPEG whose sides are each 200 to 2048 pixels, EXIF orientation applied and every other metadatum dropped, and answers CurrentUser; DELETE /me/avatar clears it and answers the same. CurrentUser.avatarUrl is a presigned GET valid 24 hours. A new ErrorCode, IMAGE_DIMENSIONS (415), answers sides out of range, an image that would need more memory to decode than the server allows (48 MiB) and a JPEG of more than 32 scans; MEDIA_TOO_LARGE, MEDIA_TYPE_UNSUPPORTED and MEDIA_UNREADABLE keep their meaning. PUT is limited to 10 an hour per user. User (someone else) carries no photo yet.

Changes since v0.70

R4, signed-in devices (T-R4.9):

  • §5.2 Signed-in devices. GET /auth/sessions lists the caller's live sessions, and DELETE /auth/sessions/{familyId} and POST /auth/sessions/revoke-others end one or all but the caller's. The location shown is where the device last signed in or last refreshed, not where it first signed in. Both writes move the caller's session epoch, so the caller's own next request answers 401 and recovers through the single-flight refresh. A request whose refresh cookie names no live session of the caller is answered 401 by both writes; the client's normal refresh then fails and the sign-in overlay shows.
  • §6.5 DELETE /auth/sessions/{familyId} 10/min and 60/h and POST /auth/sessions/revoke-others 5/min and 30/h, per signed-in user.
  • §13.5 refresh_tokens.geo_label (1 to 80 characters, nullable), migration 00102.
  • §15 The three operations, Session, and the error code SESSION_IS_CURRENT.

Changes since v0.69

R4, the skills of a published version (F-43):

  • §15 TestVersion (publishTest, listTestVersions) gains a required skills: the sorted distinct skills of the questions the version froze, a group's members included, from the same six values as Test.skills. A version whose questions carry none, which is every version published before T-R4.15, answers []. It is read from the version's own rows, so editing the draft or the bank after the publish does not change it.

Changes since v0.68

R4, notification producers and due-time items (T-R4.10b):

  • §15 Who is told, and when. A notification is written after the change that caused it has committed, under a budget of its own that the end of the request does not cancel; a failure is logged and never fails the request. The teachers are the assignment's creator and the teachers of a target class the student belongs to (visibility.PaperReaders, the rule that lets a teacher see a paper, read the other way), each once, so a teacher of another class is told nothing about this student. attempt.submitted merges the papers handed in on one assignment into one notification per fifteen minutes, a timed-out paper being a hand-in; attempt.flagged is written once, when the integrity policy flags a paper, and never for a teacher's own flag or under warn; class.joined, which has no switch, on a join by code. The student is told assignment.extended when notify is true on an extension (everyone whose close it moved) or on an override that moves a close (those it gave time past the assignment's own close), and result.ready when a teacher finishes grading and the review policy shows the score at that moment. A result.ready notification carries the title and the attempt, never a score.
  • §15 Due-time items. GET /me/summary and GET /me/notifications first write what the passing of time has earned the caller, at most once in five minutes per caller in the serving process; there is no timer and no job. A moment older than seven days is not made up, a row the caller already holds is left as it is, read or not, and a switch that is off writes nothing. For a student: assignment.opened (when the assignment opened or was published, whichever is later), assignment.due_soon a day and an hour before the student's own close, override and early close included, while they have handed nothing in and have an attempt left and the test was available at that moment, the day's only while the hour's is not yet due (a student who first reads inside the last hour gets the hour's alone), and result.ready at the close of an after_close release for a paper with nothing waiting for a mark. For a teacher: assignment.closing an hour before the assignment's close, with the number of students they reach who have not handed in and whose own close is the assignment's. A close that moves earns its reminders again.
  • §9 The Test intro says "We will notify you when it opens." under a test that has not opened for a student whose "Test due soon" switch is on, and "You can start once it opens." otherwise.

Changes since v0.67

R4, duplicate, item analysis and the results export (T-R4.13):

  • §15 POST /teacher/assignments/:id/duplicate (teaching.assignments.write) takes {classIds} and creates a draft that pins the same version and keeps the original's time limit, attempts, shuffle flags, review options, integrity policy and note to students. Its window opens now and lasts as long as the original's; it is assigned to the classes named and to no student by name, and is owned by the caller. An original the caller does not reach answers 404, one whose version the caller may not assign answers 409 TEST_NOT_PUBLISHED, and a class the caller does not teach answers 400 on classIds.
  • §15 GET /teacher/assignments/:id/item-analysis (teaching.grading) answers {handedIn, items}: for each question of the pinned version, hardest first, how many papers answered it and the share that earned its full points. The papers are, for each student the caller reaches, the latest attempt that is handed in and not voided. A question left unanswered counts against it, a manually marked answer not yet marked counts for neither side, and a question no paper has a mark for has a null rate and comes last.
  • §15 GET /teacher/assignments/results.csv?ids= (teaching.grading, 1 to 50 unique ids) downloads a UTF-8 CSV with a byte-order mark: a row for every student of each assignment whom the caller reaches, a student who has not started included, with the attempt the monitor shows for them. Its times are in the caller's own time zone, and its headers and status words follow Accept-Language. A cell a spreadsheet would run as a formula is written with a leading '. An assignment the caller does not reach answers 404 for the whole request, and more than 20000 rows answer 422 VALIDATION_FAILED. A caller may download 10 files a minute and 60 an hour.
  • §15 GET /teacher/assignments takes q (the test's title or the name of a target class the caller reaches, ignoring accents) and classId more than once (any of the classes), and its facets follow both. Every assignment carries questionCount.

Changes since v0.66

Characters the brand font draws (FONT-1, DG-141):

  • §12 Typography: the italic faces are loaded for every upright weight. Every character the UI draws is in the loaded font faces; a symbol the font lacks (arrows, ⌘, ⏎, ⇧, ≤, ≥) is an icon, or a character it has, such as the en dash for a range. font-coverage.test.ts holds the source and the locales to it.
  • §12 Typography: content is shown in NFC. Be Vietnam Pro's subsets lack the combining circumflex, breve and horn, so decomposed Vietnamese would draw in a fallback font. The rich content editor composes what it writes; a plain field, the Markdown source and a student's answer are stored as typed and composed where they are drawn (F-37 proposes composing stored text on the server).

Changes since v0.65

R4, extensions and per-student overrides, second half (T-R4.12b):

  • §13 An attempt in progress follows a change to its window. POST /teacher/assignments/:id/extend, PATCH /teacher/assignments/:id (a later close), POST /teacher/assignments/:id/reopen and PUT /teacher/assignments/:id/student-overrides set the deadline of each attempt in progress to the earlier of its start plus its student's time limit and its student's close, in the transaction that changed the window, and only where that is later than the deadline it has. A deadline never moves earlier: removing an override (DELETE), setting a shorter time limit or a shorter close, and closing early leave a running attempt where it is, and so does an attempt whose deadline has already passed (judged on the database's clock), which is over whether or not it has been swept and is not revived. Each attempt moved is audited as attempt.extended with the old and new deadline and the cause. An override moves only the attempts of the students it names.
  • §13 An attempt starts under the window as it stands when it commits. A start that is racing an extension stores the extended deadline, or is itself lengthened by the extension, whichever commits first; it is never left on the close it read before.
  • §15 closesAt of an override must be ahead of the database's clock, not the API server's.
  • §15 MonitorRow.extendedTo carries the time a student's own override closes when that is later than the assignment's effective close, and is absent otherwise. An override that closes sooner than the assignment says nothing. Closing an assignment early does not take an override away: a student whose override reaches past the close stays open.
  • §13.9 The maintenance window's extension also moves the close of each student override of a published assignment that falls inside the window, by the window's length, and writes an assignment.override_extended audit entry per override. The operator's role needs SELECT, UPDATE (closes_at) on assignment_student_overrides (release checklist). extendAssignment and the two override writes take the same advisory lock (73819, 40) shared, as a start does.

Changes since v0.64

R4, extensions and per-student overrides, first half (T-R4.12a):

  • §13 New table assignment_student_overrides, one row per assignment and student, that gives one student a later close, a longer time limit (1 to 600 minutes) and up to 10 more attempts, with a required reason (1 to 500 characters). It cascades with the assignment and with the student, and a row must change something. Migration 00101. A missing row is the assignment as everyone has it.
  • §15 POST /teacher/assignments/:id/extend moves the close later for everyone (teaching.assignments.write) and answers 409 ASSIGNMENT_CLOSED for a closed assignment, which is reopened instead. PUT, GET and DELETE on /teacher/assignments/:id/student-overrides[/:studentId] (teaching.attempts.intervene) set, list and remove one student's override. A request names only students of the assignment the caller reaches; a student who is not one answers 422 VALIDATION_FAILED for the whole request. A field the request names replaces the stored one and a field it leaves out keeps it. Every write is audited with the old and new values.
  • §9 The student's card, intro, start and result read the student's own window: the later of the assignment's close and their override's (which lifts an early close), their time limit, and the attempts they were given. An assignment that closed for everyone else reads open to a student whose override is still ahead. A result released after_close is withheld until that student's own close; the class average still waits for the assignment's. Nothing in a student's payload says an override exists or why.
  • An attempt already in progress kept its deadline in this version; v0.66 (T-R4.12b) recomputes it.

Changes since v0.63

R4, assignment review options, the note to students and the live lock (T-R4.11):

  • §13 assignments gains review_release (on_submit or after_close, default on_submit), review_show_class_average (default false) and a nullable student_note (1 to 500 characters once trimmed). Migrations 00099 and 00100; every default is a constant, so nothing is rewritten.
  • §14 Assignment.review carries release and showClassAverage, and Assignment carries studentNote. On a write the three are partial: omitted, they keep what is stored, and studentNote: null clears the note.
  • §15 While an assignment is open, a PATCH that changes testVersionId, durationMinutes or maxAttempts answers 409 ASSIGNMENT_LOCKED; nothing else locks.
  • §9 A result released after_close is withheld until the assignment's effective close (score, marks, answer key, explanations and the grader's comments) and carries releasesAt. With the teacher's switch on, a released result of a closed assignment carries classAverage once at least 3 students qualify. The Test intro draws the teacher's note and says in its score sentence when the score comes.

Changes since v0.62

R4, the content editor's Rich text and Markdown modes (T-R4.64, DG-110):

  • §7.1: rich prose is how a prompt and an explanation are written, no longer an opt-in behind VITE_RICH_QUESTION_EDITOR, which is removed. A field opens in the form it is stored in, and a field with no text opens as rich text; the builder's and the group composer's starter prompt was stored as Markdown until T-R4.31b and T-R4.34 created it as rich text (see v0.74). "Switch to Markdown" and "Apply conversion" are the only ways the stored form changes, each behind its confirmation; Markdown is refused while a fill-in-the-blank prompt holds a gap. Markdown gains GitHub's tables and ~~strikethrough~~, for the teacher and for the student, and no other GFM syntax. Default, not yet confirmed by Thuong.

Changes since v0.61

R4, alt text for a question's image (T-R4.62a):

  • A bank question whose media is an image carries optional alt text of 1 to 1000 characters, returned to teachers as mediaAlt and frozen into the published version with the rest of the question, so a later bank edit never reaches a version. Students receive it beside media in the paper and in the result; it is a description for a screen reader, not part of the answer key.
  • A value for a question whose media is not an image is refused with a field error on mediaAlt (400 VALIDATION_FAILED). A write replaces the stored value, so an update that leaves it out clears it. Restore as draft and a duplicated group carry it, and the version diff counts a changed alt text as a change to the question's media. Migrations 00097/00098 add nullable columns without backfill (docs/plan/20-data-model.md D-33, §37).

Changes since v0.60

R4, content editor frame (T-R4.63, DG-115):

  • §7.1: a formatted paste leaves the images inside copied content out, unloaded, and counts them in its preview, instead of refusing the whole paste. Every other refusal stays; a paste holding only images, and a pasted or dropped file, are refused with a notice. Default, not yet confirmed by Thuong.

Changes since v0.59

R4, question metadata and authoring compatibility (T-R4.15):

  • Teacher question reads and authoring carry nullable level and skill; draft test lists expose distinct sorted skills. Published metadata stays frozen through later bank edits and draft restoration.
  • New authoring accepts at most eight choice options. Stored reads, bodyless copies, publication and restoration preserve legacy longer arrays. Migrations 00087/00088 add nullable metadata without backfill. Local PG18 up/down/up, isolated app/migrate behavior and unchanged critical canaries pass. A separate read-only Neon data copy reports zero oversized questions among 64 bank and 23 frozen rows; no cloud DDL was executed.

Changes since v0.58

R4, import processing presentation (T-R4.37):

  • The teacher processing page follows the deck for file and pasted-text sources, preserving real run stages, revision guards, cancellation, retention and review states. Text failure titles and recovery copy do not guess which source failed. Paste intake editing remains T-R4.57; run-event history remains unavailable.

Changes since v0.57

R4, account preference adoption (T-R4.7):

  • Server-authoritative account preferences, reactive date displays and same-page actor/cookie/cleanup ordering are complete. Teacher wall-clock inputs remain fixed-HCM until T-R4.43; full profile controls and photo operations remain T-R4.43 and T-R4.8. This task completion is not a production release.

Changes since v0.56

R4, private caller profile and preferences (T-R4.7, staged):

  • §7, §15 Public users gain optional display name/avatar; only the caller gains phone, locale, zone and preferences. Self profile PATCH preserves omitted keys and supports explicit clearing of display name/phone. Preferences merge top-level keys with bounded, closed payloads and atomic audits.
  • §8 The Dashboard zone port now reads the caller's stored valid zone, defaulting only a stored NULL to Vietnam time. §13 records migrations00085/00086.
  • Frontend account preference adoption, actor/cookie ordering and reactive date display remain the staged companion's acceptance gates. Existing datetime input parsing stays fixed to Vietnam time; full profile controls/photo follow T-R4.43/T-R4.8. No completed frontend or release is claimed by this record.

R4, private pasted-text sources (T-R4.55):

  • §16 Adds plaintext exam intake, source character counts and text evidence using the existing private upload lifecycle. Recognition conventions and paste editing remain separate T-R4.56 and T-R4.57.

Changes since v0.55

R4, import history read model (T-R4.21):

  • §16 History adds search-scoped status facets and nullable counts from the current review body. Repeated statuses are OR-ed; Processing includes all four active statuses. Legacy uncomputed counts remain unknown, and a pending machine candidate never replaces the counts of the teacher's current edits.

Changes since v0.54

R4, import history and source upload presentation (T-R4.36):

  • §16 History uses one responsive card/grid with URL-backed filters and real pagination. File intake keeps selected files and the title across Source mode changes. Facets, text metadata and paste intake follow in T-R4.21, T-R4.55 and T-R4.57; their data and operations are not synthesized by the presentation.

Changes since v0.53

R4, read-only grading queue (T-R4.14):

  • §8, §15 Teachers can read pending manual answers grouped by student or frozen question, with complete counts and a deterministic first200-item prefix. Historical paper reach and the existing explicit Finish operation are preserved.

Changes since v0.52

R4, teacher home and shell counts (T-R4.19):

  • §5 Both home operations use the caller's own teaching scope, admins included; repository-wide reads and the attempt list retain their existing scope.
  • §8, §15 The dashboard adds live taking counts, range-bound calendar submissions, today's assignment events and recent activity; the nav gets its own summary. Grading is permission-aware, calendar queries use the actor-zone port and application clock, and nullable graded scores retain their existing meaning.

Changes since v0.51

Round 5, the merged fixes and their limits:

  • §9, §15 A tab names its held session on each attempt read; a superseded answer stays read-only and preserves another session's local draft (#337).
  • §9 A deadline lock rearms automatic submission without replacing a closed or superseded lock (#353, #337).
  • §10.1, §10.6 A question's blocked seek records audio_seek (#339).
  • §10.4, §10.6 Sequence numbers use a server-calibrated offset and local increments; cross-tab order and uniqueness are not guaranteed. The timeline follows its session and sequence comparator (#338).
  • §10.6 A shared recording reports its end and block; duration is elapsed device wall time since the latest start, with pairing limits (#340).
  • §11.3 A blocked play keeps its player, and an aborted start shows no expired-link card (#339).
  • §15 The body-limit reader answers 408 REQUEST_INCOMPLETE; the earlier body-key reader can still hide an incomplete request (#342).
  • §15 Every JSON-body operation declares the validator's 400 response (#346).
  • §5.2 Own sign-out removes next in that tab's guard; the second-tab expired card remains open work (#355, #375).

Changes since v0.50

R4, "Teacher workspace" (v0.10.0, docs/plan/74-r4.md):

  • §11.1 Audio may be 50 MB; an image stays at 10 MB, and the length stays at five minutes (DG-63). A teacher's media library has a quota, 5 GB by default. A file can be renamed in the library, audio can carry a default play limit, and an image's size in pixels is recorded at upload. §15 lists the search, the "not used" filter and PATCH /teacher/media/:id (T-R4.17a).

Changes since v0.49

R4, "Teacher workspace" (v0.10.0, docs/plan/74-r4.md):

  • §6.1 At each start-up the API replaces every join code held only as its SHA-256, and neither revoked nor expired, with a sealed one that keeps its expiry and use cap, and tells the class's teacher once in the app. The old code then answers as any replaced code does (T-R4.22).

Changes since v0.48

A code nothing sent leaves the contract, and the sections are brought level with six fixes to the web app:

  • §15 ALREADY_ENROLLED is removed from ErrorCode: no server version ever sent it (#315).
  • §15 A path outside the contract answers 404 NOT_FOUND and a path under a method it does not serve answers 405 METHOD_NOT_ALLOWED with Allow, both in the envelope. HEAD is answered as GET without a body on the operations that need no token (#308).
  • §9 A draft a closed tab left behind is sent under the session that wrote it before "Tiếp tục làm bài" gives the student a new one. A refusal that can never become a save drops it; any other failure keeps it, and the attempt is not resumed (#330).
  • §10.6 Leaving the engine while the attempt goes on sends the buffered events through the beacon and keeps the sequence (#331).
  • §10.6 A question's audio player records audio_ended and audio_blocked beside audio_play. §10.4 A play with no recorded end shows no duration and is not called ongoing (#314).
  • §10.1, §11.4 A question's play is counted by POST /app/attempts/:id/audio-play; the three audio events report it and change no count.
  • §11.3 The total a player shows is the server's probed length when it is known (#321).
  • §11.3 The player's file is web/src/features/media/components/AudioPlayer.tsx.
  • §9 Once the server has said the time is up, the timer and the Submit dialog's time left read 00:00 (#329).
  • §5.2 A sign-out closes the "Vui lòng đăng nhập lại" overlay if it was raised while the sign-out was on its way (#328).

R4, "Teacher workspace" (v0.10.0, docs/plan/74-r4.md):

  • §5 In the teacher workspace the six content lists (tests, questions, question groups, media, imports, assignments) hold the caller's own rows for every caller, the Admin included: scope.all no longer widens them. It still reads and edits by id, and still widens the lists of classes, students and attempts and the dashboard. §16 says the same of the import history (DG-53, T-R4.54).

Changes since v0.47

The contract's descriptions, brought level with four fixes:

  • §15 An error's message follows Accept-Language on every operation; the field sentences and publish violations the rules word do not yet (#284).
  • §15 publishTest reports a question whose media file was deleted as a violation, not a 500 (#287).
  • §15 A JSON body that repeats a member name is refused, unless the server filled a default into it (#288).
  • §15 createDraftFromTestVersion answers RESOURCE_REFERENCED when a question of the draft's groups is still used elsewhere (#296).

R4, "Teacher workspace" (v0.10.0, docs/plan/74-r4.md):

  • §6.5 The signed-in operations that hand out a credential are limited per signed-in user, after the permission check, and no longer per address: staff behind one address stop sharing a budget. §5.5 says the same of openDocsSession (T-R4.53).

Changes since v0.46

R3, "Student console" (v0.9.0, docs/plan/73-r3.md), the screens and the engine as built (T-R3.5 to T-R3.11):

  • §5.2 A refusal ends only the session its request was sent under, and a 401 at app load keeps a sign-in that finished while the request was out (#317).
  • §6.2 A signed-in student joins from the Join dialog on Classes and Home. A member is told so from the preview's classId; a member's join answers 200, and nothing sends ALREADY_ENROLLED. A lookup's answer is remembered for 30 seconds.
  • §6.5 The preview returns three fields, classId among them.
  • §9 The student shell branches at 768px, with bottom tabs below it. The /app rows describe Home, the intro, the result, Classes (cards that are not links) and Settings (Profile, Sign-in, Appearance) as built. The engine has panes, a number strip or a question sheet, and the Submit dialog in place of the review page. Keys are A–E, F and the arrows, which stop at either end. §7.3's sentence on learner material says the same: one pane at a time on a phone, with no collapse state.
  • §10.1 The clipboard listeners are on document.
  • §10.2 The focus dialog's body is the deck's, without the seconds away or "the timer keeps running". The intro's start or resume click enters fullscreen; Home's resume card does not, and the engine's bar offers it. The engine leaves fullscreen once the attempt is submitted and records no fullscreen change after that. A blocked copy or paste raises a toast. The timer turns red under five minutes.
  • §10.6 The monitor owns every listener that records or stops a signal. Other hooks listen to the same events and do neither: useClipboardNotice for the toast, useSaveStatus for the save line, useVersionWatch for a newer build. A question's audio player records audio_play through recordAudioEvent.
  • §12 The student rules: one breakpoint, the page widths, the 44px floor and the controls that keep the deck's size, the dialog frame, the timer's digit cells, larger text in tests. The integrity UI follows the deck on the student side. The teacher console is the only one that still forces light.
  • §7, §13.3, §15 v0.9.0 ships without R2's contract steps. The legacy role, the NOT VALID owner constraints with their fill triggers and the /admin alias stay until v0.9.1 (T-R3.1 to T-R3.3), no earlier than 2026-10-10.

Changes since v0.45

R3, "Student console" (v0.9.0, docs/plan/73-r3.md), the contract (T-R3.4 and three fixes):

  • §15 A student's assignment card carries liveAnsweredCount, and the result carries the paper's sections with a sectionId on every question (T-R3.4).
  • §15 startOrResumeAttempt accepts resume; a Continue never starts an attempt (#237).
  • §15 The monitor's answeredCount counts by the same rule as liveAnsweredCount (#234).
  • §15 A student's assignment card carries classIds (#240).
  • §15 A logout the maintenance gate or the limiter refuses, or whose revoke fails, still clears the session cookies (#188, #256).
  • §5.2 A sign-in stores its refresh token under the user's lock and is refused if the account changed meanwhile; the rotations, reuse detections and logouts of one user run one at a time (#248, #279).
  • §5.4 An individual target who is already on an assignment stays on it when disabled (#210).
  • §15 deleteQuestionGroup answers RESOURCE_REFERENCED (#213).

Changes since v0.44

R2, "Access" (v0.8.0, docs/plan/72-r2.md):

  • §1.1 Roles are data, one per user. The personas are the Admin (the owner), Teachers and Students; the Assistant role exists with no class staff yet.
  • §1.3 One organization with several teachers. Every class, test, question, question group and media asset has an owner, and every Word import its creator.
  • §5 Every operation that requires the bearer token declares x-permission, which RequirePermission enforces on every request from a 10-second principal cache; the seven open operations declare none. Another teacher's id answers as a missing one does.
  • §5.2 The session epoch: disabling a student or resetting their password ends their live sessions at once, and a disabled user is refused on the next request.
  • §5.3 Sign-in returns CurrentUser.
  • §5.4 The web guards read the user's workspaces, not the role. The guards that are not permissions: strict student targets, the subset rule, disabling, shared students (403 STUDENT_SHARED) and the last Admin.
  • §5.5 The API reference needs system.api_reference, and its cookie carries the session epoch.
  • §6.1 New join codes are stored encrypted under JOIN_CODE_KEY and read back for their teacher. Codes issued before v0.8.0 stay hashed until R4. The key and its rotation are described.
  • §6.4 The controls belong to the class's teacher, and to an Admin through scope.all.
  • §6.5 Limits are sized for a classroom: 120/min and 600/h per address, 200/h per code, and the login, refresh and logout budgets. Keyed bodies take at most 8 KiB (413). Lookup is by a keyed hash.
  • §7 Adds CurrentUser, PermissionKey and Workspace; Role is legacy.
  • §13.2 roles is a table, not an enum. §13.3 adds permissions, roles, role_permissions, users.role_id, session_epoch and created_by, the owner columns and the join-code columns. §13.5 describes sealed join codes and the read-only access tables. §13.7 allows reference data in a migration.
  • §15 Teaching operations move to /teacher/*, and the v0.7.0 /admin/* paths answer through an alias until R3 (v0.9.1, since v0.47). /admin/* keeps deleteUser and the docs session. Adds PATCH /auth/me, getJoinCode, the guards' 403s and RESOURCE_REFERENCED's details.referencedBy.
  • Word import: an import, its sources, review and commit belong to its creator, and scope.all reaches every import.

Changes since v0.43

R1, "Foundations and front door" (v0.7.0, docs/plan/71-r1.md):

  • §4 The organisation's name and front-desk details come from build-time config.
  • §5.2 A refused session keeps a signed-in user's page under a "sign in again" overlay. A refresh that fails without refusing is not a lost session.
  • §5.4 New passwords need 8 characters and a number or symbol, and must differ from the current one (PASSWORD_UNCHANGED). /forgot-password exists.
  • §6.1 The alphabet prose now matches the alphabet, which contains L.
  • §6.2 Join previews the class inline on /join/:code, and ends on a Joined state. /join/:code/confirm redirects.
  • §9 Covers /forgot-password, the system pages, the boot splash and the overlays (maintenance, sign in again, a newer build).
  • §10.2 Integrity pauses under the maintenance and sign-in overlays. The engine adopts the server's deadline from every autosave.
  • §12 Records the deck's foundations as built: tokens, Be Vietnam Pro, dark mode, data-scale="deck", the accessibility tokens and the motion list.
  • §13.9 Adds maintenance windows.
  • §15 Adds GET /public/status and the 503 during a window, plus MAINTENANCE_SCHEDULED, DEADLINE_NOT_REACHED, PASSWORD_UNCHANGED and saveAnswers.deadlineAt.

Changes since v0.42

  • §16 Word and PDF import run in production (O-24, decided 2026-09-25). The image ships the import worker, and fly.toml runs it as its own process group on a 1 GB Machine, which the API wakes over Fly's private network.

Changes since v0.41

  • §16 Word import also takes PDF (D-10, decided 2026-09-25):
    • Only a PDF with a text layer can be read. A scan fails with PDF_NO_TEXT; there is no OCR. (Superseded in v0.99 for an import that asks for "Read with AI": the assistant reads the scan's pages, §16. There is still no local OCR.)
    • Underline, bold and colour are not read from a PDF, and every PDF draft says so. Keys come from a key file or explicit 1. A lines.
    • PDFium reads the file inside a WebAssembly sandbox in the worker. No conversion runs.
    • ImportSource.format and ImportLimits.formats gain pdf.

Changes since v0.40

  • §16 Word import has a retention policy (D-08, approved 2026-09-25):
    • Original files and the review draft are kept 30 days after the test is created and 7 days after cancellation.
    • An import untouched for 60 days is closed and its files removed.
    • History rows stay.
    • The API applies the policy at start-up and then daily. It exists wherever import storage does, with or without a worker.
    • WordImport.filesRemovedAt records the removal. The source download, the source view and the review then answer 410 IMPORT_FILES_REMOVED.
    • GET /admin/imports/capabilities states the three periods.

Changes since v0.39

  • §16 Word import states whether it can run on this deployment:
    • GET /admin/imports/capabilities reports intakeEnabled and processingEnabled.
    • Processing requires IMPORT_PROCESSING_ENABLED, set beside a worker. Without it, processWordImport answers 503 IMPORT_PROCESSING_UNAVAILABLE and queues nothing.
    • The client hides or explains whatever cannot run.
    • Production keeps import off until O-24 is decided (#138).

Changes since v0.38

  • §5.5 adds a docs session: the API reference (/docs, /docs/openapi.json) opens only with a fifteen-minute, docs-only cookie that an admin gets from POST /admin/docs-session. Its token is signed with its own key for its own audience and is never an access token. Local development may opt out with DOCS_PUBLIC, which production refuses. The page loads Scalar by SRI under a CSP that admits only that bundle and its own inline script (#135).

Changes since v0.37

  • W-11b connects private storage, isolated conversion, chunked source extraction and deterministic recognition in a separate worker process. Completed stages survive retries; source/artifact checksums are verified before parsing. Run envelopes contain lineage IDs, with source and answer content in private artifacts. Public processing controls and full review/domain validation remain subsequent work.

Changes since v0.34

  • W-13a adds an internal isolated legacy Word converter and private source-page rendition. One physical container slot, offline safe document loading, resource limits, cancellation and independent timeout protect processing capacity. Outputs retain conversion lineage and require review. Public legacy intake and durable processing integration remain disabled pending the complete pipeline.

Changes since v0.33

  • W-12a adds ordered, source-bound extraction with Unicode fragment locations, nested/merged table evidence and explicit review reasons for private/ambiguous content. Bounded raster normalization retains images privately. These internal tools do not yet enable recognition, review or draft creation; original evidence is never a learner payload.

Changes since v0.32

  • W-11 adds an internal durable processing queue with immutable source/pipeline identity, bounded attempts, leases, fenced writes and append-only run events. Cancellation and completion serialize on the import. A context-aware runner records safe operational metadata and preserves retryable work after shutdown. The production processor, supervisor and public processing controls are not wired yet; this checkpoint does not enable recognition or review.

Changes since v0.31

  • W-10a adds disabled-by-default private native DOCX intake, teacher-only history, immutable source sets and retry identities. Storage writes have durable reservations, quota accounting and revision guards. Original downloads require teacher access and short-lived attachment URLs from a separate private bucket. Legacy conversion, processing, review, commit, retention and production acceptance remain later work.

Changes since v0.30

  • W-07h integrates section → group → question authoring into the test builder. Outline and group writes share a serialized enclosing-test revision; acknowledged moves update only the editor's own revision baseline. Whole-group bank insertion, independent bank copies, complete learner preview and local recovery are available. The import pipeline and pilot acceptance remain release gates.

Changes since v0.29

  • W-07g adds the independent group bank and a complete-group editor: shared materials, authorized image/audio insertion, stable gap links, learner preview, revision-checked autosave and explicit local recovery. Archived groups support bulk restoration/deletion; copies remain on the list with an indicator. Mixed builder editing and the import pipeline remain release gates.

Changes since v0.28

  • W-07f saves complete mixed outlines under the test revision, moving whole owned groups between sections while retaining every existing group exactly once. Group removal remains explicit. Legacy saves clear obsolete unit rows after the last group is removed. Builder integration remains a release gate.

Changes since v0.27

  • W-07e exposes teacher-only complete-group read/write/copy/archive operations, bounded bank summaries and ordered draft units. Writes check aggregate and enclosing-test revisions. Media-link outages return a retryable asset state without obscuring committed content. Builder integration remains a release gate.

Changes since v0.26

  • Draft totals/tag filters include group members. Legacy question-only outline writes refuse grouped drafts atomically. Archived tests must be restored before draft restoration/default-version changes. Assignment introductions include shared recording policies from the assigned version and explain their scope.

Changes since v0.25

  • W-09f supplies frozen shared context in learner results and both teacher grading modes. Transcript release follows each recording's policy independently of score/key/explanation flags. Review playback does not add attempt plays.

Changes since v0.24

  • W-09e renders shared learner materials beside the question on wide screens and above it on phones with remembered collapse state. Stable recording players and idempotent gesture recovery preserve shared counts across member navigation, reload and takeover. Result/review context and authoring remain gated.

Changes since v0.23

  • W-09d freezes the delivery algorithm on each published version. Historical standalone papers keep section_v1; group-aware snapshots use group_v1. Attempt reload, takeover and results select the frozen marker, never the current test default. Unsupported formats fail closed.

Changes since v0.22

  • W-09c persists shared-recording counters and gesture receipts, deduplicates retries atomically with timeline events, and includes shared excess plays in teacher monitoring. Group authoring/player/result UI remain gated.

Changes since v0.21

  • W-09b adds learner-safe shared context to start/resume/read attempt responses. Frozen media bindings require an attempt owned by the learner. Shared playback, result/review context and group authoring remain gated.

Changes since v0.20

  • W-08c previews frozen group context and authorized assets without grading keys, follows the selected default version, and offers a 320px learner preview inside the supported admin shell. Group authoring and live learner delivery stay gated.

Changes since v0.19

  • W-08b restores and duplicates complete independent context graphs, preserves mixed unit order and removes owned draft graphs when an unreferenced archived test is deleted. Grouped preview/draft UI and delivery remain gated.

Changes since v0.18

  • W-08 defines independent frozen group membership, materials, gap targets and recording policies alongside existing version questions. Historical snapshots are not backfilled; group authoring stays unavailable until readers are ready.

Changes since v0.17

  • W-07d adds revision-checked full-group editing, independent copy materialization, bank archive/restore/delete and section removal. These remain internal operations until complete draft, snapshot and learner readers are available.

Changes since v0.16

  • W-07c adds atomic internal group persistence, owned-member barriers and protected media references. No group endpoint or authoring affordance is enabled yet.

Changes since v0.15

  • W-07b adds relational group ownership, ordered units, stable cloze targets and protected material/recording bindings. Existing question ownership remains null; new group authoring is still unavailable until lifecycle and readers ship.

Changes since v0.14

  • W-07a defines the bounded, independent group graph: ordered members, rich materials, explicit choice/blank gap targets and shared recording identities. Validation and detached copying precede persistence and delivery; group writes remain unavailable until snapshots, protected references and readers are ready.

Changes since v0.13

  • Rich authoring supports bounded structured clipboard conversion with an explicit preview, atomic insertion and undo. Unsupported source content is refused in full; it is never silently reduced to plain text. Original clipboard HTML stays local.

Changes since v0.12

  • Rich fill-blank prompts bind stable gap identities to answer rows. Moving gaps keeps answers attached; copies remap both ends; published versions preserve the frozen graph. Legacy Markdown and answer payloads remain compatible.

Changes since v0.11

  • Manual/internal question writes and publication share interaction validation. Single-choice and true/false require one key; true/false requires two options. Question points are positive exact hundredths within numeric(8,2); publication sums exact hundredths and rejects empty exams or an overflowing total.

Changes since v0.10

  • W-05b adds bounded semantic prompts and explanations with exact plain projections, immutable snapshots and policy-gated explanation delivery. Legacy Markdown remains on its existing reader; no old content is rewritten. Fill-blank prompts retain Markdown until stable gap binding is integrated.

Changes since v0.9

  • Inline option formatting now has additive storage, snapshot/restore and learner readers (§7.1, §13.3). New authoring is a pilot opt-in; legacy options remain literal text and existing published rows are not backfilled.
  • Option documents contain only marked text and line breaks; their plain text must match the stored text. Missing rich content in a legacy write cannot silently erase formatting. See docs/plan/19-content-contract.md.

Changes since v0.8

  • The Word foundation defines an additive ContentDocument contract (§7.1). Existing question writes, stored Markdown and published versions are unchanged.
  • Thuong approved independent copies of groups/materials and per-account local recovery for unsent edits, with seven-day expiry and logout clearing (§16).
  • New grouped exams will shuffle whole groups within their section, retain member order, and share a recording's play allowance across its group (§7.2, §11.5).

Changes since v0.7

  • The Word exam import milestone is accepted for implementation (§16). Its full scope includes semantic content, shared materials and groups, durable review, safe publication/delivery and operational release gates. It is not yet released.
  • Both cloud and private AI processing remain candidates for measured evaluation; provider selection and external exam-data processing are not enabled by default. Superseded by D19 (v0.99) for the AI assistant's features: cloud processing is on, with the provider chosen and the data each feature sends decided (§2, §17). Private inference is still a candidate (docs/plan/17-word-import.md §8).

Changes since v0.6

  • Dashboard work cards lead into a full-width assignment table and compact activity below.
  • Student discovery has counted status filters and prominent resume cards.
  • Teacher and student settings share Profile, Security and Preferences routes, persistent forms, section navigation and separated form rows.
  • Subtle shadows, hover feedback and 150ms section transitions respect reduced motion.

Changes since v0.5

  • Admin lists retain filters, support bulk removal and duplicate in place. The question bank exposes attached outlines in a nested table (§8).
  • Version history supports safe draft restoration, default selection and unused version deletion without changing assigned snapshots or reusing version numbers.
  • Assignment limits support custom durations, attempt counts and immediate integrity submission with retained answers and recorded violations (§10).

Changes since v0.4

  • The approved independent UX review replaces the student mockup's sparse right panels with fluid discovery pages and centred reading/form pages (§9, §12).
  • All live attempts remain discoverable; class/status filters, result empties, explicit selection/audio/save states and direct submitted-paper navigation are part of the student flow. Responsive changes preserve local form/filter state.
  • Phone actions use 44px touch targets, including navigation and dialog controls; the final-question footer must fit at 320px in Vietnamese and English.

Changes since v0.3

  • Pending student answers survive a failed save/reload within the same session; manual submission waits for the final save. Keyboard editing/navigation rules are explicit in §9.
  • §12 pins the mockup type scale and phone input/touch exceptions.
  • Approved O-23 defines thirteen-month integrity retention, retained audit logs and manual structured-identity anonymization in §13.3.

Changes since v0.2

  • OQ-2 answered: yes — students self-join with a class code. New §6, new /join flow, join-code lifecycle in the schema. This adds a public, unauthenticated surface that did not exist before; read §6.5 on the security consequences.
  • OQ-3 answered: yes — audio (listening) questions ship in v1, with upload and a custom player. New §11, new media_assets table, object storage added to the stack.
  • OQ-4 answered: yes — sample_answer on short_answer questions, visible to the admin during grading only.
  • All open questions are now closed. §17 lists the decisions that were made inside these answers and are worth a second look.


Generated from docs/quizzivy-spec-v0.3.md. Edit that file in the repository; changes made on this page are overwritten.

⚠️ **GitHub.com Fallback** ⚠️