Plan 74 R4 Part 3 - SekiroKenjii/Quizzivy GitHub Wiki
Part 3 of 6 of R4 — Teacher workspace (v0.10.0) · Previous · Next
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-resourcethe test) andTest.unpublishedChanges, required and nullable. The permission golden and the operation count (108 to 109) move with it, and the isolation table has its row besidedeleteTestVersion. -
The read is one
REPEATABLE READ,READ ONLYtransaction (Postgres.DiffPapers):Begin, thenSET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLYas the first statement, so both papers are one snapshot, then 16a's lock-freeloadDraftSnapshotandloadVersion. It cannot run on a repository built over a transaction (Beginthere is a savepoint), and its doc comment says so. Nodb.Conngained aBeginTx.readFrozenGrouptakes the lock clause (versionSharedforFrozenand the restore,versionUnlockedhere); the draft's "group belongs to another section" became the sentinelerrGroupOtherSection, with the same text, so the diff can tell it from a database failure. -
Sides.
against=previousis the highest version below the path version and, on the first version, has nofrom: every question isaddedand the total is not reported.draftmakes the path versionfrom. A number puts the lower of the two first. The path version itself is a 400 fromdomain.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 422VALIDATION_FAILED(the draft's groups withagainst=draft, or a version's, see below), the answer a refused group gets elsewhere. -
Identity.
Comparematches bySourceID(the version'ssource_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 asaddedandremoved. -
Entries.
addedandremovedper question (removednumbered in the older paper);changedper question with the parts that differ (type,prompt,options,blanks,media,points,explanation,context,section);answerper question whose key differs;pointsonce when the totals differ. A question can have achangedand ananswerentry. 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 ischangedandanswer(answerFrom,answerTocarry the labels, for a choice question only). -
contextandsectionreach every member. A group's title, instructions, materials with their gap bindings and recordings, and a member's option order, are the question'scontext; 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 inflatesunpublishedChangesfor 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. -
unpublishedChangesislen(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 whosecurrent_versionis 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: aGroupError, 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 allremoved; any other failure of the count failsgetTest. -
A version that no longer reads back (review round 1).
loadVersionreads a group withreadFrozenGroup, which validates it. A stored group that fails (*GroupError) or is gone (ErrNotFound) is wrapped indomain.ErrVersionUnreadable, sogetTestleaves the count null and the endpoint answers 422VALIDATION_FAILEDwith 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 showsgetTestat 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,IntroductionandDiffPapers.Changesreturn an error: a question's points thatPointUnitsrefuses (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 + Sstatements 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 (readUnlockedandreadFrozenGroupeach read one group in about eight statements); batching them is not done here. -
QA-16a-1 is fixed in this PR (its own commit):
testColumnscarried seven correlated subplans, four over the draft's questions and, since 16a, three over the assignments. Atlimit=100the planner priced the list at 111,350 on 400 tests, 1,200 versions and 3,600 assignments, overjit_above_cost, so a connection's first executions JIT-compiled 168 functions (98.7 ms against 6.9 ms). Both groups are now oneLATERALaggregate each (testFrom); the estimate is 65,541, there is no JIT, and the 100 rows are identical. The laterals run for the rows anOFFSETskips 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
getTestagainst a scratch database leftpg_stat_user_tables.n_tup_upd,n_tup_insandn_tup_del(summed over schemaapp) andtests.updated_atunchanged, where oneUPDATEof 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'sxmin,xmaxandupdated_at, by the statements the transaction runs (the second is theSET TRANSACTION; none is a write or has aFORclause), and by a bank edit committed from another connection in the middle of a diff, which it must not see. Done when: -
domain/diff.gocompares two papers, matching questions bysource_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) andpoints(total); order alone is not a change -
GET /teacher/tests/{id}/versions/{version}/diff?against=previous|draft|<n>(thex-permissionoflistTestVersions) →{from, to, changes: [{kind, questionNumber, questionId?, params}]}(DG-66) -
getTestgainsunpublishedChanges, the number of changes between the draft and the latest version, computed by the same diff in aREPEATABLE READ READ ONLYtransaction that reads the draft withloadDraftSnapshotand never callslockDraftContent,GroupsPostgres.Getor anyFORclause; 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.gogreen and untouched - QA-16a-1: the list's estimated cost at
limit=100is underjit_above_cost, with the same rows
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}andapplication/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, andplatform/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
00082to00084, the next numbers after00081at 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, inrepositories/list.go(library). The list,total,totalBytes, the facets, the usage and the quota sum are built on it.Get,Readable,ReferencesFor,LockForVersionUseandSoftDeleteare unchanged and still reach a replaced file. Nothing here writesreplaced_by. -
questionCountandunusedare 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 aUNION, 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})takespg_advisory_xact_lock(73820, hashtext(owner::uuid::text))and then sums the owner's library withoutLeaveOut;repositories.Insert(ctx, tx, in)writes the row and its audit row forin.OwnerID, or the uploader when that is empty.Postgres.Insertcomposes them. The one comparison,held + add > quota, isdomain.Assets.CheckQuota. F-68 split it intoLockQuotaandCheckQuota, droppedLeaveOut, 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. -
InsertInputgainedOwnerID,DisplayName,DefaultMaxPlays,Width,HeightandQuotaBytes. A caller of the repository must setQuotaBytes: zero refuses every row. The application passes its own,domain.DefaultOwnerQuotaBytesunlessWithOwnerQuotaset another; a config test holds that default equal toMEDIA_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.Intakecarries the kind, MIME type, bytes, duration, size in pixels and checksum;Body()rewinds andClose()removes the file. It takes nothing from the upload command. -
One code, two sentences for a file too large.
domain.ErrImageTooLargewrapsErrTooLarge; 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. -
updateMediais 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.defaultMaxPlaysisjson.RawMessagein the generated type: left out keeps the limit,nullclears it, andnullis accepted for an image. -
defaultMaxPlaysis stored and applied to nothing. No question takes it by itself; the editor reads it (T-R4.65).uploadMediastill answersMediaAsset, so the limit sent with an upload is read back from the library row. -
probe.Imagereads PNG and JPEG throughimage.DecodeConfigand 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_MIBis 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.mdrecords 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 NULLwithreplaced_by <> id. Down drops the three -
add_media_assets_dimensions:width,heightintegers, 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), addedNOT VALIDthen validated, the old constraint dropped. Down re-adds the old CHECKNOT VALIDso 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.tsand 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 (409MEDIA_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.exampleupdated;deployment_test.goandfly.tomltakeMEDIA_OWNER_QUOTA_MIB - Contract:
LibraryAssetgainsdisplayName,defaultMaxPlays,width,heightandquestionCount(bank and draft references);listMediagainsunused=trueand returnsusage {audioBytes, imageBytes, quotaBytes}; replaced assets leave the listing; upload takes an optionaldefaultMaxPlaysas a query parameter, anduploadMediadeclares 400 and 409;PATCH /teacher/media/{id}(content.media.write) setsdisplayNameanddefaultMaxPlays -
listMediagainsq, matched without accents on the display name and the original filename throughapp.immutable_unaccent(lower(…)), and returnsfacets {all, audio, image, unused}counted underq, ignoringkindandunused. 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)
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, declarescontent.media.writeand the media resource. Multipart file validation retains upload's sniff, size, duration and probe limits; different kinds return 415MEDIA_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.Poolbefore Begin and owns the outer transaction. Nil Commit confirms Committed; known rollback orSafeToRetryconfirms 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}/replaceis 10 a minute and 60 an hour per user inPrincipalRateLimits(), 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.
replaceMediaanswersprevious(the old file, the versions that hold it, a URL to download it, when its object goes),deleteMediaanswers 200 with the same shape, andusagegainsheldBytes,pendingBytesandpendingDeleteAt. 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 are20-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 namesdeleteAt; the Storage card countsheldBytesand 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 neededuploadFileto readRetry-After.deleteMediastill 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 genstays 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 (415MEDIA_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 getsreplaced_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 inleft; notest_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 ingroup_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;TestLockForVersionUseSerialisesAgainstDeleteandpublish_snapshot_test.gogreen and untouched
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) androom text(1–60), nullable. Down drops both -
ClassgainsscheduleLabel,roomandaverageScore(anAttemptScorepair over best graded attempts on assignments targeting the class, or null);createClassandupdateClassaccept the two fields;MyClassgainsscheduleLabel,roomandteacherAvatarUrl— never counts or members./join/previewis unchanged here; R8 adds schedule and room to it -
rotateJoinCodesets 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
listClassesitems carry the readable code in their own item schema (ClassListItem;Class.joinCodestaysJoinCodeInfo), opened only when the request sayswithCodes=trueand only for a caller who holdsteaching.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 onegetJoinCodeper card and pickers never receive a code; every answer isCache-Control: no-store;ListClassesjoinstheCredentialMintersand the pinnedwantlist, with 60 a minute and 600 an hour per actor inPrincipalRateLimits() - 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.golistslistClasses
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 onlyfeatures/classes/api.tsand 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):
listClassesopens a code only forwithCodes=true, so pickers never receive one and their query caches never hold one.withCodes=truewithoutteaching.classes.writeis not an error: everyjoinCode.codeis null. The rule is that the list grants nothinggetJoinCodedoes not: a code opens in the list exactly whengetJoinCodewould open it for the same caller and class. One function,Enrolment.Read, opens a stored code for both;ActiveCodesreads the same columns under the same scope asActiveCode; the permission is the onegetJoinCodedeclares.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 makesgetJoinCodefail, and the list shows null for that class, logged by class id, so one damaged row cannot take a page of cards down. -
ClassListItemrepeatsClassinstead of extending it:allOfwithadditionalProperties: falsedoes not validate, and acodeinJoinCodeInfowould makegetClass,createClassandupdateClasscredential minters. A test holds the two property lists together (and the row requires onlyaverageScorebeyond the class). The detector finds{code, expiresAt}inJoinCode, solistClassesis a minter without any special case. -
Expiry:
domain.CodeExpiry(now, days, zone)istime.Date(y, m, d+days, 23, 59, 59, 0, zone)with y, m, d fromnowin zone. The zone is the actor's stored profile preference, read through identity's effective-zone query behindports.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 toAsia/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, behindstats.ClassSource, and the classes app takes it throughWithClassScores(none: no average).Class.averageScoreis optional, so it is omitted when null ongetClass,createClassandupdateClassand never on a student's join response; aClassListItemalways 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 ofattempts_student_started_idxand no sequential scan ofattempts, andattempt_answers_pending_idxfor the unmarked answers, which are counted for the 25,000 best attempts only. No index was added. -
MyClassgainsscheduleLabel,roomandteacherAvatarUrl, all required and nullable, and nothing else; a test pins the eight keys. The photo is signed by identity'sAvatarURLquery throughports.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,
nullor blank clears (x-go-type: json.RawMessage, asstudentNote). 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,theCredentialMintersand its detector list namelistClasses. No operation is added, sopermissions.goldenand its count are unchanged. -
Residual risks:
listClassesis 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 onscope.allwithwithCodes=truecan read up to 100 codes a call, which is whatgetJoinCodealready lets them read one by one, at 60 calls a minute.
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:
-
getDashboardtakesrange=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), andrecentActivity: [{kind: submitted|started|joined, at, studentName, subject, flagged}](≤10). Existing fields stay for old tabs. The rebuilt dashboard (T-R4.23) stops readingopenAssignments,activeStudents,totalStudentsandrecentAttempts, and takes the shell'suseNavCountsoffgetDashboardin 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;liveAssignmentscounts the rows the Assignments list shows (T-R4.54) - Both answer for the caller's own teaching (
.Own()), an Admin included;EXPLAINon the seeded volume showsattempts_assignment_status_idx,attempts_one_live,attempts_grading_queue_idx(foranswersToGradeonly),assignments_window_idxandclass_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
getDashboardandgetTeacherSummarypass.Own()for teachers and admins; repositories retainAll, andListAttemptsretains its full scope. -
takingNowcounts distinct students and assignments before their live attempt deadlines. -
submissionsreturns exactly 7, 14 or 30 local calendar days, zero-filled and oldest first; only this reading changes withrange(default14d). - 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. -
todayis the first 20 published openings/effective closings in the local day;notSubmittedis distinct enabled reached targets with no handed-in paper. -
recentActivityis 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.liveAssignmentsmatches the open Assignments list;answersToGradecounts unmarked manual answers only withteaching.grading, otherwise null without running that count. Notifications are the caller's own unread count. -
ports.Zonesresolves the calendar name,adapters.DefaultZonesuppliesAsia/Ho_Chi_Minhuntil T-R4.7, andService.Nowis the injected new-query clock. -
ports.UnreadNotificationsis the notifications summary handler, set throughWithNotifications; 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 optionalrange, preserving its default and runtime URL. The Admin nav's grading count is their own reach whileListAttemptsstill reaches every teacher withscope.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.
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 onlyclassIdsin the actor's scope (404 otherwise); zero classes is allowed, and the student stays visible to its creator through R2'screated_byscope. Shipped in v0.8.0:TestACreatedStudentEntersOnlyClassesTheCreatorTeachespins the class scope andTestEachReachRuleCountsOnlyWhileItHoldsthe 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}]}withCache-Control: no-store; every target passes R2's strict student-target predicate and the subset rule, or fails asNOT_FOUND; at most two hashes run at a time, insideMAX_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 (unsharedinstudents_write.go), so single and bulk cannot diverge. Anything else fails as 403STUDENT_SHARED(in bulk, infailed), and the reset goes to apeople.users.manageholder 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);resetStudentsPasswordsjoinstheCredentialMintersby name (itstemporaryPasswordis nested initems[]) -
listStudents:classIdbecomes an array (explode, a single value still works) andmustChangePasswordfilters -
x-resource: the reset'sstudentIdsbody field declaresstudent;listStudents'classIdquery declaresclass - The call sites in
web/srcchange in the same PR (pnpm typecheck) - Tests: an admin holding
learning.take_testsin the list fails and the rest succeed; another teacher's student fails; teacher B resetting a student shared with teacher A is refused withSTUDENT_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.ClassIDsandMustChange,BulkReset,ErrResetFailed),application/{app.go,internal/support/students.go}(theReachguard,SetLogger, andSetTemporaryPasswordsfor tests),core/wiring/identity.go, the three tables that pin operations (permissions_test.go121 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}andweb/src/features/students/api.ts.students_write.go,list_students.goand the single reset's command are untouched: the bulk command calls the sameRepo.ResetPassword, whoseunsharedcheck 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.ReachisMayActOnthat also returns the student; the order is unchanged (found in the caller's scope,people.users.managewhere asked, then the subset rule), so a student the caller cannot reach answersNOT_FOUNDeven when the id is held by someone with more rights. A disabled student isNOT_FOUND, as in the single reset. -
Two workers, request order. The command hands indices to two goroutines (inside the
process-wide
MAX_CONCURRENT_PASSWORD_HASHESsemaphore, so a call holds at most two of its four slots and a sign-in is never starved) and readsitemsandfailedback in the order asked. Each student is oneRepo.ResetPasswordtransaction (hash,must_change_password, epoch, refresh families, audit row) andPrincipals.Forgetruns after that student's commit. A fault or a panic fails only its student asINTERNAL, 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:
200even when every student failed;failed[].codeisErrorCode, in practice one ofNOT_FOUND,FORBIDDEN,STUDENT_SHARED,INTERNAL;studentIdshasuniqueItems, so a repeated id is400in any spelling (the schema compares text, so the handler refuses a repeat after parsing, asduplicateAssignment,setStudentOverrides,exportResultsCsvandlistAssignmentsdo since F-70);Cache-Control: no-storeis declared (andSecurityHeadersalready sets it on every/teacher/answer).classIdonlistStudentsis an array of at most 50 (a single value still works) and the facets in the answer follow it andmustChangePassword. -
Rate limit: 2 a minute and 10 an hour per user. The limiter runs before the body is
validated, so a
400spends budget too; an anonymous or student caller spends none. -
Isolation:
resetStudentsPasswordsis excused (it answers 200 and reports the student asNOT_FOUND) and blind to the caller's own id (the third call in a minute is limited). The positive proof isstudents_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,Forgetbefore 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,ANYasALL, 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 whereword-word-NNcarried 18.45. One generator (domain.Passwords.Temporary()) servescreateStudent,resetStudentPasswordandresetStudentsPasswords, 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 askStudents.CheckResettablebefore 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'sdiffas{"batchId": ...}(WriteRequest.BatchID); the rows of a call are found withdiff ->> 'batchId', the single reset writes no diff, and no index was added for the key. A reset forgets the student's cached principal whenResetPasswordanswers 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.
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) fromdomain.Review.Summary(Blocking,NeedsDecision). Down drops both -
listWordImports:statusbecomes an array (a single value still works); the response gainsfacets {all, processing, needsReview, failed, committed, cancelled}, where processing coversawaiting_sources,queued,processingandcommitting; the counts follow the search and ignore the status filter, as theTestStatusFacetsschema's counts do; items gainreviewCounts {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
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.Classesand 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) andserver/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 againFOR UPDATEunderrevoked_at IS NULL AND lookup_scheme = 1 AND expires_at > $now;UPDATE … SET revoked_at = $nowon that row; theINSERTof the sealed row, with the columnsRotatewrites; the auditINSERT. 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_countandcreated_by, and is created at the$nowthe old row is revoked at. As first built it started atuses_count0, so a class whose legacy code had reached its cap became joinable formax_usesmore students; the owner decided on 2026-10-12 that the count is carried over (F-67). The code is readFOR UPDATEwith 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_enabledis 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'sRotateandRevokestill 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 lockedFOR NO KEY UPDATEby the legacy rotation,RotateandRevoke, which a key-share lock does not wait for. The modes, the orders, what stays serialised and whyDeletekeepsFOR UPDATEare in20-data-model.md§33, "Lock order for a class and its join codes". The repository still answersdomain.ErrRotationContendedfor SQLSTATE 40P01 and 40001, read from thepgconn.PgErrorcode, and the command still tries such a class once more, after every other class and with a new code, and counts it inFailedif that fails too. No other error is retried. -
The audit row: actor NULL,
class.join_code_rotated, entityclass_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 withRotatedoes. Where self-join is closed or the class archived it answersPreviewInvalid, 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 answersdomain.LegacyRotation{Found, Rotated, Failed, Teachers, NotifyFailed}. A class another run rotated first is inFoundonly. It takes two optional callbacks,Found(count)andProblem(err), because the application layer does not log: the job logs from them. -
The notification: one
Notifyper teacher, after that teacher's last class, or after the retries when one of their classes was contended. Kindjoin_codes.rotated, params{count, classNames}(the first five rotated, in name order), targetclasses, dedupe keyjoin_codes.rotated:legacy, mergeAdd. 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
Notifyis counted and logged and undoes nothing. A process that dies between a teacher's last commit and theirNotifyleaves them untold: the next run finds nothing to rotate and sends nothing. The release note tells teachers too. EachNotifyruns 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: aNotifythat fails there is counted innotify_failed, and the next run's notification then counts only the classes it rotates. -
The job
jobs.RotateLegacyJoinCodesruns once under a two-minute budget, started byServebeside the prunes and by no other process, and never on a timer. It logslegacy_join_codes_found {count}before the first class when the count is above zero andlegacy_join_codes_rotated {found, rotated, failed, teachers, notify_failed}after the last; a warninglegacy_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; andlegacy_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'sNotifyhandler, set withApplication.WithNotifier.Newkeeps 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, aFOR UPDATEon the row the new code'screated_bypoints at (which stops the rotation, or a teacher'sRotate, at its insert), aFOR SHAREon 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 ofdeadlock_timeout(read frompg_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,RotateandRevokein turn, each in its only attempt, and aRotatein flight is tested to hold backRevoke, 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
Rotatewrote, 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 sameexpires_at,max_uses(a stored NULL stays NULL) anduses_count(F-67), and self-join unchanged; audited as System with reasonlegacy_rotation, the diff carrying hints only (DG-11) -
core/jobs/join_codes.goruns it once at API start-up, each class in its own transaction that takespg_advisory_xact_lock(73819, 41)and re-reads the code row; no session lock. It logs the count, applies, logslegacy_join_codes_rotated; a second run finds nothing. The key is recorded in20-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.rotatednotification{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
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, inAGENTS.md, thecore/routerrow of the composition-root table, which now names the three registries.core/router/deps.go,core/core.goandrouter_test.goneeded no change:router.Newbuilds both registries itself. -
The names.
ratelimit.WithPrincipaland theKeyFuncratelimit.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), whichRequirePermissionputs 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 oftheOpenSeven, refresh and logout included, whichAssertPublicRoutesLimiteddoes not count as public. It also refuses an entry that names no operation. -
TestRateLimitsRunBeforeAuthenticationAndThePermissionGatechanged its operation, toPOST /app/classes/join(120 a minute per address), and its forbidden caller to a teacher, because a student holdslearning.take_tests. Its claim is the same. -
The start-up call is pinned by reading
router.go.TestTheRouterAssertsThePrincipalRegistryItEnforcesrequires the registry asserted to be the one enforced, andTestTheRouterStopsOnThePrincipalAssertionsErrorrequiresNewto return the assertion's error.router.Newcannot 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 nox-rate-limitblock:TestEveryRateLimitBlockMatchesTheRegistryreadsRateLimits()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.mdkeeps "per address" (T-R2.8, T-R2.14b) as R2's record. Done when: -
ratelimit.PrincipalKeykeys a bucket by the resolved principal's user id.PrincipalRateLimits()is a second registry besideRateLimits(); an entry for an open operation fails at start-up, since no principal exists there - A second limiter middleware reads it, placed directly after
RequirePermissioninrouter.New'sinExecutionOrderlist;RateLimits()keeps running first, keyed by address or body field, before authentication. A router test pins both positions, asvalidate_test.goandhardening_test.gopin 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
-
theCredentialMintersaccepts an entry in either registry;TestRegistryHasNoStaleEntriescovers 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
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'shttp/tests/scope_test.go(new intests, which had none);server/tests/isolation_flow_test.go; the generated files; in the contract alsoWordImportandlistAssignments;docs/plan/60-backend-architecture.md(the kernel'sScope),docs/quizzivy-spec-v0.3.md(v0.49: §5, §16) and the scope bullet inAGENTS.md. One repository changed, against the brief:assignments/repositories/assignments.go, withListInput.EveryTargetinassignments/domain/commands.go(the two assignment bullets below). -
One method, six call sites.
access.Scope.Own()is the same user withAllcleared. Each of the six handlers builds its list input fromhttpapi.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 callsOwn. -
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
targetCountshort ofsubmittedCount("5/0"). The handler setsListInput.EveryTargetfrom the caller'sscope.all: it lifts the reach from the targets and leaves it on the rows, so a listed row names and counts whatgetAssignmentdoes. A caller withoutscope.allruns 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 holdsscope.all. The Admin then reads those of their own assignments that target it (EveryTargetagain), so the assignments card of another teacher's class (listClasseskeepsscope.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 keepsscope.all, and other modules bind an asset throughReadablein the caller's scope. Both are pinned. Intests,previewTestandpublishTestbuild their scope inline and are pinned at the caller's besidegetTest. -
For T-R4.19:
liveAssignmentscounts with the same narrowed scope the Assignments list uses,httpapi.ScopeFromContext(ctx).Own(). -
The contract changed in words only:
PermissionKey,WordImport,listWordImportsandlistAssignments. 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,listWordImportsandlistAssignments, with their facets and tab counts, show the caller's own rows for every caller, admins included: in these listsscope.alldoes not lift the owner filter - Reads and edits by id keep
scope.all(70 §4.1);listStudentsandlistClasseskeepscope.alllists 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
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_checkbecomesformat IN ('docx','doc','pdf','text'). Down re-adds the three-value CHECKNOT VALID, as00048and00052do, 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 whenformat = '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 andadditionalProperties: false.textis 1–100000 characters (maxLengthcounts code points) with at least one non-space character. It answers 201ImportUploadReceiptand the upload's refusals: 400, 404, 409IMPORT_CONFLICT, 413, 415, 429, and 501 where intake is off -
x-resource: the pathiddeclaresimport, the body/uploadIddeclaresnone. Nox-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.formatgainstext, andImportSource.charactersappears exactly for a text source.ImportLimitsgainspasteMaxCharacters(100000);formatsis 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 filenamepasted-text.txt, andcharactersis 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.txtfile onuploadImportSourceis still refused - A replay with the same
uploadId,expectedRevisionand text returns the first receipt; a changed replay answers 409IMPORT_CONFLICT - The contract refuses a
textof more than 100,000 code points as sent with 400VALIDATION_FAILED, before intake runs. Intake normalises to NFC and refuses a NUL (415IMPORT_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 (413IMPORT_SOURCE_TOO_LARGE). It does not refuse a text without questions: the blockingNO_QUESTIONSfinding covers that. Its messages name the pasted text, not a file (vi first) -
listWordImportsdoes not match a text source's stored filename: the filename half of the search skips sources withformat = 'text'; title and companion-file names still match, and the operation's description says so. Test:q=txtandq=pastreturn 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 exportedadapters.TextEvidence(text, sourceID, role), asPDFEvidenceis 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 blockl{n}, n being the line's number in the pasted text, with no spans. The evidence version istext-lines-v1and the document carries one informational finding,TEXT_MARKS_UNAVAILABLE, as a PDF carriesPDF_MARKS_UNAVAILABLE. Recognition accepts the version, and normalisation skips a text source -
PipelineVersionbecomesword-pipeline-v3, so a worker that predates text sources never claims a text run -
downloadImportSourcereturns the stored.txtandgetWordImportSourceits 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 throughuploadImportSource, like any import - No rate-limit entry, as
uploadImportSourcehas none: the import quotas and the intake slot bound it. IfuploadImportSourcegains a bucket inPrincipalRateLimits()(T-R4.53),pasteImportSourceshares it - Pinned tests updated, none weakened:
permissions.goldengains the operation,imports_test.gocounts 15 protected import operations, andisolation_cases_test.gogains 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,001ErrTooLarge, invalid UTF-8 and a NULErrInvalid, a replay, a changed replay);intake_test.go(through the router: 100,000 code points answer 201, 100,001 answer 400VALIDATION_FAILED, 20,001 lines answer 413, a NUL 415, the quota and busy answers, a.txtupload refused);text_pipeline_test.go(a pasted test from intake to its draft, aspdf_pipeline_test.godoes 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.mdrecords both migrations
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 underrules-v2: the existingrecognize_test.gocases 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 - BandĐáp án: B - A one-line key,
Answer key: 1-B 2-C(alsoKey: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, so1-Bnever becomes a question label.Đáp án: 1-B 2-Cis no longer attached to the last question as one value. This rule applies to every evidence version, file or text.Answers: 1. B 2. Cis read the same way. A line with fewer than two pairs keeps today's meaning: afterAnswer:,Key:orĐáp án:its value is the answer of the question above it, and afterAnswer key:orAnswers:it is not read -
recognize_test.gocovers #233 with the existingrecognize(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. Cgive answers B and C and no conflict;Đáp án: Bunder a question is still that question's answer, andAnswer key: Bunder 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 givenstays 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.Versionbecomesrules-v3 -
api/testdata/pasted-text-counts.jsonholds texts with their expected sections, questions, answered count and missing question numbers. A repeated-number case also carriesduplicates, the numbers the page's note names; onlypaste-scan.test.tsasserts it, since the server reports no duplicates. A Go test inimport_text_test.gobuilds each text's exam withadapters.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 itsMISSING_ANSWERfindings; 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 1without punctuation,Q1.andCau 1.; a bare roman heading,Part AandBà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 2twice (four questions) -
text_pipeline_test.gotakes 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
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
00097and00098, the next numbers after00092at9e838c83. The reservation of00093to00096for 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 took00099and00100, T-R4.12a00101).media_alt textis nullable with no default and no backfill, andquestions_media_alt_checkandtest_version_questions_media_alt_checkhold 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
mediaAltas an optional nullable string onQuestionInputandStoredQuestionInput(1 to 1000 characters),AdminQuestion,StudentQuestionandResultQuestion.StoredQuestionInputis 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.previewTestreturnsStudentQuestion, so the version preview has it. -
A value for a question whose media is not an image is a 400
VALIDATION_FAILEDwitherror.details.mediaAlt, the answer every other field error of a question write has (audio,transcript), not a 422.Input.Validatealso 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
levelandskill: an update that leavesmediaAltout 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.
loadQuestionsselects it with the draft question andfreezeQuestionwrites it; only the column lists grew.lockDraftContent, the order of its row locks,questions.LockForDraftUseandmedia.LockForVersionUseare not touched, andpublish_snapshot_test.gois untouched and green. Restore as draft copies it incopySnapshotQuestion'sINSERT ... SELECTand, for a group member, throughreadFrozenMembers. A test duplicate references the bank questions it lists, so standalone alt text travels by reference (duplicate.goneeded no change); a duplicated group copies its members through the bundle, which carriesInput.MediaAlt. The version preview and the student's paper read the frozen column. -
The diff puts it in a question's
mediapart (diff_paper.go), so an alt-text edit is onechangedentry under media and raisesunpublishedChangesby 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 inquestionsQueryand its scan, and the two response mappings.Questionsserves the paper, the session and the result. The payload-leak walks are unchanged and green. -
Beyond the Touches list:
questions/http/{input,questions}.goandquestions/application/internal/support/shared.go(a bank duplicate rebuilds its input throughInputOf),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
QuestiongainsmediaAlt, the §13.3 sketches ofquestionsandtest_version_questionsgainmedia_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
AdminQuestionfrom the frozen question inattempts/http/admin_attempts.goand leavesmediaAltunset; T-R4.47, the attempt review, carries it. Its server read (Reviews.questionsinattempts/repositories/review_review.goand the mapping inadmin_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
mediaAltback, because a write replaces the stored value and one that drops it clears it: the bank editor (web/src/features/question-bank/questionSchema.tsandQuestionEditor); 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_altonquestionswhere the kind is not an image, assert that notest_version_questionsrow holds the pair (publish validation and the R3 binary make that so; a version is not rewritten), addCHECK (media_alt IS NULL OR media_asset_kind IS NOT DISTINCT FROM 'image'::app.media_kind)to both tablesNOT VALID, thenVALIDATEthem. 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 saysIS 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
9e838c83and on the last code commitc066130e, 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 holdspublish_snapshot_test.go, 121 then 126; the attempts application package, which holdsTestTheSameClientSeqFromTwoSessionsBothPersistand 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-player14 tests,client.refresh10,router-chunks13,autosave-unmount8 andtests/units/take-test35 files with 516 tests. The nested--- FAILlines in those logs are the fixture-lifecycle children that fail on purpose under a passing parent. The diff from9e838c83shows none of the five canary files, nor anything undermodules/access,features/take-test,features/integrityorfeatures/media. As built, web half, T-R4.62b (2026-10-10): -
One reader,
take-test/components/QuestionImage.tsx, takes the question (mediaandmediaAlt, the shapeStudentQuestionandResultQuestionshare) and draws its image throughContentImagein R3's frame (DG-80), withaltthe question's text or the generictakeTest.imageLabelwhen 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.StudentPreviewalready renders throughQuestionSheet, so the builder's preview and the version preview take the alt text with no edit toStudentPreview.tsx, which T-R4.30 and then T-R4.31b edit. The result'sReviewItemnow draws the question's image under the prompt row; it sits inside[data-slot=result-answers], which already carriesPAPER_SURFACE(DG-35). -
The writers.
QuestionValues.mediaAltis required (.nullable().default(null), aslevelandskill), so a constructor that forgets it failspnpm typecheckrather than clearing the stored value:toFormValuesreadsquestion.mediaAlt, andemptyQuestion, the builder'sstarterQuestionand the group editor'smemberValuesname it. The builder's inline editing goes throughtoFormValues. The group recovery's zod object names it (z.string().nullish()).QuestionEditorsets it to null besidemediaAssetIdwhenever 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-r4at00c7c45eonly the question API writesmedia_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 inbuilder/group-preview.test.tsx; a result case inresults/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, throughtoFormValues, the schema andupdateQuestion) and the group recovery andmemberValues(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 boundContentImage.althas), onquestions.add_test_version_questions_media_alt: the same column ontest_version_questions, nullable, no backfill. Down drops each - Contract:
QuestionInputandAdminQuestiongain nullablemediaAlt. A value is refused with a field error unlessmediaAssetIdnames an image, and clearing or changing the media to audio clears it. The student question and the result's question gainmediaAltbesidemedia. It is not an answer key, and the payload-leak walk stays as it is - Publish copies
media_altinto the version row, keeping themedia.LockForVersionUseandquestions.LockForDraftUseordering unchanged; restore as draft, duplicate and the draft preview carry it - The version diff (T-R4.16) counts a changed
media_altaschanged, under media, sounpublishedChangesrises 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'sReviewItemand the builder'sStudentPreviewdraw no question image and learn to here - (T-R4.62b) Every web writer of a question sends
mediaAltback 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:
toFormValuesthen the request keepsmediaAlt, set and null. The one-wayAssignableTo<QuestionValues, QuestionInput>atquestionSchema.ts:226does not fail when the form omits an optional field of the request, so only a test sees it dropped - Tests:
publish_snapshot_test.gogreen 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)
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 throughuseIdlePolling) - 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;useNavCountsswitches fromgetDashboardtoGET /teacher/summary;smoke.spec.ts's three sidebar cases are removed, each recorded as replaced by a case ofweb/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.tsat 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
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 withteaching.assignments.write), then the deck's segmented tabs Live, Scheduled, Closed and Drafts with their counts fromAssignmentStatusFacets(openreads Live, and so does its status pill in English), a search (q, debounced) and the Class facet (FacetFilter, several classes). The tab isstatus(Live when absent), the classesclassId, thenq,pageandsize, all in the URL; the wizard's and the class card's links keep working. -
Table.
DataTablewithassignmentColumns: Assignment (title, "Test v{n} · {q} questions", "· {n} to grade" in warning ink frompendingGradingCount), 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'sCloseEarlyDialog, 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, oneextendAssignmentper assignment, the failures named with Retry) and Close now (BulkActions, one close per live assignment, the others reported) withteaching.assignments.write; Export results withteaching.grading, one CSV for up to 50 throughdownloadFile(lib/api/client.ts, sharing the token and the one refresh) and a name fromContent-DispositionorresultsFileName; 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 (
openreads 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
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, settab=students, preserve other query/hash values and replace history; sheet opening pushes, close replaces onlyattempt, 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
useIdlePollingwhile 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/attemptsredirects to?tab=students, andLegacyTeacherRedirectgains 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.tsat 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.