Skip to content

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