Repository navigation
Plan 76 R6
The deck draws question types the product does not have. Since the fourth export (2026-10-04) the Student page draws True / False / Not given, matching, a multiple choice capped at two picks and a word limit on the take-test screen (DG-60, DG-194). The import screen offers True / False / Not given. Since the 2026-10-04 export the builder draws the authoring side of Matching and an "Include “Not given”" switch on True / False (DG-113); R4 leaves both out. Grading scores essays on a band with rubric tiles. DG-60 names one canonical set and DG-61 an interim rubric model. After R6 the set is:
- single choice
- multiple choice, optionally "choose exactly N"
- true / false
- true / false / not given
- fill in the blank
- short answer, with an optional word limit
- essay, scored on a band
- matching
- cloze groups (question groups; the engine shows a run of gap-bound members as one stop, T-D4.10)
true_false stays. Production holds it and the deck's question editor draws it, although
DG-60 leaves it out.
The work runs through every high-risk area in AGENTS: questions, test_versions, the
student projection, attempts, marking, grading, the engine and the importer. It is therefore
one vertical track, ordered schema → contract → bank → snapshot → projection → marking →
grading → result, with the screens after. Every server task on the track runs the five
canaries on its branch point and on its last commit:
server/internal/modules/tests/application/tests/publish_snapshot_test.goweb/tests/units/media/audio-player.test.tsxserver/internal/modules/attempts/application/tests/events_test.goweb/tests/units/api/client.refresh.test.tsweb/tests/integration/router-chunks.test.ts
Every change is additive: new enum values, nullable columns and new tables. R6 therefore has
no contract step in R7. The release needs R4 (question skill, frozen on
test_version_questions) and R3's engine, with its unknown-type block. Branch
work/redesign-r6 is cut from develop.
Deliverable: every type in the canonical set is authored in the editor, the builder and question groups. Each is frozen at publish, delivered through a student-safe projection, auto-marked or hand-graded (bands and rubric criteria for essays), shown on the result with band, rubric, part and skill tiles, and mapped by the Word import where the deck says so.
Exit criteria:
-
publish_snapshot_test.goand the other four canaries pass untouched. - A new live spec takes a paper of every type from authoring to result.
- E2E 9 passes, extended to that paper and to the result-only keys.
- The migrations pass goose up/down/up and are rehearsed on a Neon branch.
- The engine's new types and the band result match the deck at the five widths.
- The release ships to production as
v0.13.0.
Decisions taken in this file, worth a second look.
-
True / false / not given is its own type, stored as three fixed options and answered like a single choice.
tfngrows hold threequestion_optionsin fixed order (True, False, Not given) with exactly one correct. Students answer with the existingchoiceshape, and marking reuses the choice rule. The engine labels the options throught()by position, so vi shows Vietnamese whatever text is stored. Options are never shuffled. No new answer shape is needed, and an engine that predates R6 would at worst show the reload block. The deck's builder (2026-10-04) draws it as the switch "Include “Not given”" on a True / False question, while its import review lists the type on its own. Default: the stored type staystfng, and the switch is how the question editor chooses betweentrue_falseandtfng(DG-113). -
"Choose exactly N" is
multiple_choicewithselectCount, marked per correct pick. IELTS counts each letter of a "Choose TWO" item, so the question scorespoints × correct picks ÷ N. More than N picks scores 0; the engine prevents that, because the (N+1)th pick replaces the oldest. A plain multiple choice keeps its all-or-nothing rule, so existing questions do not change. -
The word limit is advice, never truncation.
maxWordsapplies to short answers. The engine counts words live and flags "too many". The server stores whatever was typed, because autosave must never lose text, and the grader sees the over-limit badge and the deck's "Word limit exceeded" snippet. The penalty is the teacher's call. -
An essay's band is converted to points, and the band is stored beside them. Essays score 3–9 in half steps, as the approved plan says. The deck's grading mock uses a nine-value set from 3 to 8, and the design team is asked to redraw it.
-
manual_score = round(band × points ÷ 9, 2). An essay's default is 9 points, so points equal the band. -
final_score,finishGradingand every points total are unchanged. -
AttemptScore.bandis the essays' points-weighted mean band, rounded to 0.5, and appears only when every scored question on the paper is a graded essay. A Writing Task 2 worth twice the points counts twice.
-
-
Rubric criteria are defined per question (DG-61, interim). A question has up to six criteria, frozen at publish. When criteria exist, the band is their mean rounded half up to 0.5:
floor(mean × 2 + 0.5) ÷ 2, so 6.25 → 6.5 and 6.75 → 7.0. The editor offers an "IELTS Writing" preset of four localised labels. Without criteria, the teacher sets the band directly. -
Band keys are the digit for a whole band and Shift+digit for the half above. Thirteen values do not fit the deck's position keys 1…n. Digits 3–9 by
event.codeset that band, and Shift adds 0.5. Each button shows its hint. Points questions keep position keys. -
Matching reuses
question_optionsas its choice list and adds items. The choices are options, keyed with roman numerals as the builder draws them; the items carry letters. None of the options is marked correct. Each item is a row keyed to one choice by a composite FK(question_id, correct_option_id), so a key cannot point into another question. The FK isNO ACTION: it is checked at the end of the statement, so a purge that cascades to options and items together passes, whereRESTRICTwould depend on the cascade order. Choices keep their order, because authors refer to their keys. Marking is per item, like fill-in. The duplicate path copies items, and R7's copy-on-write reuses that path. The deck's builder (2026-10-04) stops both lists at ten. Default: the editor takes its layout and keeps this file's bounds, 12 options and 20 items, until the design team confirms a limit (DG-113). -
The student projection is written out in full (T-R6.5) and holds only rules and labels.
selectCountandmaxWordsare rules, likecaseSensitive. Match items carry no key, and the rubric is not sent before submission. Correct matches, bands and criteria appear only on the result, behind the review policy. -
Result tiles follow one rule, all behind
showScore. The first match wins:- a single essay with criteria shows its criteria
- otherwise two or more parts show part tiles (R3)
- otherwise two or more skills show skill tiles, from R4's frozen
skill - otherwise there are no tiles
The deck's Result variant 2 ("Unit 3 · Grammar check") draws topic tiles (Present perfect 4/5, Past simple 4/5) on a one-part, one-skill paper. Topics have no model, so R6 does not build that variant: the paper shows no tiles (open item, and a DG entry through T-R6.15).
When a paper's only hand-graded answer has a comment, the result shows it in the deck's comment callout, as the deck's Writing Task 1 does. Otherwise comments stay on their items.
-
True / false marking accepts the answer the engine actually sends. The engine's
Choicerenderer (take-test/components/QuestionBody.tsx) answers true / false with{type: "choice", optionIds: [id]}.gradeTrueFalseinattempts/domain/grading.goonce read only{value}; it now readsoptionIdstoo (grading.go:100-110), so the fix is made and R6's grader keeps both shapes. -
The Word import maps TFNG, "Choose N" and word limits, and nothing else. Matching stays "Matching (not supported)", as the deck's import screen draws it. Essays are not recognised (open item).
Depends on: —
Touches: migrations/NNNNN_add_question_types.sql, migrations/NNNNN_add_question_select_count.sql, migrations/NNNNN_add_question_max_words.sql, migrations/NNNNN_allow_essay_sample_answer.sql (all new), server/internal/platform/db/tests/{constraints,migrate}_test.go, docs/plan/20-data-model.md
Size: M
Done when:
-
add_question_typesrunsALTER TYPE app.question_type ADD VALUE IF NOT EXISTSfor'tfng','matching'and'essay', so the second up of up/down/up finds them present. - No statement in the file uses the new values: a value added in a transaction cannot be used before it commits, so every CHECK that names them is in a later file. - Down raises if anyquestionsortest_version_questionsrow uses a new value, and otherwise leaves the three values in place. PostgreSQL cannot drop an enum value, and rebuilding the type fails: every CHECK that comparestype(00011'squestions_sample_answer_only_short_answer, and 00034'squestions_checkandtest_version_questions_checkwithtype <> 'fill_blank') is re-parsed duringALTER COLUMN … TYPEand fails withoperator does not exist. An unused value is harmless, so this is still a working Down. It is a §12 deviation in20-data-model.md. -
add_question_select_countaddsselect_count smallinttoquestionsandtest_version_questions, withCHECK (select_count IS NULL OR (type = 'multiple_choice' AND select_count BETWEEN 2 AND 7))addedNOT VALIDand validated in the same file. Seven is the 8-option maximum (DG-63) minus one. Down drops both columns. -
add_question_max_wordsaddsmax_words smallintto both tables in the same pattern, withCHECK (max_words IS NULL OR (type = 'short_answer' AND max_words BETWEEN 1 AND 100)). -
allow_essay_sample_answerreplacesquestions_sample_answer_only_short_answerwithquestions_sample_answer_only_written(sample_answer IS NULL OR type IN ('short_answer', 'essay')), addedNOT VALIDand then validated. Down refuses while an essay has a sample answer, and otherwise restores the original. -
constraints_test.goshows each CHECK firing: aselect_counton a single choice,max_words0, and a sample answer on a matching question. A question inserted with the v0.12.0 column list still inserts, which is the old binary's way. - goose up/down/up passes in CI.
-
20-data-model.mdgains the R6 section for these columns, explaining why the values are enum values rather than text and why populated tables getNOT VALIDplusVALIDATE. The §12 deviation entries go with it, including the enum Down that leaves the new values in place.
Depends on: T-R6.1a
Touches: migrations/NNNNN_create_question_rubric_criteria.sql, migrations/NNNNN_index_question_options_identity.sql, migrations/NNNNN_create_question_match_items.sql, migrations/NNNNN_add_attempt_answer_bands.sql (all new), server/internal/platform/db/tests/{constraints,privileges}_test.go, docs/plan/20-data-model.md
Size: M
Done when:
-
create_question_rubric_criteriacreates two tables. Down drops both. -app.question_rubric_criteria (id uuid PK DEFAULT uuidv7(), question_id → questions ON DELETE CASCADE, ordinal smallint ≥ 0, label text 1..80), withUNIQUE (question_id, ordinal) DEFERRABLE INITIALLY IMMEDIATE, the D-13 reorder pattern. -app.test_version_rubric_criteria, the same shape againsttest_version_questions, withREVOKE UPDATE … FROM quizzivy_appfor frozen rows, as00039does. -
index_question_options_identityis marked-- +goose NO TRANSACTION. - It runsCREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS question_options_question_id_id_key ON app.question_options (question_id, id)and the same onapp.test_version_options (test_version_question_id, id). - Both tables are populated, henceCONCURRENTLY. The existing unique key cannot serve the FK, because an FK cannot reference a deferrable constraint. A non-partial unique index can serve it directly, so the index is never attached as a constraint. - Down isDROP INDEX CONCURRENTLY IF EXISTSfor both. - An interrupted build is handled by the v0.6.0 release runbook: drop the INVALID index and re-run. -
create_question_match_items: - Its FKs reference(question_id, id)and(test_version_question_id, id), backed by the plain unique indexes fromindex_question_options_identity. Nothing is attached withADD CONSTRAINT … UNIQUE USING INDEX: that hands the index to the constraint, and dropping the constraint would drop the index. - It createsapp.question_match_items (id, question_id → questions ON DELETE CASCADE, ordinal, text 1..500, correct_option_id)withFOREIGN KEY (question_id, correct_option_id) REFERENCES app.question_options (question_id, id) ON DELETE NO ACTION,UNIQUE (question_id, ordinal) DEFERRABLE INITIALLY IMMEDIATE, and an index on(question_id, correct_option_id). - It createsapp.test_version_match_itemsagainst the frozen tables, withREVOKE UPDATE. - It addsmatch_allow_reuse booleanto both question tables withCHECK ((type = 'matching') = (match_allow_reuse IS NOT NULL)),NOT VALIDthen validated. - Down drops the tables and the columns. The indexes stay for the index file's Down. -
add_attempt_answer_bands: - It addsattempt_answers.band numeric(3,1)withCHECK (band IS NULL OR (band BETWEEN 3 AND 9 AND band * 2 = trunc(band * 2)))andCHECK (band IS NULL OR manual_score IS NOT NULL), bothNOT VALIDthen validated. - It createsapp.attempt_answer_criteria (attempt_id, question_id, criterion_id → test_version_rubric_criteria RESTRICT, band numeric(3,1) NOT NULL, PRIMARY KEY (attempt_id, question_id, criterion_id), FOREIGN KEY (attempt_id, question_id) → attempt_answers ON DELETE CASCADE), with the same step CHECK onbandand a(criterion_id)index. - The table is not append-only: regrading replaces rows. A grade write is not audited in R4 (last write wins, ledger T-11); whether regrading keeps a history in the audit log is an open decision for the user (spec §13.4), so this task records only what it builds. - Down refuses while any band exists. -
constraints_test.gocovers each CHECK and FK: an item keyed to another question's option fails, a band of 6.25 fails, and a band withoutmanual_scorefails. -
privileges_test.goasserts that the app role cannot UPDATE the two frozen tables. - goose up/down/up passes. The migrations are rehearsed on a Neon branch of production, and
the
CONCURRENTLYandVALIDATEtimings go in the PR. -
20-data-model.mdgains these sections and their deviations: matching reuses options, and bands are stored beside points.
Depends on: —
Touches: api/openapi.yaml, server/gen/openapi/, web/src/lib/api/schema.d.ts, web/tests/support/{fixtures,handlers}.ts, the exhaustive type maps and switches in web/src/features/{question-bank,tests,question-groups,imports,take-test,attempts,results}/ (a mechanical commit), web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/contract/openapi-contract.test.ts
Size: M
Done when:
-
QuestionTypeandQuestionTypeFacetsgaintfng,matchingandessay. -
Teacher shapes gain: -
selectCount,maxWordsandmatchAllowReuse-matchItems[] {id, ordinal, text, correctOptionId}-rubric[] {id, ordinal, label}These shapes are `AdminQuestion`, the create and update bodies, and the grading and review rows. `sampleAnswer` is documented for short answer and essay. -
Student shapes follow T-R6.5's table exactly: -
StudentQuestiongainsselectCount,maxWords,matchAllowReuseandmatchItems[] {id, text}-AnswergainsMatchingAnswer {type: matching, pairs: {itemId: optionId}}-ResultQuestiongainscorrectMatches(only withshowCorrectAnswers),bandandcriteria[] {id, label, band}(only withshowScore), andskill-AttemptScoregainsband -
Grade items for
gradeAttemptand the by-question grade: -pointsbecomes optional;bandis 3–9,multipleOf: 0.5;criteria[]is{criterionId, band}- the description states the handler rule the schema cannot express: an essay takes a band or complete criteria, anything else takes points - it lists the 422 codes -x-permissionstaysteaching.grading -
ImportDraftQuestion.typegainstfng, and the draft gainsselectCountandmaxWords. -
No operation is added, and no
x-permissionchanges. -
make genis its own commit. A second, mechanical commit adds the new members to every exhaustive switch and label map, with vi and en labels. Until T-R6.10 the engine routes the new types to R3's reload block, andpnpm typecheckpasses in this PR. -
openapi-contract.test.ts: - every schema reachable from/app/*still lacks the four banned keys -StudentMatchItemhas nocorrectOptionId- the session schemas carry none ofcorrectOptionIds,correctAnswers,correctMatches,bandorcriteriaMSW fixtures validate with ajv.
Depends on: T-R6.1a, T-R6.1b, T-R6.2
Touches: server/internal/modules/questions/domain/{question,input,content,manager}.go, server/internal/modules/questions/repositories/{store,option_content,question_content,facets}.go, server/internal/modules/questions/application/command/{create,update,duplicate}.go, server/internal/modules/questions/http/{input,questions_handlers}.go, server/internal/modules/questions/domain/tests/{validate,cardinality}_test.go, server/internal/modules/questions/application/tests/{crud,children}_test.go
Size: L
Done when:
- The domain accepts each type with its own fields and rejects every field that belongs to
another type. Errors use the existing field-error format.
-
tfng: three options in fixed order, whose texts the server sets to True, False and Not given; exactly one is correct. -multiple_choicewithselectCountN: 2 ≤ N ≤ options − 1, with exactly N correct. WithoutselectCount, nothing changes. -short_answer:maxWordsis 1–100, or absent. -essay: no options or blanks. Up to six rubric criteria with distinct labels, 1–80 characters, compared case-insensitively. A sample answer is allowed. -matching: - 2–12 choices; matching-headings lists run to about ten - choices are options, none marked correct - 2–20 items of 1–500 characters, each keyed to a choice of this question -matchAllowReuseis required; without reuse, items ≤ choices and the keys are distinct - The store writes and reads match items and rubric criteria in the question's transaction.
It deletes orphaned items before the options they reference, because of the
NO ACTIONFK. Reorders use the deferred ordinal constraints. - Duplicating a question copies its match items, re-keyed to the copied options, and its criteria. R7's copy-on-write reuses this path.
- Facets count the new types.
- Group members accept every new type.
LockForDraftUseis unchanged, andTestLockForDraftUseSerialisesAgainstDeletepasses. - Each rule has a unit test. Integration tests cover create, update, reorder and duplicate for each type.
Depends on: T-R6.3
Touches: server/internal/modules/tests/repositories/{publish_snapshot,publish_load,publish_publisher,version_draft,duplicate,duplicate_graph,preview}.go, server/internal/modules/tests/domain/{publish_validation,preview,diff}.go (diff.go from R4), server/internal/modules/tests/application/tests/publish_question_types_test.go (new)
Size: L
Done when:
-
publish_snapshot_test.gopasses, untouched, before the first commit and after the last. - Publishing freezes
select_count,max_words,match_allow_reuse, match items (keyed to the frozen options) and rubric criteria.publish_question_types_test.goedits every new field of the bank question after publishing and shows that the version is unchanged. - Publish validation refuses three cases, in the builder's existing problem format: - a "Choose N" whose number of correct options differs from N - a matching question with an unkeyed item - a TFNG without exactly one correct option
- "Edit from this version" and test duplication copy the new fields and tables back into drafts.
- R4's
domain/diff.gocompares the new fields (DG-66): - a changedselectCount,maxWords, match item text, choice text or rubric label is achanged- a changed match key or correct option is ananswer- reordering alone is still not a change - its unit tests gain a case for each - The teacher preview returns the student projection from T-R6.5.
-
LockForVersionUseis unchanged, andTestLockForVersionUseSerialisesAgainstDeletepasses. - New versions are
group_v1.PresentVersionstill refuses asection_v1snapshot that carries a fixed-order question (an existing guard).
Depends on: T-R6.4
Touches: server/internal/modules/attempts/domain/{paper,deal}.go, server/internal/modules/attempts/repositories/{store,autosave}.go, server/internal/modules/attempts/http/attempts.go, server/internal/modules/attempts/application/tests/payload_test.go, server/internal/modules/attempts/domain/tests/{shuffle,delivery}_test.go
Size: M
The projection. "Session" means POST /app/assignments/{id}/attempts and
GET /app/attempts/{id}; "result" means GET /app/attempts/{id}/result.
| Type | Session adds | Result adds, gated | Never under /app/*
|
|---|---|---|---|
| every type | nothing beyond today's fields | today's fields and gates |
isCorrect, sampleAnswer, acceptedAnswers; transcript on the session |
tfng |
options[] {id, text} in fixed order |
correctOptionIds (showCorrectAnswers) |
a per-option key |
multiple_choice with N |
selectCount |
correctOptionIds (showCorrectAnswers) |
— |
short_answer with a limit |
maxWords |
— | sampleAnswer |
essay |
nothing |
band, criteria[] {id, label, band} (showScore, once graded) |
sampleAnswer; the rubric before submission |
matching |
options[] {id, key, text} in fixed order, matchItems[] {id, key, text}, matchAllowReuse
|
correctMatches[] {itemId, optionId} (showCorrectAnswers) |
an item's correctOptionId
|
Done when:
- The paper types in
attempts/domain/paper.gogain only the session fields above.payload_test.goreflects over them and fails on any field whose name containsCorrect,Accepted,Sample,TranscriptorRubric. -
tfngandmatchingare dealt withFixedOptionOrder, set in the question load beside the group-bound case. Items keep their authored order, and the within-section shuffle is unchanged:shuffle_test.goanddelivery_test.gopass. - Autosave keeps only answers that belong to the question:
-
pairskeys must be the question's items, and values its options, the same checkoptionIdsgets today - achoiceanswer with more picks thanselectCountis stored, so nothing typed is lost, and marked 0 (T-R6.6) - The projection carries each matching item's and option's key as authored (
keybesideidandtext), so the engine can draw the keys the teacher wrote (DG-194).payload_test.gostill fails on any field whose name containsCorrect,Accepted,Sample,TranscriptorRubric: a key is a label, never an answer. -
requires_manualis true forshort_answerandessay. -
events_test.gopasses untouched.
Depends on: T-R6.5
Touches: server/internal/modules/attempts/domain/grading.go, server/internal/modules/attempts/repositories/submit.go, server/internal/modules/assignments/repositories/student.go (the answered count from T-R3.4), server/internal/modules/attempts/domain/tests/grading_autograde_test.go, server/internal/modules/assignments/repositories/tests/student_test.go, web/tests/units/take-test/answered.test.ts
Size: M
Done when:
-
tfngscores full points for the one correct option, otherwise 0. -
"Choose N" scores
points × correct picks ÷ N, rounded to hundredths. More than N picks scores 0. A plainmultiple_choicekeeps all-or-nothing. -
matchingscorespoints × correctly paired items ÷ items, rounded to hundredths, as fill-in does. -
essayrequires manual grading.short_answeris unchanged. -
true_falseaccepts both thechoiceanswer the engine sends (one option id, checked against that option's key) and{type: true_false, value}, with a test for each shape. Past attempts are an open item. -
The answered count on the student card counts a new question as answered when: - "Choose N": exactly N picks - matching: every item is paired - essay: the text is non-blank
The shared case list gains these cases on both sides. -
Table tests cover every type with empty, wrong, partial, right and malformed answers.
Depends on: T-R6.6
Touches: server/internal/modules/attempts/domain/{grading,review}.go, server/internal/modules/attempts/repositories/{review_grade,review_byquestion,review_review}.go, server/internal/modules/attempts/application/command/grade.go, the grading handlers in server/internal/modules/attempts/http/, server/internal/modules/attempts/application/tests/{review_grade,review_byquestion}_test.go
Size: M
Done when:
- An essay item takes either
band, when its question has no criteria, orcriteriacovering every criterion. - With criteria, the band isfloor(mean × 2 + 0.5) ÷ 2(DG-61 interim). -manual_score = round(band × points ÷ 9, 2). - The band and the criteria rows are written in the grade's transaction. - Anything else is refused with a 422:
GRADE_NEEDS_BAND,GRADE_CRITERIA_INCOMPLETEorGRADE_NEEDS_POINTS. - Items for other types keep
points≤ the question's points, as today. - The grade's audit entry records the band and criteria in its diff.
-
finishGradingstill sumsfinal_score.AttemptScore.bandis computed as the decisions section says, whereverAttemptScoreis built. - Teacher review and by-question rows carry the band, criteria, rubric and
maxWords. The web counts words, and the server does not. - Tests cover:
- band arithmetic at the half boundaries: 6.25 → 6.5, 6.75 → 7.0, 6.125 → 6.0
- incomplete criteria
- a re-grade that replaces criteria
- the points-weighted mean across two essays
- a mixed paper, which has no
band
Depends on: T-R6.7
Touches: server/internal/modules/attempts/repositories/result.go, server/internal/modules/attempts/application/query/result.go, server/internal/modules/attempts/http/result.go, server/internal/modules/assignments/repositories/student.go, R5's data-export writer, server/internal/modules/attempts/application/tests/result_test.go, R4's result.ready producer test (T-R4.10b)
Size: M
Done when:
-
Gates on the result: -
correctMatchesappears only withshowCorrectAnswers-bandandcriteriaappear only withshowScore, once graded -skillcomes from R4's frozen column under every policyA gated-off field is absent, not null, as the endpoint does today. -
Home cards carry
score.bandunder the sameshowScorerule. -
No notification stores the band.
result.readykeeps carrying only the attempt id and the title (T-R4.10b), and the web reads the band from the result query, which applies the policy in force when it is read. R4's test that noresult.readyrow holds a score also asserts that none holds a band. -
R5's data export writes
bandand the criteria for essays, formula-escaped like every other cell. -
result_test.gotests every new field under each policy combination. When the score is withheld, no band appears anywhere in the body.
Depends on: T-R6.3
Touches: web/src/features/question-bank/components/{QuestionEditor,OptionsEditor,OptionField}.tsx, web/src/features/question-bank/components/{MatchingEditor,RubricEditor}.tsx (new), web/src/features/question-bank/questionSchema.ts, locales, web/tests/units/question-bank/{editor,structure-validation}.test.ts(x)
Size: L
Done when:
- The type control offers Single choice, Multiple choice, True / False, Fill in the blank,
Short answer, Essay and Matching. True / False / Not given is not a separate entry: it is
the True / False block with "Include “Not given”" on, as the builder draws it (DG-113).
The builder's 2026-10-04 drawing gives the authoring side of that switch and of Matching.
"Choose N", the word limit, the essay and its rubric are still undrawn (DG-60), so they
are extrapolated from deck primitives and each new panel is listed for sign-off. The
blocks extend R4's answer areas (
74-r4.mdT-R4.66), which the builder and the bank's Question editor share. - Multiple choice has "Students choose exactly [N]", off by default. When on, N is 2–7 and the hint reads "tick N correct answers".
- True / False gains the switch "Include “Not given”", with the hint "· for IELTS-style
True / False / Not given". On, the question is stored as
tfngand a third card, "Not given", joins "True" and "False"; off, it istrue_false. Turning it off while "Not given" is the answer sets the answer to False, as drawn. The cards are labelled throught(). - Short answer has an optional word limit of 1–100.
- Essay has: - default points of 9 - the note "Graded by you on a 3–9 band" - a sample answer - rubric criteria: add, rename, reorder and remove, up to six, with the "IELTS Writing" preset
- Matching follows the builder's drawing, two columns from 1100px of page width and one below: - "Items · what students match, with the correct option": rows with a letter, the text, a select of the options ("Choose…", then i, ii, iii …) and "Remove item"; "Add item"; 2–20 items. An item with no option chosen has the danger border - "Options · the list students choose from": rows with a roman numeral (i to xii; the deck's list stops at x), the text, the tag "Extra" on an option no item uses (its title is the deck's), and "Remove option"; "Add option"; 2–12 options. Removing an option unsets the items that used it - the toasts name this file's bounds, "Up to 20 items" and "Up to 12 options", not the deck's ten and ten (DG-113) - the switch "Each choice can be used more than once", which the deck does not draw, listed for sign-off - the grading note "Marked automatically when the student submits. Each item scores its share of the points."
- Validation messages follow the deck's order and style. The zod schema keeps its
Expect<Equal<…>>assertion against the generated request type. - Switching type keeps what still applies and drops the rest. Loading, error and dirty states are unchanged. The PR lists the vi and en keys.
Depends on: T-R6.9a
Touches: web/src/features/tests/pages/teacher/TestBuilderPage.tsx, web/src/features/tests/components/{OutlineTree,OutlineGroupRow,StudentPreview,QuestionPickerDialog}.tsx, web/src/features/tests/publishProblem.ts, web/src/features/question-groups/{model.ts,components/GroupComposer.tsx}, web/src/features/question-bank/pages/teacher/QuestionBankPage.tsx, locales, web/tests/units/tests/outline.test.ts, web/tests/integration/builder-question-switch.test.tsx, web/tests/units/builder/autosave-unmount.test.tsx
Size: M
Done when:
- The builder's type menu and outline icons cover the whole set. The menu gains "Matching"
(
arrow-left-right) and "Essay" beside R4's five entries, whose icons are the deck's (74-r4.mdT-R4.31b); an icon the deck does not give comes fromlib/icons.ts. The outline warns "No correct answer set" for an unkeyed matching question or "Choose N". -
publishProblem.tshas builder messages for T-R6.4's publish refusals. - The group composer offers the new types as members.
- The bank's type filter and counts include the new types.
- The preview-mode engine (T-D4.16) renders the new types.
-
autosave-unmount.test.tsxpasses. Autosave still flushes on unmount and before publish.
Depends on: T-R6.5, R3's engine
Touches: web/src/features/take-test/components/QuestionBody.tsx, web/src/features/take-test/components/{MatchingBody,EssayBody}.tsx (new), web/src/features/take-test/{answered,draft}.ts, web/src/lib/words.ts (new), locales, web/tests/units/take-test/{renderers,answered,draft,draft-compat,keyboard}.test.ts(x), web/tests/units/lib/words.test.ts (new)
Size: L
Done when:
- The five canaries and the take-test, integrity and media suites pass before and after, with both runs in the PR.
- TFNG shows its three options in fixed order, labelled from
t()by position, with the type label "True / False / Not given". - "Choose N":
- the type label stays "Multiple choice"; the hint line reads "Choose two answers.", with
the number in words in both locales; one pick of two is "Partly" in the rail and the
sheet (
progress, T-D4.4) - square markers - the (N+1)th pick replaces the oldest;optionIdskeeps pick order, so the oldest is still known after a reload - Word limit:
- "2 of 3 words" under the input, then "· too many" in danger past the limit
- typing is never blocked
-
lib/words.tscounts whitespace-separated tokens, counting a hyphenated word or a number once, and the grading screen uses the same function - Essay: a textarea that starts at twelve lines and grows, a live word count, paste blocked when the policy blocks it, and the same autosave as a short answer.
- Matching: - one card per item (1.5px border, primary once matched, radius 11) with its key, its text and a radiogroup of chips, one per option key (38 by 38px from 1024, 44px below it, radius 9); a chip used by another item is at 50% opacity, choosing it moves it, and tapping the chosen chip clears it; above the cards "{k} of {n} matched"; when the options have text, the question has no shared content and the engine is at least 1000 wide, a 220px card lists the options once, each with the item that uses it; keys are shown as authored (DG-194); a matching is one question, one number and one square, "Partly" until every item is matched - without reuse, a choice used on another item shows where, and choosing it moves it - keyboard-operable, with 44px targets on phones
-
answered.tsfollows T-R6.6's rules. - The draft schema:
- gains
matching- parses entry by entry, so one unreadable entry is dropped rather than clearing the whole draft - still restores a v0.9.0-format draft (draft-compat.test.ts) - Larger text applies to the new types. The reload block now covers only types this build does not know.
- TFNG, the two-pick multiple choice, the word counter and matching are compared with the deck's papers s1, s2 and s9. The essay frame is listed for DG-80 and DG-60.
-
progress()(T-D4.4) follows T-R6.6's rules for the new types; the Submit dialog's sentence becomes the deck's "Unanswered gaps, matches and questions score zero." once matching exists. - The AI's Generate offers "True / False / Not given" and its Check applies "Change to Not given" and "Add word limit" once the types exist (DG-202).
Depends on: T-R6.7
Touches: web/src/features/attempts/pages/teacher/{GradingPage,AttemptReviewPage}.tsx (R4), web/src/features/attempts/components/{GradingCard,AnswerReview,AttemptSheet}.tsx, web/src/features/attempts/components/BandScale.tsx (new), the Students tab of web/src/features/assignments/pages/teacher/AssignmentDetailPage.tsx (R4), locales, web/tests/units/attempts/{grading,papers}.test.tsx, web/tests/units/assignments/detail.test.tsx
Size: M
Done when:
- Points questions keep R4's score buttons (74-r4.md): 0 to the maximum in 0.5 steps with position keys when that is nine values or fewer, a number field otherwise. Nothing changes for them.
- Essays replace those buttons with a 3–9 half-step scale. With criteria there is one scale per criterion, and the derived band updates live.
- Keys apply to the focused scale: a digit 3–9 sets that band, and Shift+digit sets the half above. Each button shows its key hint.
- A short answer over its limit shows the badge "Over the word limit (5 of 3)", from
lib/words.ts, next to the deck's "Word limit exceeded" snippet. - Review shows: - TFNG with its labels - "Choose N" with the picks against the key - matching with the pairs against the key - essays with the text, word count, band and criteria
- Rosters and the attempt sheet show "6.0 band" where
score.bandis present. - 422 grade errors render as field messages. "Save & next" stays disabled until every criterion is set.
- D4's "Suggest a score" takes the band form: the four criteria as reasons and the band as the score (DG-202).
Depends on: T-R6.8, T-R6.10
Touches: web/src/features/results/pages/ResultPage.tsx, web/src/features/results/components/{ScoreRing,ResultTiles,ReviewItem}.tsx, web/src/features/assignments/components/RecentResults.tsx, locales, web/tests/units/results/result.test.tsx, web/tests/units/student/home.test.tsx
Size: M
Done when:
- With
score.band, the ring reads "6.0 · band" and the sentence names the grader. Recent results on Home show the band. - Tiles follow the rule in the decisions section. A single essay with criteria shows its criteria bands, as the deck's Task achievement, Coherence, Vocabulary and Grammar tiles do. Pending answers keep R3's summary tiles.
- The deck's Result variant 2 topic tiles are not built: a one-part, one-skill paper shows no tiles, and the PR lists the variant as a known difference from the deck.
- When the paper's only hand-graded answer has a comment, the comment callout shows it. Otherwise comments stay on their items.
- Each new type has a review item, and correctness shows only with
showCorrectAnswers. - Compared with the deck's "Hand-graded band" variant at the five widths, light and dark.
Depends on: T-R6.3
Touches: server/internal/modules/imports/domain/recognition/{tasks,build,exam,keys}.go, server/internal/modules/imports/domain/{draft,drafts}.go, server/internal/modules/imports/application/command/commit.go, server/internal/modules/imports/domain/recognition/tests/, web/src/features/imports/components/review/QuestionEditor.tsx, web/src/features/imports/draft.ts, locales, web/tests/units/imports/{draft,review-workspace}.test.ts(x)
Size: L
Done when:
- A section whose instruction names TRUE / FALSE / NOT GIVEN produces
tfngquestions. The instruction may also read T / F / NG, or the Vietnamese "đúng / sai / không có thông tin". - The key comes from the answer key's TRUE / FALSE / NOT GIVEN or T / F / NG. - A key outside those three is a finding. - Pasted text maps the same way (R4's text source,74-r4.mdT-R4.55 and T-R4.56): a part whose instruction reads "True, False or Not given", with "Answer: Not given" under a question, gives atfngquestion and its key, where R4 leaves a short answer for the teacher to fix. If the mapping moves a count for any pasted text, R4's shared fixtureapi/fixtures/pasted-text-counts.jsongains the case and the browser's quick count changes in the same PR. - "Choose TWO letters" on a multiple choice produces
selectCount, with the number as a word (ONE–FIVE) or a digit. A key with a different number of letters is a finding. - "NO MORE THAN n WORDS" and "ONE WORD ONLY" on a short answer produce
maxWords. On a fill-in, they stay instruction text. - Matching stays "Matching (not supported)" with the deck's exclude finding. Essays are not recognised (open item).
- The review editor's type select gains "True / False / Not given", with "Choose N" and word-limit fields. The draft JSON gains them additively.
- Commit writes the new fields. Recognition tests cover each form of instruction, using fixtures written for the test; the real corpus stays out of the repo.
Depends on: T-R6.10 … T-R6.13
Touches: seed/NN-dev-question-types.sql (new, development only, numbered at merge), seed/99-assert.sql, seed/README.md, web/tests/e2e/{author-and-publish,payload-leak}.live.spec.ts, web/tests/e2e/question-types.live.spec.ts (new), web/tests/e2e/support/live.ts
Size: M
Done when:
-
The dev seed gains a published paper in two parts with one question of every type, a reading-passage group, and an essay with four criteria. Seed data never goes in a migration. - Seeds load in lexical order, so the file takes the next free number at merge, one number per file in merge order, as every release from R2 does. It follows the earlier seeds whose teachers, classes and students it uses (at least R2's access and classroom seeds), and its header names them. -
99-assert.sqlalso refuses a frozen row that breaks a T-R6.4 publish rule, since a seed that writes version rows directly skips publish validation.README.mdlists the file. -
E2E 1 authors one question of every type through the editor and publishes the test.
-
question-types.live.spec.tsruns a paper from start to result: - a student answers every type, with "Choose two" replacing its oldest pick and a word limit exceeded, then submits - the auto-marks match T-R6.6 - a teacher grades the essay by criteria - the result shows the band and the tilesIt is a live spec because marking and the projection are server behaviour meeting the engine. -
E2E 9 runs against the every-type paper: - the session payload carries none of the four banned keys, nor
correctOptionIds,correctAnswers,correctMatches,correctOptionId,bandorcriteria- a second case reads the result withshowCorrectAnswersandshowScoreoff and finds none of them -
pnpm e2e:livepasses E2E 1, 2 and 5–9 and the new spec.
Depends on: T-R6.14
Touches: docs/quizzivy-spec-v0.3.md (§7, §13, §15; version bumped per §18), docs/plan/20-data-model.md, AGENTS.md, docs/design/gaps.md
Size: S
Done when:
- Spec §7 defines each type with its answer shape, marking rule and the band rule. §13 lists the new tables and columns, and §15 the contract. The "Availability: R6" markers go, and the version is bumped.
-
20-data-model.mdis complete for R6, with its deviations. - AGENTS.md's student-payload rule adds that the session payload never carries the
result-only keys (
correct*,band,criteria). No other rule changes. -
gaps.mdnotes, under DG-60 and DG-61, what was built in the meantime. -
gaps.mdholds a DG entry for each design request this release makes, added here unless it is already there: - the essay grading scale: the deck draws 3–8 in nine values, and R6 builds 3–9 in half steps - "Topic tiles have no model": Result variant 2's topic tiles, with the proposed interim rule from the open items
- Every task is ticked on
work/redesign-r6.developis merged into it, and verification is re-run after that merge. - Every CI step runs locally, in order, judged by exit code (70 §8.1):
-
make lint,make gen-check-make test-api(afterdocker compose up -d db minio), and goose up/down/up -pnpm lint,pnpm typecheck,pnpm format:check-pnpm test:unit,pnpm test:integration,pnpm build-pnpm e2e,pnpm e2e:content-pnpm e2e:live, required because take-test and imports changed -node scripts/check-design-deck.mjs - The five canaries pass, and their files are untouched against
develop. - Security: the isolation suite passes, E2E 9 passes as extended, and no public operation was added.
- All eight migrations are rehearsed on a Neon branch of production, including the enum
additions, the two
CONCURRENTLYindexes and eachVALIDATE. Timings go in the release PR. - The deploy window is outside exam windows (query production assignments) and outside import hours. The worker and the API run the same binary, so an import in review during the switch is read by one version or the other, never both.
- Deploy and smoke:
- Thuong's go, then
release/0.13.0→main- deploy the API (the release command migrates), then Pages - smoke/healthz,/livez, and sign-in as each role - in the owner's test class, a teacher publishes a paper with one question of every type, a student takes it, and the teacher grades the essay - back-merge todevelop, and tagv0.13.0 - Release notes, vi first: - the new types - how essay bands turn into points - rubric criteria - "Choose N" marking per correct pick - word limits advise and never cut text - old tabs are asked to reload
- T-R6.15's docs are in the release.
-
DG-60, DG-113. Since the 2026-10-04 export the builder draws the authoring side of
Matching and of "Not given". "Choose N", the word limit, the essay and its rubric are still
undrawn; their panels ship extrapolated and need the design team's sign-off. The drawing and
this file disagree on two points, and the defaults are this file's:
tfngstays its own stored type behind the drawn switch, and matching keeps 12 options and 20 items where the deck stops at ten of each. Thuong or the design team confirms. -
Images and audio inside a prompt or an explanation (DG-109). The 2026-10-04 editor draws
them, the contract has no place for them, and R4 leaves them out (
74-r4.md, open items). Default: R6 builds them on this file's track (contract, bank, snapshot, projection, engine, result), and this file gains the tasks when Thuong confirms. - Matching on the Student page (DG-194). Three key styles and the nouns "pick the {pick} for each {what}" have no model. Default: keys as authored and no nouns.
- A per-question hint line and an image caption (DG-189) have no field. Default: not built.
- DG-61. The rubric model (criteria per question, band = mean rounded to 0.5) is interim.
-
The deck's essay score set is 3–8 in nine values; R6 builds 3–9 in half steps. A DG
entry in
gaps.md(T-R6.15) asks the design team to redraw the grading scale. - Topic tiles have no model. The deck's Result variant 2 draws topic tiles on a one-part, one-skill grammar paper, and nothing in the data model defines a topic. The DG entry (T-R6.15) proposes an interim rule: tiles from question tags when a paper has one part and one skill. Thuong or the design team decides. Default: not built in R6; such a paper shows no tiles.
-
YES / NO / NOT GIVEN, IELTS's opinion variant of TFNG, is not built. It would need a label
set on
tfng. Default: teachers use TFNG. - Essays in the Word import. Writing prompts ("Write at least 150 words") are not recognised as essays. Default: the teacher changes the type in review.
Generated from docs/plan/76-r6.md. Edit that file in the repository; changes made on this page are overwritten.
Generated from the repository's documentation on every push to develop.
Start here
Product
Implementation plan
- 00 Architecture overview
- 10 Phase 0: Scaffold and contract
- 11 Phase 1: Auth, join, shells
- 12 Phase 2: Admin authoring
- 13 Phase 3: Assign and take
- 14 Phase 4: Grade and results
- 15 Phase 5: Hardening
- 16 Phase 4.5: The student at a desk
- 17 Word import: milestone plan
- 18 Word import: teacher workflow
- 19 Versioned content contract
- 20 Data model
- 30 Risk register
- 40 Open items
- 50 Non-functional requirements
- 60 Backend architecture
- 91 September 2026 issue remediation
- 92 Student layouts and interaction rules
Redesign (Phase R)
- 70 Redesign overview
- 71 R1: Foundations (v0.7.0)
- 72 R2: Access (v0.8.0)
- 73 R3: Student console (v0.9.0)
- 74 R4: Teacher workspace (v0.10.0)
- 74d D4: Deck update (v0.11.0)
- 74e E1: Moving a question across a group
- 75 R5: Admin console and email (v0.12.0)
- 76 R6: Question types and scoring (v0.13.0)
- 77 R7: Collaboration (v0.14.0)
- 78 R8: Schedule (v0.15.0)
- 79 R9: Insights (v0.16.0)
- 80 R10: Learn (v0.17.0)
- 81 R11: Landing, leads, v1.0 (v1.0.0)
Setup and operations
- CI
- Deploying
- DNS for quizzivy.com
- Google OAuth client
- Production operations
- Cloudflare R2 bucket
- Word import worker
- R4 rehearsal on a Neon branch
Design
Team
- Agent team
- Verification environment
- Team ledger
- R4 readiness
- R4 verification strategy
- T-R4.51 browser verification
Code READMEs