Plan 74 R4 Part 3 - SekiroKenjii/Quizzivy GitHub Wiki

Part 3 of 6 of R4 — Teacher workspace (v0.10.0) · Previous · Next

T-R4.16b — Test versions: the diff and unpublished changes

Depends on: T-R4.16a Touches: api/openapi.yaml, generated code, server/internal/modules/tests/{domain/{diff,diff_paper,diff_context,diff_request}.go (new),domain/{draft,repository,test}.go,application/{app.go,query/diff.go (new),query/get.go},repositories/{diff.go (new, holds loadVersion),store.go,usage.go,list.go,group_frozen.go,publish_groups.go,version_group_restore.go},http/{diff.go (new),tests.go}}, server/internal/core/router/tests/{permissions_test.go,testdata/permissions.golden}, server/tests/isolation_cases_test.go, the web fixtures the contract change forces Size: M As built (2026-10-08):

  • No migration. The contract is getTestVersionDiff (workspace.teacher, x-resource the test) and Test.unpublishedChanges, required and nullable. The permission golden and the operation count (108 to 109) move with it, and the isolation table has its row beside deleteTestVersion.
  • The read is one REPEATABLE READ, READ ONLY transaction (Postgres.DiffPapers): Begin, then SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY as the first statement, so both papers are one snapshot, then 16a's lock-free loadDraftSnapshot and loadVersion. It cannot run on a repository built over a transaction (Begin there is a savepoint), and its doc comment says so. No db.Conn gained a BeginTx. readFrozenGroup takes the lock clause (versionShared for Frozen and the restore, versionUnlocked here); the draft's "group belongs to another section" became the sentinel errGroupOtherSection, with the same text, so the diff can tell it from a database failure.
  • Sides. against=previous is the highest version below the path version and, on the first version, has no from: every question is added and the total is not reported. draft makes the path version from. A number puts the lower of the two first. The path version itself is a 400 from domain.ParseAgainst, a missing version or another teacher's test a 404 (one message for both, so a stranger learns nothing), and an unreadable paper a 422 VALIDATION_FAILED (the draft's groups with against=draft, or a version's, see below), the answer a refused group gets elsewhere.
  • Identity. Compare matches by SourceID (the version's source_question_id, the draft's bank id; group members too), pairing equal keys in paper order, then the questions left over by a fingerprint of their type, prompt, explanation, media with its audio policy and transcript, points, options, blanks and answer key. Restore as draft copies every question and renews every gap id, so the fingerprint reads a gap by its place in the text and a blank's gap by that place; a group's bindings are read through the pairing, not through the ids. A restored version is therefore identical to its source, and a restored question that was then edited is not matched: it reads as added and removed.
  • Entries. added and removed per question (removed numbered in the older paper); changed per question with the parts that differ (type, prompt, options, blanks, media, points, explanation, context, section); answer per question whose key differs; points once when the totals differ. A question can have a changed and an answer entry. Level, skill, tags and the order of questions are not compared.
  • The key is read by position, as the letters the teacher sees: correct options by place, each blank's accepted answers sorted, and the sample answer. A key that stays on A while the options swap is changed (options) alone; one that moves with its option is changed and answer (answerFrom, answerTo carry the labels, for a choice question only).
  • context and section reach every member. A group's title, instructions, materials with their gap bindings and recordings, and a member's option order, are the question's context; a section's title and instructions are its questions' section. So editing a passage changes each question of the group, and renaming a section each question in it. That is one entry per question as the contract has it, and it inflates unpublishedChanges for a large group or section. The Principal kept it in review round 1: the deck's banner is boolean, and T-R4.30's chips need one entry per member.
  • unpublishedChanges is len(changes) of the latest version against the draft, counted in a second read-only transaction after the one that read the test (GetHandler). It is skipped for a test whose current_version is 0. It is null for a test with no version, in every list row, in a write's answer, and when a group graph is refused: the draft's (ErrDraftUnreadable: a GroupError, an unknown question, a group of another section) or the latest version's (below); a draft with no sections is an empty paper, so its questions are all removed; any other failure of the count fails getTest.
  • A version that no longer reads back (review round 1). loadVersion reads a group with readFrozenGroup, which validates it. A stored group that fails (*GroupError) or is gone (ErrNotFound) is wrapped in domain.ErrVersionUnreadable, so getTest leaves the count null and the endpoint answers 422 VALIDATION_FAILED with its own message, not a 500 and not a 404 that would say the version does not exist. A test freezes a group, shifts its members' ordinals in the database, and shows getTest at 200 with null, the diff against the draft and against the previous version unreadable and not "not found", and the count back once mended.
  • No error is read as a value. Compare, Introduction and DiffPapers.Changes return an error: a question's points that PointUnits refuses (stored data never has them) no longer count as 0, and a failure to encode a part of a question no longer gives an empty part. The handlers pass it up as a 500.
  • Cost. A diff is 14 + S statements for a draft of S sections, begin and commit included, and 16 more for each group: a 10 and a 100 question paper in five sections both run 19, and one group of two members with a standalone question runs 31. So it is constant in the questions and linear in the groups, as publish's own read is (readUnlocked and readFrozenGroup each read one group in about eight statements); batching them is not done here.
  • QA-16a-1 is fixed in this PR (its own commit): testColumns carried seven correlated subplans, four over the draft's questions and, since 16a, three over the assignments. At limit=100 the planner priced the list at 111,350 on 400 tests, 1,200 versions and 3,600 assignments, over jit_above_cost, so a connection's first executions JIT-compiled 168 functions (98.7 ms against 6.9 ms). Both groups are now one LATERAL aggregate each (testFrom); the estimate is 65,541, there is no JIT, and the 100 rows are identical. The laterals run for the rows an OFFSET skips too, as the subplans did; at page sizes of at most 100 that is small. Folding only the assignments left it at 100,083, still over.
  • Read-only evidence. 50 diffs and 25 getTest against a scratch database left pg_stat_user_tables.n_tup_upd, n_tup_ins and n_tup_del (summed over schema app) and tests.updated_at unchanged, where one UPDATE of the test moved them (a throwaway test, not kept: the counters are database-wide and other packages write). The suite pins the same by the test row's xmin, xmax and updated_at, by the statements the transaction runs (the second is the SET TRANSACTION; none is a write or has a FOR clause), and by a bank edit committed from another connection in the middle of a diff, which it must not see. Done when:
  • domain/diff.go compares two papers, matching questions by source_question_id (group members too), then, for what is left unmatched, by identical content, because Restore as draft creates new bank questions: added, removed, changed (prompt, options, media, blanks, points, explanation, context, section), answer (correct options or accepted answers) and points (total); order alone is not a change
  • GET /teacher/tests/{id}/versions/{version}/diff?against=previous|draft|<n> (the x-permission of listTestVersions) → {from, to, changes: [{kind, questionNumber, questionId?, params}]} (DG-66)
  • getTest gains unpublishedChanges, the number of changes between the draft and the latest version, computed by the same diff in a REPEATABLE READ READ ONLY transaction that reads the draft with loadDraftSnapshot and never calls lockDraftContent, GroupsPostgres.Get or any FOR clause; it is null in a list row, in a write's answer, for a test with no version, and when the draft's group graph is refused
  • Tests: diff unit tests for every kind and for reorder; integration for the endpoint and for a bank edit that makes the draft differ; publish_snapshot_test.go green and untouched
  • QA-16a-1: the list's estimated cost at limit=100 is under jit_above_cost, with the same rows

T-R4.17a — Media library backend

Depends on: — Touches: migrations/NNNNN_add_media_assets_library_fields.sql, migrations/NNNNN_add_media_assets_dimensions.sql, migrations/NNNNN_widen_media_assets_audio_bytes.sql, api/openapi.yaml, generated code, server/internal/modules/media/{domain/asset.go,domain/manager.go,application/command/upload.go,application/command/update.go (new),application/query/list.go,repositories/store.go,repositories/list.go,repositories/references.go,http/media.go}, server/internal/platform/config/ (tests/deployment_test.go among them), fly.toml, .env.example, web/src/features/media/{limits,probe}.ts, web/tests/units/contract/openapi-contract.test.ts Size: M As built:

  • Touched beyond the list: in the module, domain/{commands,errors,repository}.go, application/{app.go,ports/ports.go,query/{total_bytes,facets,usage}.go} and application/internal/support/{service,shared,intake}.go; platform/probe/image.go, core/adapters/probe.go, core/wiring/media.go; the pinned router tests (the golden, the count 102 to 103, the Assistant's refusals 33 to 34, the body budgets 8 to 9), server/tests/isolation_{cases,flow}_test.go, and platform/db/tests/constraints_test.go, whose oversized-asset case named the constraint 00084 replaces and skips without a database. New tests: core/router/tests/media_test.go, core/tests/media_wiring_test.go, media/application/tests/{intake,library}_test.go, platform/probe/tests/image_test.go, server/tests/media_library_flow_test.go. Web: MaterialAssetDialog.tsx, the three audio sentences, upload-panel.test.tsx, two MSW fixtures, and two new tests (media/limit-strings.test.ts, question-groups/material-asset-limit.test.tsx).
  • The migrations are 00082 to 00084, the next numbers after 00081 at the branch point. If another file lands first they are renamed, in that order, and nothing else changes.
  • One predicate is the library: a.deleted_at IS NULL AND a.replaced_by IS NULL, then the scope's owner, in repositories/list.go (library). The list, total, totalBytes, the facets, the usage and the quota sum are built on it. Get, Readable, ReferencesFor, LockForVersionUse and SoftDelete are unchanged and still reach a replaced file. Nothing here writes replaced_by.
  • questionCount and unused are one subquery (questionsUsing, references.go): the questions that attach the file, and the members of the groups whose recordings or stimulus assets name it, as a UNION, so a question counts once. A group member cannot be soft-deleted (questions_context_complete), so "live member" is every member. Any owner's question counts; the files counted are the scope's own.
  • The quota is two functions that take a pgx.Tx: repositories.RequireQuota(ctx, tx, QuotaCheck{OwnerID, Add, Quota, LeaveOut}) takes pg_advisory_xact_lock(73820, hashtext(owner::uuid::text)) and then sums the owner's library without LeaveOut; repositories.Insert(ctx, tx, in) writes the row and its audit row for in.OwnerID, or the uploader when that is empty. Postgres.Insert composes them. The one comparison, held + add > quota, is domain.Assets.CheckQuota. F-68 split it into LockQuota and CheckQuota, dropped LeaveOut, and made the sum count the retired files something still names (20-data-model.md §45).
  • The command also checks before it stores the object, from Repo.Usage, so a full library costs no 50 MiB put. That read takes no lock; the check inside the insert is the one that holds, and a refusal there deletes the object.
  • InsertInput gained OwnerID, DisplayName, DefaultMaxPlays, Width, Height and QuotaBytes. A caller of the repository must set QuotaBytes: zero refuses every row. The application passes its own, domain.DefaultOwnerQuotaBytes unless WithOwnerQuota set another; a config test holds that default equal to MEDIA_OWNER_QUOTA_MIB's 5120.
  • The intake helper is support.Service.Receive(body io.Reader) (*Intake, error): it copies at most the audio limit to a temp file through one 32 KiB buffer, identifies the file, refuses an image over its own limit and audio over five minutes, and reads an image's size. Intake carries the kind, MIME type, bytes, duration, size in pixels and checksum; Body() rewinds and Close() removes the file. It takes nothing from the upload command.
  • One code, two sentences for a file too large. domain.ErrImageTooLarge wraps ErrTooLarge; the transport words 10 MB for it and 50 MB otherwise, and gives the 50 MB answer to *http.MaxBytesError, whether the cap is passed while a part before the file is skipped or while the file is copied.
  • updateMedia is a scoped read, then a scoped write. A play limit depends on the kind, so the command reads the file first (Repo.Find, the library in the caller's scope) and the write repeats the predicate. The name is checked before the read and the play limit after it, so a foreign id and a missing one answer alike whatever the body holds. defaultMaxPlays is json.RawMessage in the generated type: left out keeps the limit, null clears it, and null is accepted for an image.
  • defaultMaxPlays is stored and applied to nothing. No question takes it by itself; the editor reads it (T-R4.65). uploadMedia still answers MediaAsset, so the limit sent with an upload is read back from the library row.
  • probe.Image reads PNG and JPEG through image.DecodeConfig and accepts only those two formats from it, whatever other decoder a later import registers; WebP is read from its first 30 bytes. A header it cannot read, or a size that is not positive, stores the image without one. Files uploaded before 00083 have no size: there is no backfill.
  • MEDIA_OWNER_QUOTA_MIB is 1 to 1048576 and is read whether or not object storage is configured, so a bad value stops the server either way.
  • The table refuses 51 MiB of audio. The brief's test list says it accepts it; the rule in the same brief is 50 MiB, and the tests pin 50 MiB accepted, one byte more refused, and 11 MiB of audio accepted, which 00010 refused.
  • The timeouts stay unchanged. The streamed-body read deadline is 110 seconds (it took effect only with F-73: from 5bcf081e until then the access log's response writer hid the connection from http.ResponseController, the call failed unseen, and the server's 30-second read timeout held every upload); the server's 120-second write timeout bounds the response, not the request context. A 50 MiB payload needs at least 3.81 Mbit/s before multipart overhead, with more needed to leave time for probing, object storage and the database. docs/setup/dns.md records that limit and the separately verified Cloudflare defaults (100 MB body, 125-second proxy read, 30-second proxy write). The size cap does not promise success on a slow connection. Done when:
  • add_media_assets_library_fields: display_name text (1–200), default_max_plays smallint (NULL = not set, so new questions get the editor's 2; 0 = unlimited; 1–3; audio only), replaced_by uuid REFERENCES app.media_assets(id) ON DELETE SET NULL with replaced_by <> id. Down drops the three
  • add_media_assets_dimensions: width, height integers, images only, both or neither. Down drops both
  • widen_media_assets_audio_bytes: the bytes CHECK becomes audio ≤ 50 MiB, images ≤ 10 MiB (DG-63), added NOT VALID then validated, the old constraint dropped. Down re-adds the old CHECK NOT VALID so it works when larger audio exists
  • Upload accepts audio up to 50 MiB while formats and duration stay as spec §11.1 has them (MP3/M4A, five minutes) until the open item is answered; the body streams to a temp file, never into memory; images record their dimensions: PNG and JPEG with image.DecodeConfig, WebP from its header. web/src/features/media/limits.ts, probe.ts and the contract test travel in this pull request
  • MEDIA_OWNER_QUOTA_MIB (default 5120) is enforced per owner at upload under a transaction advisory lock keyed by owner, namespace 73820 (409 MEDIA_QUOTA_EXCEEDED, which is new); the quota check and the insert are functions that take a transaction, so T-R4.17b runs them inside its own; .env.example updated; deployment_test.go and fly.toml take MEDIA_OWNER_QUOTA_MIB
  • Contract: LibraryAsset gains displayName, defaultMaxPlays, width, height and questionCount (bank and draft references); listMedia gains unused=true and returns usage {audioBytes, imageBytes, quotaBytes}; replaced assets leave the listing; upload takes an optional defaultMaxPlays as a query parameter, and uploadMedia declares 400 and 409; PATCH /teacher/media/{id} (content.media.write) sets displayName and defaultMaxPlays
  • listMedia gains q, matched without accents on the display name and the original filename through app.immutable_unaccent(lower(…)), and returns facets {all, audio, image, unused} counted under q, ignoring kind and unused. The Media page's tabs (T-R4.35) and the kind filter of "Choose from Media" (T-R4.65) read them, so a count follows the search (DG-17, DG-112)
  • Tests: rename, two concurrent uploads at the quota edge (one 409), the unused filter, 51 MiB audio answers 413, dimensions stored, an R3-shaped insert succeeds, the facets under a search with and without accents
  • The five canaries and the media module's unit tests run on the branch point and on the last commit, both runs in the PR (high-risk area)

T-R4.17b — Media replace

Depends on: T-R4.17a Touches: server/internal/modules/media/{application/command/replace.go (new),repositories/replace.go (new),http/media.go}, server/internal/shared/content/assets.go, api/openapi.yaml, generated code Size: M As built (T-R4.17b: media replace):

  • API: replaceMedia, POST /teacher/media/{id}/replace, declares content.media.write and the media resource. Multipart file validation retains upload's sniff, size, duration and probe limits; different kinds return 415 MEDIA_KIND_MISMATCH. The closed 201 response holds the library asset and four nonnegative counts. Scoped preflight precedes multipart consumption; foreign and missing IDs answer identically, and a nil application answers 501. No legacy alias, catalogue or general Student asset shape changed.
  • Graph: locks are sorted groups, sorted questions, old asset, then owner quota. Post-wait owner, current metadata and library eligibility are rechecked. Explicit locked owner and acting uploader remain distinct. Validated copied semantic IDs, recordings and relational holders change only inside the retained owner graph, with one revision per changed group and distinct counts. Archived live parents remain eligible; soft-deleted parents are excluded. Foreign bindings, published versions, import reviews and later bindings keep the readable old file. No test timestamp or snapshot changes.
  • Transaction and storage: the repository requires a nonnil concrete pgxpool.Pool before Begin and owns the outer transaction. Nil Commit confirms Committed; known rollback or SafeToRetry confirms NotCommitted; other failures remain Unknown. Body failure performs independent bounded rollback. Fresh-key deletion follows Put failure or confirmed NotCommitted, synchronously with its own five-second context; Unknown, unrecognized errors and Committed failures retain it. Rollback/delete failures take internal-error precedence. Post-commit describe/count/sign failure can return 500, and retrying the old ID can return 404. There is no distributed atomicity, idempotent retry, orphan-object sweeper or ten-second guarantee for whole-server reclamation; failed Put compensation may race remote completion and Unknown may retain an orphan.
  • Rate limit (release review): POST /teacher/media/{id}/replace is 10 a minute and 60 an hour per user in PrincipalRateLimits(), so it runs after the permission gate and before the file is read. F-68 declares the 429 in the contract.
  • F-68 (after v0.10.0, the owner's decision of 2026-10-12): the old file is kept, then removed. The old file counts toward its owner's quota while a published version, another owner's content or a live question outside any group still names it, and its object is removed by a sweep seven days after nothing does. replaceMedia answers previous (the old file, the versions that hold it, a URL to download it, when its object goes), deleteMedia answers 200 with the same shape, and usage gains heldBytes, pendingBytes and pendingDeleteAt. There is no restore and no bell notification. Migrations 00111 to 00113; the design, the sweep's locking and the trigger that keeps a writer from naming a removed file are 20-data-model.md §45. The web part is built on the library page as it is (DG-206): the Replace dialog says what becomes of the old file (kept for the N published tests that use it, each counted once, or deleted for good seven days later) and the Delete dialog says it is deleted for good seven days later, both with the file's download; the toast names deleteAt; the Storage card counts heldBytes and lists the held and the pending files; a 409 for a held file says the library needs room for both files, and a 429 says how long to wait, which needed uploadFile to read Retry-After. deleteMedia still refuses a file a published version uses, so the delete dialog has no "kept for N tests" case.
  • Contract and schema: no migration, index, dependency or config change. Existing 00082–00084 fields and the quota helper are reused. OpenAPI was edited first; repeated make gen stays byte-identical and 132 contract cases pass. Permission, isolation and request-budget inventories add this operation. Generic database/storage helpers and prior canaries stay unchanged.
  • Developer evidence: 510 passing integration events and 40 exclusive E2E events, 75 web checks across ten files including the real chunk build, strict project typecheck, build/vet/staticcheck and pinned lint pass. Fifteen actual assertion mutants were caught and restored. The initial overlapping-writer E2E failure remains an orchestration failure; no frozen lifecycle assertion changed. Principal's local-prefix finding was repaired only in two setup helpers; all 25 replacement top-level scenarios produced 37 passing events on new non-task- prefixed PG18.6/MinIO resources. These event counts include subtests. Full old controls stayed identical and exact owned resources are absent. The rejected audit-row repair was not retried. Source review approves the repaired helpers; independent purpose-resource QA and exact-head CI remain integration gates.
  • Consumers: T-R4.35 owns cache invalidation and question/group editor reloads, and must not automatically retry a possibly committed 500. Backend evidence does not certify a native media screen, builder, D4 or release.

Done when:

  • POST /teacher/media/{id}/replace (content.media.write, multipart {file}) validates like upload, requires the same kind (415 MEDIA_KIND_MISMATCH, which is new), and returns 201 {asset, repointed: {questions, groups}, left: {questions, groups}}
  • In one transaction that locks the owner's groups, then the questions to re-point, then the old asset (LockForVersionUse), the order publish and a group save follow: the new asset takes the old display name and default play limit; the old gets replaced_by; bank questions and bank or draft-owned group media owned by the asset's owner are re-pointed; other owners' rows keep the old asset, which stays served, and are counted in left; no test_version_* row is touched; the change is audited with the counts (DG-09). The quota is summed for the old asset's owner without the old file (F-68: the old file counts while a row still names it, and the sum is taken after the repoint). An import review draft is not re-pointed. A draft references an asset in recording rows and inside stored content, not only in group_assets.go
  • The old asset keeps serving published versions and attempts; deleting an asset a published version uses stays refused (409, as today)
  • Tests: a new integration test beside the canary shows a published version still delivers the old asset after a replace; a race of replace against publish ends with the version holding one of the two ids, never a dangling one; another owner's question bound to the asset (through scope.all) still holds the old asset after the owner replaces it; TestLockForVersionUseSerialisesAgainstDelete and publish_snapshot_test.go green and untouched

T-R4.18 — Class schedule and room; end-of-day code expiry; codes in the class list

Depends on: T-R4.7, T-R4.53, T-R4.8 Touches: migrations/00104_add_classes_schedule_room.sql, api/openapi.yaml, generated code, server/internal/modules/classes/{domain/class.go,domain/joincode.go,application/command/{create,update,rotate}.go,repositories/postgres.go,repositories/joincodes.go}, server/internal/core/router/ratelimits.go, server/internal/core/router/tests/credential_limits_test.go Size: S Done when:

  • add_classes_schedule_room: schedule_label text (1–120) and room text (1–60), nullable. Down drops both
  • Class gains scheduleLabel, room and averageScore (an AttemptScore pair over best graded attempts on assignments targeting the class, or null); createClass and updateClass accept the two fields; MyClass gains scheduleLabel, room and teacherAvatarUrl — never counts or members. /join/preview is unchanged here; R8 adds schedule and room to it
  • rotateJoinCode sets the expiry to 23:59:59 in the actor's time zone on the last day rather than now + N days; the web offers 7, 30 and 90 days
  • The teacher listClasses items carry the readable code in their own item schema (ClassListItem; Class.joinCode stays JoinCodeInfo), opened only when the request says withCodes=true and only for a caller who holds teaching.classes.write, null for anyone else and for a request that did not ask, so the class cards show every code from one call rather than one getJoinCode per card and pickers never receive a code; every answer is Cache-Control: no-store; ListClasses joins theCredentialMinters and the pinned want list, with 60 a minute and 600 an hour per actor in PrincipalRateLimits()
  • Tests: expiry across a DST-free zone, a UTC+9 actor at 23:30 and at 00:30 local, and Berlin across the March change; the average ignores voided and ungraded attempts; the student projection has no counts; credential_limits_test.go lists listClasses

As built:

  • Files beyond the list: migrations/00104_add_classes_schedule_room.sql (00103 is F-37's), classes/application/{app.go,ports/ports.go,internal/support/{service,enrolment}.go,query/{get,list,list_mine,active_code}.go,command/archive.go}, classes/domain/{commands,enrolment,repository}.go, classes/http/{classes,my_classes,joincode}.go, classes/repositories/{enrol,postgres}.go, shared/stats/stats.go, attempts/repositories/classscores.go, core/adapters/avatars.go, core/wiring/{classes,wiring}.go, core/router/tests/principal_limits_test.go, server/tests/{harness_test,class_schedule_flow_test}.go, and in the web only features/classes/api.ts and the fixtures the contract-validated mocks use. The rotate dialog already offered 7, 30 and 90 days, so it is unchanged.
  • Opt-in codes (a departure from this task's first wording, decided with the Tech Lead): listClasses opens a code only for withCodes=true, so pickers never receive one and their query caches never hold one. withCodes=true without teaching.classes.write is not an error: every joinCode.code is null. The rule is that the list grants nothing getJoinCode does not: a code opens in the list exactly when getJoinCode would open it for the same caller and class. One function, Enrolment.Read, opens a stored code for both; ActiveCodes reads the same columns under the same scope as ActiveCode; the permission is the one getJoinCode declares. TestTheListOpensACodeExactlyWhereReadingItByIdWould (the teacher, scope.all, another teacher, no one; sealed, legacy, lost-key, tampered and absent codes) and the end-to-end matrix over HTTP (a reader who teaches a class but cannot write) pin it. One difference, on purpose: a code that does not authenticate makes getJoinCode fail, and the list shows null for that class, logged by class id, so one damaged row cannot take a page of cards down.
  • ClassListItem repeats Class instead of extending it: allOf with additionalProperties: false does not validate, and a code in JoinCodeInfo would make getClass, createClass and updateClass credential minters. A test holds the two property lists together (and the row requires only averageScore beyond the class). The detector finds {code, expiresAt} in JoinCode, so listClasses is a minter without any special case.
  • Expiry: domain.CodeExpiry(now, days, zone) is time.Date(y, m, d+days, 23, 59, 59, 0, zone) with y, m, d from now in zone. The zone is the actor's stored profile preference, read through identity's effective-zone query behind ports.Zones (the dashboard's and attempts' source), never the request. A missing port, an error (a disabled account, an invalid stored zone) or a name this server cannot load falls back to Asia/Ho_Chi_Minh, then to a fixed UTC+7. A rotate never fails on a zone. An Admin rotating a teacher's class counts in the Admin's zone.
  • Average: the sum of earned over the sum of total of the best graded attempt of each live member on each assignment that targets the class (the roster's rule, StudentStats' ordering), so a student who left or is disabled counts for nothing, and neither do voided, submitted and live attempts. It is one query for a page, behind stats.ClassSource, and the classes app takes it through WithClassScores (none: no average). Class.averageScore is optional, so it is omitted when null on getClass, createClass and updateClass and never on a student's join response; a ClassListItem always carries it. EXPLAIN on 25 classes with 3,000 attempts each (75,000): 173 ms for the page and 6.8 ms for one class, an index scan of attempts_student_started_idx and no sequential scan of attempts, and attempt_answers_pending_idx for the unmarked answers, which are counted for the 25,000 best attempts only. No index was added.
  • MyClass gains scheduleLabel, room and teacherAvatarUrl, all required and nullable, and nothing else; a test pins the eight keys. The photo is signed by identity's AvatarURL query through ports.Avatars, an interface and an adapter in the composition root rather than the other module's handler, because identity's application imports classes' commands and the cycle would not build. A photo that cannot be signed is left out and the class stays.
  • Create and update: the two fields are trimmed and a blank one is stored as none (the checks never meet an empty string); on update, absent keeps, null or blank clears (x-go-type: json.RawMessage, as studentNote). 121 and 61 characters are refused by the contract (400), 120 and 60 accepted, and the checks refuse them again in the database.
  • Pinned tables: thePerActorLimits, theCredentialMinters and its detector list name listClasses. No operation is added, so permissions.golden and its count are unchanged.
  • Residual risks: listClasses is also the pickers' call, so the 60 a minute is shared with them (a search box that fetches on every keystroke would meet it); an Admin on scope.all with withCodes=true can read up to 100 codes a call, which is what getJoinCode already lets them read one by one, at 60 calls a minute.

T-R4.19 — Teacher dashboard and nav summary

Depends on: T-R4.10a, T-R4.54 Touches: server/internal/modules/dashboard/{domain/readmodel.go,domain/queries.go,application/query/summary.go,application/query/nav.go (new),repositories/postgres.go,http/handlers.go}, server/internal/core/wiring/dashboard.go, the isolation suite's views(), api/openapi.yaml, generated code Size: M Done when:

  • getDashboard takes range=7d|14d|30d (DG-70) and gains, additively: takingNow {students, assignments} (attempts in progress before their deadline), submissions {days: [{date, count}], total, averagePercent|null}, today: [{kind: opens|closes, at, assignmentId, title, notSubmitted}] in the actor's zone (class sessions join in R8), and recentActivity: [{kind: submitted|started|joined, at, studentName, subject, flagged}] (≤10). Existing fields stay for old tabs. The rebuilt dashboard (T-R4.23) stops reading openAssignments, activeStudents, totalStudents and recentAttempts, and takes the shell's useNavCounts off getDashboard in the same task; R5 drops them (release-checklist hand-off)
  • GET /teacher/summary (workspace.teacher) → {liveAssignments, answersToGrade, unreadNotifications}; a count the caller has no key for is null; liveAssignments counts the rows the Assignments list shows (T-R4.54)
  • Both answer for the caller's own teaching (.Own()), an Admin included; EXPLAIN on the seeded volume shows attempts_assignment_status_idx, attempts_one_live, attempts_grading_queue_idx (for answersToGrade only), assignments_window_idx and class_members' primary key in use; no new index
  • Tests: each range, the day boundary in the actor's zone, scope, isolation

As built (T-R4.19)

  • Both getDashboard and getTeacherSummary pass .Own() for teachers and admins; repositories retain All, and ListAttempts retains its full scope.
  • takingNow counts distinct students and assignments before their live attempt deadlines.
  • submissions returns exactly 7, 14 or 30 local calendar days, zero-filled and oldest first; only this reading changes with range (default 14d).
  • Submitted, timed-out and graded papers count by submitted_at; the integer average rounds half up over scored graded papers. Nullable graded papers still count and remain unchanged; the average is null when no scored graded paper exists.
  • today is the first 20 published openings/effective closings in the local day; notSubmitted is distinct enabled reached targets with no handed-in paper.
  • recentActivity is the ten newest attempt states or student-like code joins; each attempt has one event, voided attempts have none, and attempt flags carry through.
  • TeacherSummary.liveAssignments matches the open Assignments list; answersToGrade counts unmarked manual answers only with teaching.grading, otherwise null without running that count. Notifications are the caller's own unread count.
  • ports.Zones resolves the calendar name, adapters.DefaultZone supplies Asia/Ho_Chi_Minh until T-R4.7, and Service.Now is the injected new-query clock.
  • ports.UnreadNotifications is the notifications summary handler, set through WithNotifications; a missing port answers 501. New(repo) stays unchanged.
  • Legacy fields and their SQL clocks stay until their consumers move in T-R4.23/R5; T-R4.23 also switches the shell to getTeacherSummary. The existing dashboard client passes an empty query object for strict compilation after adding optional range, preserving its default and runtime URL. The Admin nav's grading count is their own reach while ListAttempts still reaches every teacher with scope.all.
  • No migration or index was added. PostgreSQL 18 plans on 3,200 rollback-owned attempts used existing live and assignment/status indexes for selected readings and chose sequential scans for others; these measured choices are not scale certification.
  • Public unit/integration tests cover permissions, ownership, clock/zone, nullable scores, padding, boundaries, target de-duplication and activity identity; HTTP isolation reads both operations. The original home scope case now pins .Own(); list scope stays wide.

T-R4.20 — Students: create into own classes, bulk reset, filters

Depends on: T-R4.53 Touches: server/internal/modules/identity/{application/command/reset_students_passwords.go (new),application/query/list_students.go,repositories/students_write.go,repositories/students_students.go,http/students.go}, server/internal/core/router/ratelimits.go, server/internal/core/router/tests/credential_limits_test.go, api/openapi.yaml, generated code Size: S Done when:

  • createStudent (people.students.create, D12, DG-03) accepts only classIds in the actor's scope (404 otherwise); zero classes is allowed, and the student stays visible to its creator through R2's created_by scope. Shipped in v0.8.0: TestACreatedStudentEntersOnlyClassesTheCreatorTeaches pins the class scope and TestEachReachRuleCountsOnlyWhileItHolds the creator's reach. Nothing to build here
  • POST /teacher/students/reset-passwords (people.students.reset_password) {studentIds (1–40)} → {items: [{studentId, fullName, email, temporaryPassword}], failed: [{studentId, code}]} with Cache-Control: no-store; every target passes R2's strict student-target predicate and the subset rule, or fails as NOT_FOUND; at most two hashes run at a time, inside MAX_CONCURRENT_PASSWORD_HASHES; each reset is audited as the single reset is
  • A shared student is not the actor's to reset (T-R2.13's rule, single and bulk alike): for an actor without people.users.manage, a target qualifies only when no one else reaches the student (72-r2.md, T-R2.13): every class of theirs, archived ones included, is one the actor teaches; no other account created them; no other account's assignment targets them individually; and with no class, the actor created them. The bulk path reuses identity's single-reset check (unshared in students_write.go), so single and bulk cannot diverge. Anything else fails as 403 STUDENT_SHARED (in bulk, in failed), and the reset goes to a people.users.manage holder in the Admin console
  • Each reset, single and bulk, does three things in one command, as R2's single reset does: revokes the student's refresh families, bumps their session epoch and calls Principals.Forget(userID)
  • Rate limit 2 a minute and 10 an hour per actor in PrincipalRateLimits() (T-R4.53); resetStudentsPasswords joins theCredentialMinters by name (its temporaryPassword is nested in items[])
  • listStudents: classId becomes an array (explode, a single value still works) and mustChangePassword filters
  • x-resource: the reset's studentIds body field declares student; listStudents' classId query declares class
  • The call sites in web/src change in the same PR (pnpm typecheck)
  • Tests: an admin holding learning.take_tests in the list fails and the rest succeed; another teacher's student fails; teacher B resetting a student shared with teacher A is refused with STUDENT_SHARED, single and bulk; a reset student's access token fails on its next request; the rate limit; isolation

As built:

  • Files beyond the list: identity/domain/{student,errors}.go (StudentQuery.ClassIDs and MustChange, BulkReset, ErrResetFailed), application/{app.go,internal/support/students.go} (the Reach guard, SetLogger, and SetTemporaryPasswords for tests), core/wiring/identity.go, the three tables that pin operations (permissions_test.go 121 to 122 and the Assistant's refusals 38 to 39, permissions.golden, thePerActorLimits), core/router/tests/credential_limits_test.go, server/tests/{harness_test.go,isolation_cases_test.go,students_bulk_reset_flow_test.go} and web/src/features/students/api.ts. students_write.go, list_students.go and the single reset's command are untouched: the bulk command calls the same Repo.ResetPassword, whose unshared check is the one single and bulk share, so they cannot diverge.
  • No migration. The filters need no index: on 6,000 students (3,000 per teacher, 10,800 memberships, 600 waiting to change) PostgreSQL 18.6 runs the list and its count in 1 to 12 ms, the slowest being a teacher's "must change" alone with no class (every reachable student through users_role_id_active_idx, 12 ms).
  • One command, one guard. Students.Reach is MayActOn that also returns the student; the order is unchanged (found in the caller's scope, people.users.manage where asked, then the subset rule), so a student the caller cannot reach answers NOT_FOUND even when the id is held by someone with more rights. A disabled student is NOT_FOUND, as in the single reset.
  • Two workers, request order. The command hands indices to two goroutines (inside the process-wide MAX_CONCURRENT_PASSWORD_HASHES semaphore, so a call holds at most two of its four slots and a sign-in is never starved) and reads items and failed back in the order asked. Each student is one Repo.ResetPassword transaction (hash, must_change_password, epoch, refresh families, audit row) and Principals.Forget runs after that student's commit. A fault or a panic fails only its student as INTERNAL, logged with the student's id and the cause and never the password. The command never returns an error once any student is reset. One hash took about 270 ms on the sandbox under load, so a call of forty is about 5 s at two at a time (estimated, not timed end to end), well inside the server's 120 s write timeout.
  • A cancelled request stops handing out students before the next one and keeps what it did; the answer lists the students already reset (nobody sees it, but the log says "cut short").
  • Contract: 200 even when every student failed; failed[].code is ErrorCode, in practice one of NOT_FOUND, FORBIDDEN, STUDENT_SHARED, INTERNAL; studentIds has uniqueItems, so a repeated id is 400 in any spelling (the schema compares text, so the handler refuses a repeat after parsing, as duplicateAssignment, setStudentOverrides, exportResultsCsv and listAssignments do since F-70); Cache-Control: no-store is declared (and SecurityHeaders already sets it on every /teacher/ answer). classId on listStudents is an array of at most 50 (a single value still works) and the facets in the answer follow it and mustChangePassword.
  • Rate limit: 2 a minute and 10 an hour per user. The limiter runs before the body is validated, so a 400 spends budget too; an anonymous or student caller spends none.
  • Isolation: resetStudentsPasswords is excused (it answers 200 and reports the student as NOT_FOUND) and blind to the caller's own id (the third call in a minute is limited). The positive proof is students_bulk_reset_flow_test.go, which resets the caller's students in one batch with another teacher's, a teacher's, an Admin's and a disabled account's ids and compares the untouched rows before and after.
  • Mutants killed: skipping the subset rule, the disabled check, Forget, Forget before the commit, the reach-before-permissions order, the epoch bump (database and end to end), the refresh revocation, the shared-student check, the request order, three workers, one worker, the rate limit, ANY as ALL, the inverted flag, a logged password (unit and end to end), a swallowed fault, a swallowed panic, a refused student spending a password, and a shared student reported as missing.
  • Residual: the plaintext exists in the answer only (and in the browser that shows it); the log is searched for it at debug level in every end-to-end scenario and the audit rows are searched in the same tests. A lost answer costs a second reset, found with mustChangePassword=true.
  • F-53 (Thuong, 2026-10-11): option B. The temporary password is word-word-word-NN: three words from the 63-word list and a number from 10 to 99, about 24.4 bits where word-word-NN carried 18.45. One generator (domain.Passwords.Temporary()) serves createStudent, resetStudentPassword and resetStudentsPasswords, so all three change together; PasswordManager.TemporaryBits() states the entropy and a test holds it between 24 and 25. The list, the 20 an hour sign-in limit (§6.5) and the number's range are unchanged. The longest password is 20 characters (duong-duong-duong-99), inside the 8 to 512 the login schema allows. SR-420-1 is folded in: the error for a password under the minimum names its length and never a candidate (the branch cannot be reached with the list as it is, so no test drives it).
  • SR-420-2 to SR-420-4 (follow-ups to the Principal's review, fix/sr-420-followups). Both resets ask Students.CheckResettable before they hash: the transaction's own sharing query without its row lock, so a student who is shared, disabled or gone costs one short read and no Argon2id hash. ResetPassword's own check stays authoritative, so a student who becomes shared in between is still refused there and does cost a hash. A bulk reset makes one version 7 uuid when the call starts and records it in each row's diff as {"batchId": ...} (WriteRequest.BatchID); the rows of a call are found with diff ->> 'batchId', the single reset writes no diff, and no index was added for the key. A reset forgets the student's cached principal when ResetPassword answers nil and also when it answers a context error, because the commit may have happened though the ended request never read it (Students.ForgetAfterReset, an idempotent eviction); any other error means nothing committed and nothing is forgotten. No contract or migration change.

T-R4.21 — Imports history read model

Depends on: — Touches: migrations/00089_add_word_import_draft_counts.sql, server/internal/modules/imports/{domain/import.go,http/imports.go,repositories/list.go,repositories/drafts.go} and public tests, api/openapi.yaml, generated code, the imports history page and its tests Size: S Capacity decision: INTEGER replaces the proposed SMALLINT because valid existing drafts can exceed32767 derived findings. No new draft limit, clamping or invented NULL is introduced. Counts are centralized in repository body writes, so worker completion and review commands keep their existing interfaces. As built and verified: The API, persistence and rebuilt history wiring are complete. Migration 00089 adds nullable nonnegative INTEGER counts without a backfill or default; pending candidates retain current counts until adoption. On a fresh purpose PostgreSQL 18 database, 89 up/down/up preserves the legacy body and run, 32768 remains exact, and all 18 named repository parents pass across app and migrate connections. A genuine post-insert connection fault verifies checked fixture cleanup. The purpose database was dropped normally after joins; all six read-only control databases and append-only ACLs remained unchanged. The earlier metadata-harness failure remains recorded separately.

Independent native verification passed 25 cases with no retry and personally reviewed 20 history boards at 360/768/1024/1280/1440, both themes and both sidebar states. It covers all six filters, four Processing statuses, nullable/zero/large chips, authoritative search counters, classic-scrollbar geometry, skeleton hover/reduced motion and actual menu keyboard focus. These native fixtures do not claim live API or cookie coverage. Independent source review and exact product-head CI 37510861704 pass. This task does not authorize a release. Done when:

  • add_word_import_draft_counts: open_action_count, open_confirm_count integer (≥0), nullable; written by the new binary on every draft write (processing result, review save, adopt) from domain.Review.Summary (Blocking, NeedsDecision). Down drops both
  • listWordImports: status becomes an array (a single value still works); the response gains facets {all, processing, needsReview, failed, committed, cancelled}, where processing covers awaiting_sources, queued, processing and committing; the counts follow the search and ignore the status filter, as the TestStatusFacets schema's counts do; items gain reviewCounts {needsAction, toConfirm}|null
  • Web call sites change in the same PR
  • Tests: facet counts under a search, counts written by review save and by the worker, null for a draft untouched since the deploy

T-R4.22 — Rotate every legacy join code at release

Depends on: T-R4.10a, R2's join-code encryption Touches: server/internal/modules/classes/application/command/rotate_legacy.go (new), server/internal/modules/classes/repositories/joincodes.go, server/internal/core/jobs/join_codes.go (new), server/internal/core/core.go, server/internal/core/wiring/classes.go, server/internal/core/jobs/tests/join_codes_test.go (new), docs/plan/20-data-model.md (advisory-lock registry) Size: M As built:

  • Touched beyond the list: classes/domain/{commands.go,repository.go}, classes/application/{app.go,ports/ports.go (new)}, core/wiring/wiring.go (the notifier argument, Assembly.Classes and the import its type needs), core/tests/jobs_test.go, docs/quizzivy-spec-v0.3.md (§6.1, v0.50) and three test files: classes/application/tests/rotate_legacy_test.go (the command, untagged), classes/application/tests/joincode_legacy_rotation_test.go (integration) and server/tests/legacy_join_codes_flow_test.go (the start-up path end to end, and the only test that sees the notifier wired).
  • One class is one transaction of six statements, in Postgres.RotateLegacyCode: SELECT pg_advisory_xact_lock(73819, 41); SELECT true FROM app.classes WHERE id = $1 FOR NO KEY UPDATE; the class's active code read again FOR UPDATE under revoked_at IS NULL AND lookup_scheme = 1 AND expires_at > $now; UPDATE … SET revoked_at = $now on that row; the INSERT of the sealed row, with the columns Rotate writes; the audit INSERT. With no class row or no code row the transaction commits having written nothing and the method answers false. That second read is what makes two start-ups rotate once.
  • The new row keeps the old row's expires_at, max_uses (a NULL stays NULL), uses_count and created_by, and is created at the $now the old row is revoked at. As first built it started at uses_count 0, so a class whose legacy code had reached its cap became joinable for max_uses more students; the owner decided on 2026-10-12 that the count is carried over (F-67). The code is read FOR UPDATE with its count, so a redemption that finished before the rotation is counted and one that waits for the rotation meets the revoked code. classes.self_join_enabled is not written: the class row is locked and never updated.
  • The lock order is the class row and then the code row, and the reverse of a redemption's, which locks the code row and then takes a key-share lock on the class row through the member insert. As first built the class row was locked FOR UPDATE, which conflicts with that key-share lock, so the two could deadlock, and a teacher's Rotate and Revoke still could. F-23 found it as an intermittent failure: when the database chose the rotation as the victim, its one retry started at once, took the class row before the woken redemption had taken its key-share lock back, and met the same cycle. The class row is now locked FOR NO KEY UPDATE by the legacy rotation, Rotate and Revoke, which a key-share lock does not wait for. The modes, the orders, what stays serialised and why Delete keeps FOR UPDATE are in 20-data-model.md §33, "Lock order for a class and its join codes". The repository still answers domain.ErrRotationContended for SQLSTATE 40P01 and 40001, read from the pgconn.PgError code, and the command still tries such a class once more, after every other class and with a new code, and counts it in Failed if that fails too. No other error is retried.
  • The audit row: actor NULL, class.join_code_rotated, entity class_join_code, entity id the new row, diff {"reason": "legacy_rotation", "previousHint", "hint"} and nothing else.
  • The old code answers through today's code paths, with no special case. Where self-join is open it answers PreviewRevoked (JOIN_CODE_REVOKED), as a code its teacher replaced with Rotate does. Where self-join is closed or the class archived it answers PreviewInvalid, as an unknown code does, before the rotation and after it, and so does the new code of that class until its teacher opens it.
  • The command lists once (Postgres.LegacyCodeClasses: by teacher, then name, archived classes included, no lock), groups by teacher and answers domain.LegacyRotation{Found, Rotated, Failed, Teachers, NotifyFailed}. A class another run rotated first is in Found only. It takes two optional callbacks, Found(count) and Problem(err), because the application layer does not log: the job logs from them.
  • The notification: one Notify per teacher, after that teacher's last class, or after the retries when one of their classes was contended. Kind join_codes.rotated, params {count, classNames} (the first five rotated, in name order), target classes, dedupe key join_codes.rotated:legacy, merge Add. Two runs that split a teacher's classes leave one row, unread again, with the summed count and the later run's names. A class without a teacher is rotated and nobody is told.
  • A teacher can be left untold. A failed Notify is counted and logged and undoes nothing. A process that dies between a teacher's last commit and their Notify leaves them untold: the next run finds nothing to rotate and sends nothing. The release note tells teachers too. Each Notify runs under a ten-second budget of its own, detached from the run's context, so a run cut short by its two minutes or by shutdown rotates no further class and still tells each teacher of the classes it had rotated; the run can outlive its budget by up to ten seconds for each teacher still owed one. At shutdown that is best effort, since nobody waits for the job and the pool closes behind the server: a Notify that fails there is counted in notify_failed, and the next run's notification then counts only the classes it rotates.
  • The job jobs.RotateLegacyJoinCodes runs once under a two-minute budget, started by Serve beside the prunes and by no other process, and never on a timer. It logs legacy_join_codes_found {count} before the first class when the count is above zero and legacy_join_codes_rotated {found, rotated, failed, teachers, notify_failed} after the last; a warning legacy_join_codes_problem {err} for each class left unrotated and each teacher left untold, naming the class or the user and never a code or a hint; and legacy_join_codes_failed {err} when the listing fails. A run that finds nothing logs nothing.
  • A class left behind waits for the next start-up: a contended one that failed again on its retry, one that failed for any other reason (tried once), or one the budget did not reach. The release check runs the count query again after the deploy.
  • The port is classes/application/ports.Notifier, the notifications module's Notify handler, set with Application.WithNotifier. New keeps its signature.
  • The tests narrow the listing, not the command. The command rotates every legacy code it finds, and the integration tier runs packages in parallel against one database, so those tests run the real command over a repository whose listing keeps only the test's own classes. The races are staged with held locks and pg_blocking_pids, never with a sleep: a holder of advisory key 41, a FOR UPDATE on the row the new code's created_by points at (which stops the rotation, or a teacher's Rotate, at its insert), a FOR SHARE on the class's code row (which stops the rotation at its second read), and an uncommitted account under the joining student's email (which stops a redemption between its code lock and its member insert). One deadlock round is staged the same way. A writer that holds the code row closes the cycle by asking for the class row once the rotation has waited 50 ms, or half of deadlock_timeout (read from pg_settings) if that is less, the one wait on the clock; the rotation's timer started first, so it fires first and the rotation is the victim; a holder queued for advisory key 41 behind the rotation takes the key when the rotation aborts and keeps the retry out until the writer has finished, so the retry cannot take the class row ahead of it. A redemption holding the code row is tested against the legacy rotation, Rotate and Revoke in turn, each in its only attempt, and a Rotate in flight is tested to hold back Revoke, a rename and the legacy rotation (joincode_lock_order_test.go).
  • "The R3 binary redeems the new code" is proven by reading: a test compares, column by column, the row the rotation writes with a row a teacher's Rotate wrote, and v0.9.0 has read scheme 2 since R2. Done when:
  • RotateLegacyJoinCodes: for each class with an active legacy (hashed) code — not revoked, not expired — one transaction revokes it and issues an encrypted code under the current key id, with the same expires_at, max_uses (a stored NULL stays NULL) and uses_count (F-67), and self-join unchanged; audited as System with reason legacy_rotation, the diff carrying hints only (DG-11)
  • core/jobs/join_codes.go runs it once at API start-up, each class in its own transaction that takes pg_advisory_xact_lock(73819, 41) and re-reads the code row; no session lock. It logs the count, applies, logs legacy_join_codes_rotated; a second run finds nothing. The key is recorded in 20-data-model.md's advisory-lock registry (10 and 11 imports, 40 maintenance windows, 41 this rotation, 42 R7's notification scheduler). A class whose transaction is a deadlock or serialization victim is retried once in the same run
  • Each affected teacher gets one join_codes.rotated notification {count, classNames (≤5)} targeting Classes; T-R4.41 shows the callout while it is unread
  • Legacy rows already revoked or expired are untouched; removing the legacy lookup branch is R5's contract step
  • Tests: a legacy code is rotated; the old code then answers as a code its teacher replaced does (JOIN_CODE_REVOKED) where self-join is open, and as an unknown code where it is closed or the class archived, before and after; the new code joins; two concurrent start-ups rotate once; the audit row and the notification exist; the R3 binary redeems the new code (R2 shipped the encrypted lookup)
  • The count on a Neon branch copy of production is recorded for the release note

T-R4.53 — Principal-keyed rate limits

Depends on: — Touches: server/internal/platform/ratelimit/{registry.go,principal.go (new)}, server/internal/platform/ratelimit/tests/, server/internal/platform/httpx/ratelimit.go, server/internal/platform/httpx/publicroutes.go (the start-up assertion, tested directly as AssertPublicRoutesLimited is), server/internal/platform/httpx/tests/middleware_language_test.go, server/internal/core/router/{router.go,deps.go,ratelimits.go}, server/internal/core/core.go, server/internal/core/router/tests/{router_test.go,credential_limits_test.go}, server/internal/core/router/tests/public_routes_test.go and permissions_test.go (TestRateLimitsRunBeforeAuthenticationAndThePermissionGate moves to POST /app/classes/join, where the forbidden caller is a teacher), AGENTS.md (the middleware note) Size: S As built:

  • Touched beyond the list: platform/httpx/publicroutes.go (the start-up assertion), platform/httpx/tests/{permission_test.go,middleware_language_test.go,principal_ratelimit_test.go (new)}, platform/ratelimit/tests/principal_test.go (new), core/router/tests/{principal_limits_test.go (new),permissions_test.go,public_routes_test.go}, docs/quizzivy-spec-v0.3.md (v0.48) and, in AGENTS.md, the core/router row of the composition-root table, which now names the three registries. core/router/deps.go, core/core.go and router_test.go needed no change: router.New builds both registries itself.
  • The names. ratelimit.WithPrincipal and the KeyFunc ratelimit.PrincipalKey; Registry.AddKeyed(pattern), a route with no per-address bucket; httpx.PrincipalRateLimit(reg); httpx.AssertPrincipalRoutesGated(spec, scheme, reg); router.PrincipalRateLimits(). A later task adds one line to that function: perActor("PUT /me/avatar", ratelimit.PerHour(10)).
  • The key is the resolved principal's user id (Principal.Access.UserID), which RequirePermission puts on the context, never the token's subject. A request with no resolved principal passes and spends nothing.
  • The five moved and left RateLimits(), with their numbers. An anonymous or forbidden caller of one is answered 401 or 403 every time and spends no budget; a caller who holds the permission has one budget wherever they connect from.
  • "Open" is the gate's rule. The assertion refuses an entry on any operation that does not require bearerAuth: all of theOpenSeven, refresh and logout included, which AssertPublicRoutesLimited does not count as public. It also refuses an entry that names no operation.
  • TestRateLimitsRunBeforeAuthenticationAndThePermissionGate changed its operation, to POST /app/classes/join (120 a minute per address), and its forbidden caller to a teacher, because a student holds learning.take_tests. Its claim is the same.
  • The start-up call is pinned by reading router.go. TestTheRouterAssertsThePrincipalRegistryItEnforces requires the registry asserted to be the one enforced, and TestTheRouterStopsOnThePrincipalAssertionsError requires New to return the assertion's error. router.New cannot be handed a bad registry, so no request shows either.
  • No contract change. None of the five declares x-rate-limit, and none declares a 429 response; both were so before this task. A per-actor operation takes no x-rate-limit block: TestEveryRateLimitBlockMatchesTheRegistry reads RateLimits() only and pins seven.
  • Spec v0.48. §6.5 lists the five budgets per signed-in user, and §5.5 says it of openDocsSession. 72-r2.md keeps "per address" (T-R2.8, T-R2.14b) as R2's record. Done when:
  • ratelimit.PrincipalKey keys a bucket by the resolved principal's user id. PrincipalRateLimits() is a second registry beside RateLimits(); an entry for an open operation fails at start-up, since no principal exists there
  • A second limiter middleware reads it, placed directly after RequirePermission in router.New's inExecutionOrder list; RateLimits() keeps running first, keyed by address or body field, before authentication. A router test pins both positions, as validate_test.go and hardening_test.go pin the order today
  • Every limit this release gives "per user" or "per actor" is registered here (T-R4.8, T-R4.18, T-R4.20). The authenticated credential minters R2 keyed per address (resetStudentPassword, createStudent, rotateJoinCode, getJoinCode, openDocsSession) move to per-actor keys with their numbers unchanged, so staff behind one centre NAT stop sharing a budget
  • theCredentialMinters accepts an entry in either registry; TestRegistryHasNoStaleEntries covers both
  • AGENTS.md's middleware-chain note names both limiters and where each runs
  • Tests: two actors behind one address keep separate budgets; one actor from two addresses shares one; a 429 answers as the address limiter's does

T-R4.54 — Own rows in the teacher workspace's content lists (DG-53)

Depends on: — Touches: server/internal/shared/access/scope.go and the six list handlers in server/internal/modules/*/http; the descriptions in api/openapi.yaml that say one of these lists widens with scope.all (listWordImports, the scope.all permission's own). The database-backed cases for tests, questions and media go in each module's application/tests/; only imports and assignments have repositories/tests/ Size: S As built:

  • Touched beyond the list: shared/access/tests/access_test.go; each module's http/tests/scope_test.go (new in tests, which had none); server/tests/isolation_flow_test.go; the generated files; in the contract also WordImport and listAssignments; docs/plan/60-backend-architecture.md (the kernel's Scope), docs/quizzivy-spec-v0.3.md (v0.49: §5, §16) and the scope bullet in AGENTS.md. One repository changed, against the brief: assignments/repositories/assignments.go, with ListInput.EveryTarget in assignments/domain/commands.go (the two assignment bullets below).
  • One method, six call sites. access.Scope.Own() is the same user with All cleared. Each of the six handlers builds its list input from httpapi.ScopeFromContext(ctx).Own(), and its second queries (facets, tags, the bank's counts, the library's byte total) read that same input. No other handler calls Own.
  • The narrowing is in the transport. A repository still lists every teacher's rows for a scope with All: every read and write by id shares those statements, and R5's Admin console will ask for the wide list. The cases named "scope.all" in the database-backed tests keep pinning that; the cases beside them pin the scope the handler now passes.
  • "Own" for an assignment is the teacher's reach: created by the caller, or targeting a class the caller teaches (visibility.AssignmentIDs).
  • An Admin's own assignment names every target in the list. The repository used the list's scope for the rows and for the targets a row names, so narrowing the rows alone left the Admin's assignment on another teacher's class with no class and a targetCount short of submittedCount ("5/0"). The handler sets ListInput.EveryTarget from the caller's scope.all: it lifts the reach from the targets and leaves it on the rows, so a listed row names and counts what getAssignment does. A caller without scope.all runs the statements it ran before.
  • listAssignments?classId= of a class the caller does not teach lists nothing, as a missing class does, unless the caller holds scope.all. The Admin then reads those of their own assignments that target it (EveryTarget again), so the assignments card of another teacher's class (listClasses keeps scope.all) shows the Admin's own and no teacher's, as decided. A teacher's answer is unchanged, which the isolation suite relies on. T-R4.42 draws that card.
  • Other consequences, not worked around. An Admin who edits another teacher's test by id picks from their own bank and media. The media binds. The bank does not: a draft holds only its owner's questions (T-R2.12a), so every question that picker offers is refused as an unknown one, and the owner's questions, the only ones the draft accepts, are not offered. T-R4.31b draws that picker. The legacy dashboard's count of open assignments still covers everyone until T-R4.19.
  • Media has no read by id in the contract. Its by-id operation is deleteMedia, which keeps scope.all, and other modules bind an asset through Readable in the caller's scope. Both are pinned. In tests, previewTest and publishTest build their scope inline and are pinned at the caller's beside getTest.
  • For T-R4.19: liveAssignments counts with the same narrowed scope the Assignments list uses, httpapi.ScopeFromContext(ctx).Own().
  • The contract changed in words only: PermissionKey, WordImport, listWordImports and listAssignments. The generated diff is comments and the embedded spec.
  • The isolation flow asks the Admin for the six lists after it has opened a teacher's rows by id: each answers with no row of either teacher and counts nothing. The Admin then assigns to a teacher's class, and the listed row, and the list filtered by that class, must name and count what the assignment opened by id does. It is the one test that joins the handler and the repository; CI's "Server tests" job is its first run. Done when:
  • listTests, listQuestions, listQuestionGroups, listMedia, listWordImports and listAssignments, with their facets and tab counts, show the caller's own rows for every caller, admins included: in these lists scope.all does not lift the owner filter
  • Reads and edits by id keep scope.all (70 §4.1); listStudents and listClasses keep scope.all lists until R5's Admin Classes and Users take them over
  • Tests: as the Admin, another teacher's test, question, group, media, import and assignment are absent from each list and its facets and still open by id (200); a teacher's lists are unchanged; R2's isolation suite stays green

T-R4.55 — Pasted-text import source

Depends on: — Touches: migrations/00090_allow_text_import_sources.sql, migrations/00091_add_word_import_sources_characters.sql, api/openapi.yaml, generated code, server/internal/modules/imports/{domain/import.go,application/app.go,application/command/paste.go (new),application/command/upload.go,application/query/limits.go,application/worker/pipeline.go,repositories/store.go,repositories/sources.go,repositories/list.go,http/imports.go,http/errors.go,http/review.go}, server/internal/modules/imports/domain/recognition/{recognize.go,notices.go}, server/internal/core/adapters/{import_text.go (new),import_extraction.go,import_processing.go}, server/internal/core/adapters/tests/import_text_test.go (new), server/internal/modules/imports/{application/tests/upload_test.go,http/tests/intake_test.go,repositories/tests/text_pipeline_test.go (new),repositories/tests/scope_test.go}, server/internal/core/router/tests/{imports_test.go,testdata/permissions.golden}, server/tests/isolation_cases_test.go, docs/plan/20-data-model.md Size: M As built: Source d61d87742c5022a117f751f97c408073a96181f0, 2149 regular files plus the approved node_modules symlink. Draft PR413 required CI37582396811 passed, including server integration and live checks; the first invalid WorkerID fixture failure is retained. The operation, limits, text evidence, metadata/search and private lifecycle are implemented. Recognition remains rules-v2; T-R4.56 grammar/count corpus and T-R4.57 paste editing are not part of this task. Source behavior mutations killed 14/14 with exact restoration; they are not real-PG mutation proof.

Root verified eight public parent executions (four as app, four as migrate), ten unchanged rollback snapshot-canary executions and empty up91/down89/up91. Their role/ACL, object absence, six-control/storage equality and ordinary owned DB/bucket disposal proofs passed. A third purpose verified retained 91 Down and the expected atomic refusal of 91 Up on the format constraint: prior rows, schema and goose90 remained unchanged, with the character column absent. Exact normal DB/bucket disposal, protected inventories and client absence were proved for all three purposes. Migration 91 does not infer removed counts or repair rows. Tasks56 and57 remain separate; no paste-editor acceptance is claimed.

Done when:

  • allow_text_import_sources: word_import_sources_format_check becomes format IN ('docx','doc','pdf','text'). Down re-adds the three-value CHECK NOT VALID, as 00048 and 00052 do, so it works when text sources exist
  • add_word_import_sources_characters: characters integer (1–100000), nullable, with a CHECK that it is set exactly when format = 'text'. Down drops it. An integration test inserts a source with the R3 column list and succeeds
  • Contract: POST /teacher/imports/{id}/sources/text (pasteImportSource, content.tests.write) takes JSON {uploadId, expectedRevision, text}, all required and additionalProperties: false. text is 1–100000 characters (maxLength counts code points) with at least one non-space character. It answers 201 ImportUploadReceipt and the upload's refusals: 400, 404, 409 IMPORT_CONFLICT, 413, 415, 429, and 501 where intake is off
  • x-resource: the path id declares import, the body /uploadId declares none. No x-max-body-bytes: the existing default 1 MiB raw JSON cap applies independently of the code-point limit; JSON escaping can increase the sent byte count
  • ImportSource.format gains text, and ImportSource.characters appears exactly for a text source. ImportLimits gains pasteMaxCharacters (100000); formats is unchanged, because it lists file extensions. Web call sites and test mocks change in the same PR
  • Intake reuses the upload command with the role fixed to exam: the text is stored as UTF-8 at the usual private key, with the constant filename pasted-text.txt, and characters is its code-point count after NFC normalisation. The reservation, the quotas (a paste counts as an import, its bytes as source bytes), the intake slot and the source revision are the upload's, unchanged. A .txt file on uploadImportSource is still refused
  • A replay with the same uploadId, expectedRevision and text returns the first receipt; a changed replay answers 409 IMPORT_CONFLICT
  • The contract refuses a text of more than 100,000 code points as sent with 400 VALIDATION_FAILED, before intake runs. Intake normalises to NFC and refuses a NUL (415 IMPORT_SOURCE_INVALID; invalid UTF-8 too, which a JSON body cannot carry, so only the command sees it), and a normalised text of more than 100,000 code points or 20,000 non-blank lines, the extraction block cap (413 IMPORT_SOURCE_TOO_LARGE). It does not refuse a text without questions: the blocking NO_QUESTIONS finding covers that. Its messages name the pasted text, not a file (vi first)
  • listWordImports does not match a text source's stored filename: the filename half of the search skips sources with format = 'text'; title and companion-file names still match, and the operation's description says so. Test: q=txt and q=past return no pasted import whose title lacks them, its title returns it, and T-R4.21's facets count the same rows
  • Extraction (adapters/import_text.go): the exported adapters.TextEvidence(text, sourceID, role), as PDFEvidence is for a PDF, drops a BOM, turns CRLF and CR into LF, normalises the text to NFC and makes each non-blank line one paragraph block l{n}, n being the line's number in the pasted text, with no spans. The evidence version is text-lines-v1 and the document carries one informational finding, TEXT_MARKS_UNAVAILABLE, as a PDF carries PDF_MARKS_UNAVAILABLE. Recognition accepts the version, and normalisation skips a text source
  • PipelineVersion becomes word-pipeline-v3, so a worker that predates text sources never claims a text run
  • downloadImportSource returns the stored .txt and getWordImportSource its line blocks, with no change to either operation; retention removes the object as it removes a file. A pasted import still takes an answer-key file through uploadImportSource, like any import
  • No rate-limit entry, as uploadImportSource has none: the import quotas and the intake slot bound it. If uploadImportSource gains a bucket in PrincipalRateLimits() (T-R4.53), pasteImportSource shares it
  • Pinned tests updated, none weakened: permissions.golden gains the operation, imports_test.go counts 15 protected import operations, and isolation_cases_test.go gains its row, so teacher B pasting into teacher A's import gets 404
  • Tests: import_text_test.go (BOM, CRLF, NFD input, block ids across blank lines, the 20,000-line cap); upload_test.go (the command: 100,000 code points of Vietnamese text with an astral character accepted, 100,001 ErrTooLarge, invalid UTF-8 and a NUL ErrInvalid, a replay, a changed replay); intake_test.go (through the router: 100,000 code points answer 201, 100,001 answer 400 VALIDATION_FAILED, 20,001 lines answer 413, a NUL 415, the quota and busy answers, a .txt upload refused); text_pipeline_test.go (a pasted test from intake to its draft, as pdf_pipeline_test.go does for a PDF, and a v2 worker leaving a v3 run unclaimed). The scope hand-off and the format branch in intake, normalisation and extraction are mutated once each, and a test fails each time
  • 20-data-model.md records both migrations

T-R4.56 — Recognition conventions for pasted text (rules-v3)

Depends on: T-R4.55 Touches: server/internal/modules/imports/domain/recognition/{recognize.go,exam.go,keys.go,labels.go}, server/internal/modules/imports/domain/recognition/tests/{recognize_test.go,support_test.go}, server/internal/modules/imports/repositories/tests/text_pipeline_test.go, server/internal/core/adapters/tests/import_text_test.go, api/testdata/pasted-text-counts.json (new) Size: M Done when:

  • The rules below apply only to an exam whose evidence version is text-lines-v1, except the one-line key, which applies to every evidence version (#233). A Word or PDF paper otherwise recognises exactly as under rules-v2: the existing recognize_test.go cases pass untouched, and the local corpus run reports no change other than keys that are now read
  • * marks the correct option: a * that is the last or the first non-space character of an option is taken out of its content and makes that option the answer. Several starred options of one question are one answer ("B, D"), not a conflict. A * anywhere else, in a stem or inside an option, stays content
  • An answer line under a question reads Answer: B, Ans: B, Answer - B and Đáp án: B
  • A one-line key, Answer key: 1-B 2-C (also Key: and Đáp án:), is read as the key's entries when it holds at least two pairs. A pair's separator is -, :, . or ), in a key only, so 1-B never becomes a question label. Đáp án: 1-B 2-C is no longer attached to the last question as one value. This rule applies to every evidence version, file or text. Answers: 1. B 2. C is read the same way. A line with fewer than two pairs keeps today's meaning: after Answer:, Key: or Đáp án: its value is the answer of the question above it, and after Answer key: or Answers: it is not read
  • recognize_test.go covers #233 with the existing recognize(t, exam(lines(...))) helper: two three-option questions followed by each of Đáp án: 1-B 2-C, Answer key: 1-B 2-C, Answers: 1. B 2. C give answers B and C and no conflict; Đáp án: B under a question is still that question's answer, and Answer key: B under one still gives it no answer
  • Option letters may be lower-case; a bare 1: is a question label; a run of three or more underscores is a gap, while dots and ellipses keep their threshold
  • True / False / Not given is not a question type until R6 adds tfng: Answer: Not given stays a short answer the teacher fixes in review, and a test pins that. T-R6.13 maps the type for pasted text as for files
  • recognition.Version becomes rules-v3
  • api/testdata/pasted-text-counts.json holds texts with their expected sections, questions, answered count and missing question numbers. A repeated-number case also carries duplicates, the numbers the page's note names; only paste-scan.test.ts asserts it, since the server reports no duplicates. A Go test in import_text_test.go builds each text's exam with adapters.TextEvidence (T-R4.55), never with a helper of its own, so the NFD case passes through the extractor's NFC, and compares them with the review summary and its MISSING_ANSWER findings; T-R4.57's unit test reads the same file. Cases: the deck's sample (3 sections, 10 questions, 8 answered, 4 and 7 missing) and the page's example; each rule above; Question 1 without punctuation, Q1. and Cau 1.; a bare roman heading, Part A and Bài tập 1; "0.25 points each"; a key heading alone on its line; numbering that restarts in each part; an answer at the end of a question line; decomposed (NFD) "Phần 1"; a * inside a stem; a bare number repeated in one part (1. 2. 2. 3. is three questions) and a bare number out of order; Question 2 twice (four questions)
  • text_pipeline_test.go takes the deck's sample from intake to its draft and finds the same four numbers
  • Each rule and the text-source gate is mutated once, and a test fails each time; the PR lists the mutations. The gate's mutation covers every rule except the one-line key, which has a mutation of its own: restricting it to text sources fails the #233 file cases

T-R4.62 — Alt text for a question's image

Depends on: T-R4.16 Touches: migrations/00097_add_questions_media_alt.sql, migrations/00098_add_test_version_questions_media_alt.sql, api/openapi.yaml, generated code, server/internal/modules/questions/{domain/question.go,domain/input.go,repositories/store.go}, server/internal/modules/tests/domain/diff.go, server/internal/modules/tests/repositories/{publish_snapshot.go,version_draft.go,duplicate.go,preview.go}, server/internal/modules/attempts/ (the student question and the result's question), web/src/features/take-test/components/, the result's question image, web/src/features/tests/components/StudentPreview.tsx Size: M Delivered in parts: two pull requests, the server half first; the web half merges before T-R4.30 edits StudentPreview.tsx As built, server half, T-R4.62a (2026-10-09):

  • The migrations are 00097 and 00098, the next numbers after 00092 at 9e838c83. The reservation of 00093 to 00096 for T-R4.11, 9 and 12 that stood when this was written is void: T-5 governs, so a task takes the next number above the integration branch's highest when its PR is opened, and renumbers if another PR merges first (T-R4.11 took 00099 and 00100, T-R4.12a 00101). media_alt text is nullable with no default and no backfill, and questions_media_alt_check and test_version_questions_media_alt_check hold 1 to 1000 characters, added with the column, so there is nothing to validate. D-33 and §37 record it. No check ties the column to an image: the write path does that, and a database check would fail an R3 write that swaps an image for audio on a question that had alt text, during the rolling deploy.
  • The contract gains mediaAlt as an optional nullable string on QuestionInput and StoredQuestionInput (1 to 1000 characters), AdminQuestion, StudentQuestion and ResultQuestion. StoredQuestionInput is beyond the plan's list and needed: the group endpoints read their members through it, and a member's alt text would be dropped by the first group read-modify-write without it. It is optional everywhere, so no fixture changed and the web build is untouched. previewTest returns StudentQuestion, so the version preview has it.
  • A value for a question whose media is not an image is a 400 VALIDATION_FAILED with error.details.mediaAlt, the answer every other field error of a question write has (audio, transcript), not a 422. Input.Validate also refuses a blank value, one over 1000 characters and one with a NUL (the request schema already refuses "" and the length). Publish validates the draft question again and the group check treats it as it treats the other text.
  • A write replaces the stored value, as it does for level and skill: an update that leaves mediaAlt out clears it. T-R4.62b and T-R4.65 must send it back on every autosave. The bank store also writes it only beside an image (imageAlt), so clearing the media, or changing it to audio, leaves NULL whatever a caller handed the store. A file replacement (T-R4.17b) keeps it, because it never changes the kind.
  • Publish copies it in the INSERT that freezes the question. loadQuestions selects it with the draft question and freezeQuestion writes it; only the column lists grew. lockDraftContent, the order of its row locks, questions.LockForDraftUse and media.LockForVersionUse are not touched, and publish_snapshot_test.go is untouched and green. Restore as draft copies it in copySnapshotQuestion's INSERT ... SELECT and, for a group member, through readFrozenMembers. A test duplicate references the bank questions it lists, so standalone alt text travels by reference (duplicate.go needed no change); a duplicated group copies its members through the bundle, which carries Input.MediaAlt. The version preview and the student's paper read the frozen column.
  • The diff puts it in a question's media part (diff_paper.go), so an alt-text edit is one changed entry under media and raises unpublishedChanges by one; the fingerprint that pairs a restored copy with its original includes it, so a restored copy still matches and a copy that lost its alt text reads as added and removed. A question published before the column reads NULL, so a teacher who adds alt text to it sees one change, which is true.
  • In attempts the change is four lines: domain.Question.MediaAlt, the column in questionsQuery and its scan, and the two response mappings. Questions serves the paper, the session and the result. The payload-leak walks are unchanged and green.
  • Beyond the Touches list: questions/http/{input,questions}.go and questions/application/internal/support/shared.go (a bank duplicate rebuilds its input through InputOf), tests/repositories/{diff,group_frozen,group_store,publish_load,publish_groups}.go, tests/domain/{draft,diff_paper,group_validation,publish_validation}.go, tests/http/tests.go.
  • The spec is v0.62. §7 Question gains mediaAlt, the §13.3 sketches of questions and test_version_questions gain media_alt, and "Changes since v0.61" records the behaviour. T-R4.63 (#424) merged first with v0.61, so this entry took the next number.
  • The attempt review does not carry it yet. The teacher's attempt review builds AdminQuestion from the frozen question in attempts/http/admin_attempts.go and leaves mediaAlt unset; T-R4.47, the attempt review, carries it. Its server read (Reviews.questions in attempts/repositories/review_review.go and the mapping in admin_attempts.go) is left out of this half so as not to collide with T-R4.11, which works in attempts.
  • Web writers that must send mediaAlt back, because a write replaces the stored value and one that drops it clears it: the bank editor (web/src/features/question-bank/questionSchema.ts and QuestionEditor); the group composer's recovery (web/src/features/question-groups/recovery.ts:40, a zod object that strips unknown keys, so a draft restored through it loses the field unless the schema names it); the builder's inline question editing (web/src/features/tests/pages/teacher/TestBuilderPage.tsx:1075, starterQuestion); and T-R4.65's media block, which edits it. T-R4.62b carries the first three and T-R4.65 the last; their Done-when boxes say so.
  • For R5 (after v0.10.0): the image rule becomes a database invariant. Clear media_alt on questions where the kind is not an image, assert that no test_version_questions row holds the pair (publish validation and the R3 binary make that so; a version is not rewritten), add CHECK (media_alt IS NULL OR media_asset_kind IS NOT DISTINCT FROM 'image'::app.media_kind) to both tables NOT VALID, then VALIDATE them. The kind is already denormalised on both tables, so the predicate needs no trigger or join. 20-data-model.md §37 has the three files and why the predicate says IS NOT DISTINCT FROM.
  • Evidence. Nineteen one-line mutations, one at a time, each failed a test of this change: the freeze, the draft read, the restore copy, the frozen group read, the group copy, the version preview, the paper, the student and result mappings, the bank store's image rule and its update write, request validation, publish validation, the bank duplicate, the version diff read and the diff term. The R3-shaped insert (neither column named) succeeds on both tables, and each check holds the bounds 1 and 1000 and refuses 0 and 1001.
  • Canary runs, high-risk area. Same commands on the branch point 9e838c83 and on the last code commit c066130e, against a scratch PostgreSQL 18.6 database, every exit code 0, nothing skipped. Passing tests, branch point then last commit: the tests module's integration tier 342 then 360; its application package, which holds publish_snapshot_test.go, 121 then 126; the attempts application package, which holds TestTheSameClientSeqFromTwoSessionsBothPersist and the payload-leak walks, 151 then 152; the attempts repositories with the grading-queue database provisioned, 14 then 14; the questions module, 85 then 113. The web canaries and the take-test unit tests read the same on both: audio-player 14 tests, client.refresh 10, router-chunks 13, autosave-unmount 8 and tests/units/take-test 35 files with 516 tests. The nested --- FAIL lines in those logs are the fixture-lifecycle children that fail on purpose under a passing parent. The diff from 9e838c83 shows none of the five canary files, nor anything under modules/access, features/take-test, features/integrity or features/media. As built, web half, T-R4.62b (2026-10-10):
  • One reader, take-test/components/QuestionImage.tsx, takes the question (media and mediaAlt, the shape StudentQuestion and ResultQuestion share) and draws its image through ContentImage in R3's frame (DG-80), with alt the question's text or the generic takeTest.imageLabel when it has none; it draws nothing for audio or no media. One component rather than three <img alt> edits, so D4's preview frame (T-D4.16) and its caption (S-25, not built) change one place. No caption prop is added before D4 needs one.
  • Where it is drawn. QuestionSheet (the engine) uses it in place of its inline image. StudentPreview already renders through QuestionSheet, so the builder's preview and the version preview take the alt text with no edit to StudentPreview.tsx, which T-R4.30 and then T-R4.31b edit. The result's ReviewItem now draws the question's image under the prompt row; it sits inside [data-slot=result-answers], which already carries PAPER_SURFACE (DG-35).
  • The writers. QuestionValues.mediaAlt is required (.nullable().default(null), as level and skill), so a constructor that forgets it fails pnpm typecheck rather than clearing the stored value: toFormValues reads question.mediaAlt, and emptyQuestion, the builder's starterQuestion and the group editor's memberValues name it. The builder's inline editing goes through toFormValues. The group recovery's zod object names it (z.string().nullish()). QuestionEditor sets it to null beside mediaAssetId whenever the media changes, because the server refuses alt text on a question whose media is not an image: a swap to audio, or a removal, would otherwise fail the next save.
  • The loss this closes was latent, not live. On work/redesign-r4 at 00c7c45e only the question API writes media_alt (create, update and the group endpoints, from the request body); the Word import, the seed and the migrations never set it, and no web screen does until T-R4.65. Only a direct API call could have stored a value for a web save to clear.
  • Tests: take-test/question-image.test.tsx (alt, the generic label for null and absent, nothing for audio, retry); a preview case in builder/group-preview.test.tsx; a result case in results/result.test.tsx (the image under the prompt, on the paper surface, and the generic label beside it); round trips for the bank (question-bank/metadata-roundtrip.test.ts, set and null, through toFormValues, the schema and updateQuestion) and the group recovery and memberValues (question-groups/metadata-roundtrip.test.ts); and the editor's two media changes (question-bank/editor.test.tsx). Removing the recovery line fails its two cases, and removing the editor's line fails its two. Done when:
  • The 2026-10-04 builder draws "Alt text: describe the image for screen readers" under a question's image, and the model has no field for it (DG-111)
  • add_questions_media_alt: media_alt text, nullable, 1–1000 characters (the bound ContentImage.alt has), on questions. add_test_version_questions_media_alt: the same column on test_version_questions, nullable, no backfill. Down drops each
  • Contract: QuestionInput and AdminQuestion gain nullable mediaAlt. A value is refused with a field error unless mediaAssetId names an image, and clearing or changing the media to audio clears it. The student question and the result's question gain mediaAlt beside media. It is not an answer key, and the payload-leak walk stays as it is
  • Publish copies media_alt into the version row, keeping the media.LockForVersionUse and questions.LockForDraftUse ordering unchanged; restore as draft, duplicate and the draft preview carry it
  • The version diff (T-R4.16) counts a changed media_alt as changed, under media, so unpublishedChanges rises after an alt-text edit; a diff unit test covers it
  • (T-R4.62b) The engine's and the result's question image (R3's frame, DG-80) and the builder's Student preview take it as alt; an image without one keeps the generic label. The engine draws the image today with a generic label; the result's ReviewItem and the builder's StudentPreview draw no question image and learn to here
  • (T-R4.62b) Every web writer of a question sends mediaAlt back unchanged, because a write replaces the stored value: the bank editor (questionSchema.ts, QuestionEditor), the group composer's recovery (web/src/features/question-groups/recovery.ts:40, whose zod object strips unknown keys) and the builder's inline question editing (web/src/features/tests/pages/teacher/TestBuilderPage.tsx:1075). T-R4.65's media block, which edits the field, does the same when it lands
  • (T-R4.62b) A round-trip unit test for the bank editor and for the group recovery: toFormValues then the request keeps mediaAlt, set and null. The one-way AssignableTo<QuestionValues, QuestionInput> at questionSchema.ts:226 does not fail when the form omits an optional field of the request, so only a test sees it dropped
  • Tests: publish_snapshot_test.go green and untouched; a new test beside it changes a bank question's alt text after publish and shows the version's is unchanged; alt text on an audio question answers 400; an R3-shaped insert succeeds
  • The five canaries, the tests module's and the take-test unit tests run on the branch point and on the last commit, both runs in the PR (high-risk area)

T-R4.23 — Dashboard

Depends on: T-R4.4, T-R4.5b, T-R4.19, T-R4.3b Touches: web/src/features/dashboard/pages/teacher/TeacherDashboardPage.tsx (new, from web/src/app/pages/AdminDashboardPage.tsx), web/src/features/dashboard/api.ts, the shell's useNavCounts, web/tests/units/dashboard/, web/tests/e2e/smoke.spec.ts, web/tests/e2e/teacher-shell.spec.ts, web/tests/e2e/theme.spec.ts Reuses: KpiTile, LiveDot, BarChart, ProgressBar, StatusBadge, useIdlePolling Size: M As built, staged 2026-10-06. The Dashboard and its API destinations preserve the existing permission gates, own metrics, shared home query and independent live query. Home/live poll every 30s; nav summary polls every 60s. Independent integrated production-source review is approved. The focused six public suites pass 110 tests, and current typecheck plus scoped fixture lint/format pass. Independent native QA approved the staged frontend: 39 distinct Dashboard cases plus 23 shell/smoke, 64 passed executions including two corrected own permission probes. All ten settled width/theme boards and six lower-card scroll views were viewed. Actual LiveDot and skeleton hover/focus pause, reduced static motion and the retained chart's 200ms height transition pass. Real home/live and summary requests confirm the 30s/60s cadence. Hidden-tab behavior and actual Assign wizard composition were not newly proved by these mocked native cases. Dashboard titles remain static.

The first hosted CI failed on the extensionless AJV ESM import and a global taking fixture count. The explicit 2020.js entry preserves validator behavior; actual collection and 126 public contract/dashboard tests pass. The repository fixture now combines complete initial eligible identities with its owned rows, preserving All semantics and exact own/Admin/zero boundaries. Full flagged identities span 100+5 public rows with no extras, duplicates or omissions. Source review and independent app/migrate QA approve the one-file repair: each role passes six parent functions, five subcases and nine leaves. Checked rollback and all five preserved database inventories, including 411 prior audit identities, remain exact. The original hosted contributing fixture is unproved. The second hosted run passed the actual Go unit, integration, end-to-end and converter checks, web checks, contract and live API suites, but failed the old theme test's retired Dashboard target. The repaired test visits the genuinely old Settings route, preserving its light-theme assertions and the first two theme cases. A new case follows real SPA links from populated Dashboard to loaded Settings and back. Independent native QA passes all four cases without retries: one HTML document request, dark/light/dark computed body colours, and the stored dark preference unchanged. Returning dark is proved by trace assertions and computed styles; the final paint was not captured in the passing screencast. No new production build or source behavior changed. Staged source/native acceptance is integrated through PR408: exactd403d0a6 passed required CI37430274801 and normally merged to R4f7478e00. Integration pushCI37431176879 passed with matching recorded hashes. The user-zone header criterion stays open for T-R4.7's reactive account hydration; no profile completion is claimed.

The three retired smoke.spec.ts cases are replaced by the preserved real-route teacher-shell.spec.ts scenarios, following the new 768px shell threshold:

  • admin sidebar is open by default above 1280px: the real-route persistence scenario starts expanded at 1280, and the route geometry matrix includes 1440 in both themes. The geometry case does not assert the old nav label separately.
  • admin sidebar collapses at 1280px and the toggle reopens it: the persistence scenario collapses with Enter, checks the stored state and reload, and preserves the page/draft/focus across 767/768/360/1280. Automatic 1280 collapse is retired.
  • admin sidebar toggle is keyboard operable: that desktop Enter scenario and the phone 360 Space-open, focus-trap, Escape-dismiss/trigger-return scenario.

All 19 existing Teacher-shell assertion/request bodies and unrelated smoke cases are byte-exact. Its typed fixture adds only nullable Dashboard destinations and a separate TeacherSummary response. Assignment detail (T-R4.25) remains the first actual rebuilt route; all 19 preserved Teacher-shell scenarios now pass in fresh independent native verification with mocked HTTP responses. Dashboard titles are static. Continuous LiveDot and skeleton motion must pause on hover/focus and be static under reduced motion; the chart follows the deck's 200ms height transition. No later marquee caller or D4 requirement is closed here.

Done when:

  • Header: greeting by time of day with the given name ("Chào buổi tối, Thương" / "Good evening, Thương") and the long date in the user's zone; "New test" (creates a test and opens the builder, content.tests.write) and primary "Assign test" (wizard, teaching.assignments.write)
  • Four KPIs, auto-fit minmax(210px,1fr): To grade (→ Grading; hint "{n} students · oldest {age}"), Flagged attempts (danger icon; → the newest flagged attempt's assignment with ?tab=students&attempt=), Closing in 24 h (→ the next closing assignment; hint "{title} · {x}/{y} in"), Taking a test now (LiveDot, static under reduced motion; refetched every 30 s through useIdlePolling)
  • Submissions card: title and "{total} in the last {n} days · average score {p}%", range segments 7/14/30 days in ?range= driving the query (DG-70); every other day labelled on phones
  • Today (opens and closes of the user's assignments; sessions arrive in R8), Live assignments (title, "{classes} · closes {when}", progress x/y, "{n} to grade", View all) and Recent activity (initials, name, action, time, a danger "Flagged" marker per D8)
  • States: skeleton; empty dashboard ("No assignments yet." + Assign test, DG-83); error with retry per card
  • The index route moves to TeacherLayout; useNavCounts switches from getDashboard to GET /teacher/summary; smoke.spec.ts's three sidebar cases are removed, each recorded as replaced by a case of web/tests/e2e/teacher-shell.spec.ts
  • If T-R4.19 is merged when T-R4.4 merges, this is the first route in the new shell: it adds teacher-shell.spec.ts at its own route (the sidebar expanded by default, collapsed and persisted, the drawer below 768, the toggle operable by keyboard) and carries the first deck comparison of the shell with a real page
  • Deck check at 360, 768, 1024, 1280, 1440, light and dark; keys vi first, en from the deck, listed in the PR; web/tests/units/dashboard/ rewritten for the new screen

T-R4.24 — Assignments list

Depends on: T-R4.4, T-R4.5b, T-R4.1a, T-R4.1b, T-R4.1c, T-R4.2a, T-R4.12, T-R4.13 Touches: web/src/features/assignments/pages/teacher/AssignmentsListPage.tsx, web/src/features/assignments/api.ts, web/tests/units/assignments/list.test.tsx Reuses: DataTable, Pager, BulkActions, FacetFilter, StatusBadge, AssignmentStatusFacets (a contract schema), TargetsLine Size: M

As built:

  • Frame. PageHead ("Assignments", "Tests you have sent to classes and students.", and New assignment with teaching.assignments.write), then the deck's segmented tabs Live, Scheduled, Closed and Drafts with their counts from AssignmentStatusFacets (open reads Live, and so does its status pill in English), a search (q, debounced) and the Class facet (FacetFilter, several classes). The tab is status (Live when absent), the classes classId, then q, page and size, all in the URL; the wizard's and the class card's links keep working.
  • Table. DataTable with assignmentColumns: Assignment (title, "Test v{n} · {q} questions", "· {n} to grade" in warning ink from pendingGradingCount), Assigned to from 860px of content width, the tab's window column (Closes, Opens, Closed, Window) from 700, Submitted (the accent bar and x/y) from 560 and Status from 760; while Assigned to or the window column is hidden, the first cell adds "classes · when". A window time reads "Today, 21:00", "Tomorrow, 08:00" or "21:00 · 26/09". The status pill is derived from the window (statusAt), as before. Below 768px rows are the deck's cards, with no checkbox, menu or bulk bar.
  • Row menu. Open; then, with teaching.assignments.write, Edit settings (the detail's ?tab=settings), Extend deadline (disabled on a draft and on a closed assignment, DG-154), Duplicate (a draft for the chosen classes, DG-154), and Close early on a live one (the detail's CloseEarlyDialog, DG-153) or Delete on a draft (DG-155).
  • Bulk bar. Extend deadline (ExtendAssignmentsDialog: 15 min, 30 min, 1 hour or 1 day and Notify students, one extendAssignment per assignment, the failures named with Retry) and Close now (BulkActions, one close per live assignment, the others reported) with teaching.assignments.write; Export results with teaching.grading, one CSV for up to 50 through downloadFile (lib/api/client.ts, sharing the token and the one refresh) and a name from Content-Disposition or resultsFileName; a refusal, the rate limit included, is shown.
  • States. A skeleton of the table, "No assignments here." with New assignment inside the table, and the load error with Retry.

Done when:

  • Header "Assignments" / sub + New assignment; tabs Live, Scheduled, Closed, Drafts with counts (open reads as Live); the window column reads Closes, Opens, Closed or Window per tab
  • Columns: select, Assignment (title, "Test v{n} · {q} questions", "· {n} to grade" in warning ink), Assigned to, window, Submitted (bar and x/y), Status, menu; drops at 860/760/700/560 with the inline "classes · when" line
  • Class facet (multi) and a wired search; tab, classes, q, page and size in the URL
  • Row menu: Open, Edit settings (detail ?tab=settings), Extend deadline (disabled on drafts), Duplicate, Close early (live only), Delete (drafts only), each shown only with its permission
  • Bulk: Extend deadline (per-item extend, 15 min / 30 min / 1 hour / 1 day), Export results (CSV download, teaching.grading), Close now (per-item close) — partial failures reported
  • Phones: cards, no bulk bar, no menu
  • States: skeleton; "No assignments here." + New assignment; error with retry
  • Deck check at the five widths, light and dark; keys vi first, listed in the PR

T-R4.25 — Assignment detail: header, stats, Students tab and the attempt sheet

Depends on: T-R4.4, T-R4.5b, T-R4.1a, T-R4.1b, T-R4.2a, T-R4.2c, T-R4.3b Touches: web/src/features/assignments/pages/teacher/AssignmentDetailPage.tsx, web/src/features/attempts/components/{Monitor.tsx,AttemptSheet.tsx (new)}, web/src/features/attempts/pages/teacher/AssignmentAttemptsPage.tsx (retired, redirected), web/tests/units/assignments/detail.test.tsx, web/tests/units/attempts/{monitor,papers}.test.tsx, web/tests/e2e/teacher-shell.spec.ts (new, if this is the first route in the new shell) Reuses: Monitor (roster data and live polling), FocusLossCell, the integrity Timeline, TeacherNoteCard / setAttemptNote, StatStrip, Sheet, DataTable Size: M As built:

  • Ownership: the actual detail leaf declares its 1320px Teacher handle; small derivation/URL modules and a sheet-owned note controller accompany the page, roster, compact timeline, redirect, locale and public-test changes.
  • Full monitor: one detail-level read owns live polling and all statistics, regardless of the roster's search/filter/page; average is the rounded mean of graded positive-denominator score ratios, and missing values remain unknown.
  • Paper identity: workspace and permission gates precede private reads; a review must match the assignment, requested attempt and student before its sheet content or events appear, including historical deep links absent from the roster.
  • Notes: authorized teachers share the existing teacher-only attempt note, so copy says “Not visible to students” under DG-84; grading edits, while grading or intervention reads, without changing API scope or the full-review autosave card.
  • Departure: blur saves serialize immutable attempt payloads and drain later edits; switch, X, Esc, backdrop, tabs and ordinary SPA/Back departure await the latest save, retaining failures with generic retry, stay or explicit discard even when the assignment becomes unavailable or workspace permission is lost; unload warns but cannot promise asynchronous durability.
  • Default contracts: compact Timeline reuses neutral paired events and omits its default note card; full mode remains unchanged; DataTable gains optional row tone/opening predicates and omits only directly empty row menus while preserving their header/cell/track and existing keyboard behavior.
  • URL: both retired Teacher and case-insensitive Admin paper addresses translate the old filter to roster, set tab=students, preserve other query/hash values and replace history; sheet opening pushes, close replaces only attempt, and this roster opts into hash preservation for pager links, size setters and implicit filter resets while shared defaults stay unchanged.
  • Staged grading: header Grade counts full-monitor pending answers and opens the first eligible paper's sheet, whose Grade link opens the existing full review; the literal filtered grading queue remains T-R4.14/28 work.
  • Staged settings: pinned-version Questions and actual Settings facts remain useful; existing Publish/Edit, live row Extend/Reset/Void, Close early and closed Reopen are preserved; combined Everyone/Chosen students/Notify Extend and the redesigned dialogs remain T-R4.26 after 12/13.
  • Accepted close: the page retains only the selected assignment/attempt IDs after an accepted same-page close and drives the existing controlled Sheet to false; Presence owns its exit and removal. Current identity and permissions still gate every private element, and a first late response cannot reveal content during exit. Outgoing reads, edits, retries and navigation stop, while the painted countdown retains its last active value. Cross-page unmount keeps its save/security contract without promising a global outgoing animation.
  • Native repair geometry: deck search fields preserve icon clearance at both densities without changing off-deck controls; the fixed 54px phone row uses compact status and a truthful flag/count with a full localized accessible label. Missing counts remain unknown. Actual layout and focus still require browser verification.
  • Failed-note recovery: ordinary blur reads the current draft and does not retry a known failure, including later edits; explicit Retry or departure-save still drains the latest value. A failed departure requests focus once on the current authorized invalid note, with no replay after identity, reopen or permission changes. Active-write Discard still waits for the real write outcome.
  • Origin and loading repair: an additive per-row menu context forwards a stable Actions ref for event/effect use only; intervention close returns to that actual origin or its captured main fallback. Deck ListSkeleton hosts pause descendant shimmer on hover/focus-within without adding a tabstop or changing off-deck motion. The narrow ref-forwarding lint directive leaves the rule enabled elsewhere.
  • Verification boundary: public deferred-save, identity, permission, recovery, filtering and polling assertions and isolated mutation restoration cover source behavior; the real-route shell spec is provided, but native geometry, focus, Sheet motion and CI remain independent acceptance gates.
  • Motion: this title and row labels are static; no marquee or invented panel entrance was added, and reused Sheet/menu/control motion still requires actual intermediate/settled-frame and reduced-motion browser evidence. Done when:
  • Header: crumb, title, status pill, "{classes} · {when} · {duration}", "…" menu, Extend, primary "Grade {n} answers" when answers are pending (Extend moved to T-R4.26 and is done there, QA-F1-4)
  • Stats Submitted x/y, In progress, Average, Flagged (danger ink above 0); four columns from 640 content width, two below
  • Underline tabs Students / Questions / Settings in ?tab=
  • Students tab: Student, Status (Submitted, In progress, Not started, Timed out, Needs grading), Score, Time, Focus lost, Submitted; drops at 560/820/700/940 with the inline "status · n× focus lost" line; flagged rows highlighted (D8); paginated; polling through useIdlePolling while live
  • Row click opens the 420px attempt sheet at ?attempt=: initials and name, Score and Time spent tiles, the attempt timeline, a private note ("Only you can see this", saved on blur), "Open full attempt" and primary "Grade answers" (→ Grading filtered to this student) (the note's copy follows DG-84; the Grading link moved to T-R4.26 and is done there, QA-F1-5)
  • /teacher/assignments/:id/attempts redirects to ?tab=students, and LegacyTeacherRedirect gains the same rule for /admin/assignments/:id/attempts
  • If T-R4.19 is not merged when T-R4.4 merges, this is the first route in the new shell: it adds web/tests/e2e/teacher-shell.spec.ts at its own route (the sidebar expanded by default, collapsed and persisted, the drawer below 768, the toggle operable by keyboard) and carries the first deck comparison of the shell with a real page
  • States for the page and each tab: skeleton, empty roster ("No students yet"), error
  • Deck check at the five widths, light and dark; keys vi first, listed in the PR

Reopened in D4 (74d-d4-deck-update.md): T-D4.22 replaced the four stat tiles with the progress card, whose legend filters the roster (?roster=) and counts over the whole monitor, so a voided attempt is no longer Flagged; the roster's select gained "In progress" and keeps its search (DG-209).


Part 3 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.

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