PLAN-MVP — Phase 1 implementation plan¶
Archived — phase 1 complete
This plan drove the phase-1 build (2026-09-20 → 2026-09-22). Phase 1 is
finished and only the decisions D1–D20 of §9 below remain authoritative
(except D18, rejected by a supervisor override — the SPA API stays under
/app/api/* — and D7, superseded when polls shipped, ADR-014). The
current state of the code is in CLAUDE.md, docs/spec/ and the ADRs.
Where the rest went. The full plan, unchanged, is archived at
history/PLAN-MVP.md. Source comments cite its
sections as "PLAN-MVP §x.y" (§0 the MVP scope lock, §1 the question-type
contract, §2 the four types, §3 the schema, §4 the contracts, §5 the state
machines, §6 the web app, §7 the domain, §8 the work packages, §10 the quick
reference for implementing agents, Deviations): every such citation other
than §9 resolves in that archived copy.
9. Open decisions (spec silent or contradictory)¶
| # | Question | Decision | Rationale |
|---|---|---|---|
| D1 | docs/05 says the two registries live in packages/core, but core cannot import qt-* without a package cycle |
Contract + RNG in packages/core; the static wiring moves to packages/registry (./server, ./client) |
Breaks the cycle without changing the "static import, two registries" property; adding a type still means "create the package, register it in two places" |
| D2 | GradeResult shape: docs/04 says {points,maxPoints,details} or {pending:'runner'\|'llm'} |
Discriminated union on kind: "graded" \| "pending", plus an optional state on graded and a finalizeRunner second half |
A bare {pending} cannot carry the assembled runner request; splitting grading into grade + finalizeRunner makes the code grader unit-testable with zero infrastructure |
| D3 | MCQ answer indices leak the canonical order if exposed after shuffling | Expose the canonical index as choices[].id while shuffling the array order. Amended 2026-10-10 by ADR-097: the id is the displayed position; the answer is STORED in canonical indices and converted by the server at its single entry and exit |
Indices reveal nothing (correctness is not exposed); the alternative (opaque per-attempt tokens) needs server-side state for no security gain. Wrong (audit H2): the editor ticks the first choice of a new question, so canonical index 0 named the key in most single-answer questions |
| D4 | Cloze answer encoding for dropdown blanks | select blanks store the canonical option index as a decimal string; other blanks store the typed string. Amended 2026-10-10 by ADR-097: a dropdown option's id, and the value a student sends and reads, is its displayed position |
Keeps blanks: string[] as the spec says, while making the stored answer independent of the shuffle |
| D5 | How a cloze blank is rendered inside markdown (and inside fenced code) | Parser emits a template with sentinels ⟦b<i>⟧ (U+2E22/U+2E23); the markdown renderer swaps sentinel text nodes for input components, including inside code blocks |
Preserves markdown block structure; a per-segment render would break lists, tables and code fences |
| D6 | docs/01 lists a graded evaluation state; docs/05 has no column for it |
Stored states are draft, scheduled, lobby, running, paused, closed, grading, released. "graded" is derived (closed + no proposed grading left) and shown as a badge |
Avoids a state that no transition can reliably enter or leave (a single regrade would invalidate it) |
| D7 | poll mode is Phase 2 but the mode enum includes it |
Column and enum accept poll; every poll code path returns 501 not_implemented in MVP. Partially lifted: the poll module (ADR-014) now creates and runs poll evaluations through its own routes (POST /app/api/polls, /app/api/p/:code, /app/api/evaluations/:id/poll/*); the GENERIC create route still refuses mode: "poll", so a poll is only ever born through the launcher |
No migration later, no dead UI now — and one door into the mode rather than two, since a poll needs its single item, its session code and its immediate start in the same call |
| D8 | Time-bonus base in deadline timing mode (F-EVAL-05 says "extends the individual end" without saying of what) |
Extension = (closesAt − opensAt) × bonus/100, added to closesAt |
Uses the announced common duration, so two students with the same accommodation get the same extension regardless of when they started. Amended by #253: the base is the window as the teacher set it; the live shifts of closesAt (extension to all, pause) are recorded in closes_at_shift_s and taken back out, so they never grow the bonus. It follows the teacher's timing edits while the configuration is editable and is frozen once it locks |
| D9 | Case-insensitive exact/cloze comparison and accents |
toLocaleLowerCase("fr") on both sides; accents remain significant |
"Galilée" ≠ "Galilee" is the pedagogically correct default for a French-language course; a teacher who disagrees adds an alternative or a regex |
| D10 | Regex ReDoS in short/cloze |
Pattern ≤ 300 chars, input ≤ 500 chars, flags restricted to imsu, compiled once at publication (invalid = publication error), executed in the grading worker (not the request path). Amended by ADR-099 (2026-10-10, security audit H3): matchers also run on the request path, so the engine is RE2, linear in the input, with bounded programs; a pattern RE2 refuses is a publication issue, never a read error |
Bounded input makes catastrophic backtracking a worker-level slowdown at worst, not a request-path DoS. Revisit with re2 if a real case appears — it did (ADR-099) |
| D11 | Tiptap WYSIWYG in MVP (docs/05 §5.1 asks for it, F-QST-06 is M) | MVP ships a markdown textarea + toolbar + live sanitised preview + image paste, with the Source toggle already in place; Tiptap replaces only the "Écrire" pane later |
Markdown is the single source of truth either way, so the upgrade needs no data migration; Tiptap round-tripping is ~3 days and a fidelity risk, off the critical path of "run a graded quiz" |
| D12 | Where the +3 s grace constant lives |
One exported constant GRACE_MS in @quiz/domain/deadline, used by the ticker and the autosave gate |
The two must never drift; a student writing at deadline+2.9 s must be accepted by the same rule the ticker uses |
| D13 | Points rounding granularity | numeric(6,2), grader output round2(); grades rounded to 0.1 half-away-from-zero |
Two decimals survive partial/penalized fractions without surprising the teacher; Math.round on negatives would round −0.5 toward zero, hence the explicit half-away rule |
| D14 | Runner default in dev and CI | RUNNER_MODE=stub (an UnavailableRunner that throws), http only when RUNNER_URL is set; grading degrades to proposed + runner_unavailable, never blocks |
The dev machine has no container engine; a code question must still be authorable, playable and releasable |
| D15 | Hidden test cases during an attempt | The student sees hiddenCount and hiddenPoints but no name, stdin or expected; names + verdicts appear in the feedback when feedbackPolicy.showHiddenCaseNames (docs/06 Q8) |
Lets a student reason about the scale without exposing the key |
| D16 | Invalid drafts | PUT /draft stores an invalid config and returns issues[]; configSchema.parse is enforced only at publish |
F-QST-02 says every change is auto-saved; a teacher must be able to leave a question half-written |
| D17 | Autosave during paused |
410 attempt_closed with reason:"paused", client greys out and buffers locally, resends on evaluation.state → running |
Pausing must actually stop work; nothing is lost because the client keeps the unacked payload (N-RES-02) |
| D18 | Public API surface | One surface /api/v1 serving cookie sessions now and bearer tokens later — not a separate /app prefix |
docs/08 §8.1 principle 4: "everything the UI does, the API does". Avoids two route trees |
| D19 | Attempt seed | int4 drawn at attempt creation; all permutations derived as hashSeed(seed, itemId, purpose), never stored |
Reload, teacher preview and regrade reproduce the student's exact view; one integer instead of N stored permutations. Amended by ADR-056 (2026-10-01): the VALUES of a parameterized question are drawn from the same seed (purpose vars) but stored with the item, since an evaluator upgrade must not change them; permutations are still recomputed. |
| D20 | Grid pseudonyms (F-DASH-02 "stable pseudonym per row") | Deterministic adjective+animal from hashSeed(evaluationId, userId) in @quiz/domain/pseudonym |
Stable across reloads of the live dashboard, and not reversible without the evaluation id. Amended by ADR-044: the grading panel no longer uses pseudonyms — anonymised, it shows no label at all and shuffles its rows per visit |