PLAN-MVP — Phase 1 implementation plan (archived)¶
Historical snapshot, archived on 2026-10-09. This is the full phase-1 plan (2026-09-20 → 2026-09-22), its sections unchanged, so that the "PLAN-MVP §x.y" citations in source comments resolve here. It describes intent at the time, not the current code. Read the current pointer first: it holds the decisions D1–D20 of §9, which remain authoritative (except D18, rejected below, and D7, superseded when polls shipped — ADR-014). The current state is in
CLAUDE.md,docs/spec/and the ADRs.In particular §0 "MVP scope lock" is no longer accurate. Several items it lists as out of the MVP have since shipped: live polls (ADR-014), shared pools (ADR-013), several attempts on an exercise (ADR-025), API tokens and the MCP server (ADR-022, ADR-023), and three more question types (
circuit, ADR-019;codeimage, ADR-021; besidemcq,short,cloze,code).
Authored by the planning agent on 2026-09-20 from docs/spec/*, reviewed by the supervising session.
Supervisor overrides (binding):
- D18 rejected. The SPA API stays under
/app/api/*(heig-classroom routing, Vite proxy on/app, session cookies). The bearer-token/api/v1surface is later work and will alias the same handlers. - Section 3 must be reconciled with what the bootstrap already created in
apps/api/src/db/schema.ts(courses,course_staff,classrooms,enrollmentsexist). Add tables in a new migration; never edit0000_init.sql. RUNNER_MODE=stubis the default everywhere until a machine with Podman exists.
Sources read in full: docs/01, 02, 04, 05, 08; skimmed 00, 03, 06, 07; mockup headers + DOM structure of
01–10; heig-classroom ui.tsx, contracts/, db/schema.ts, events.ts, modules/events.ts, modules/guards.ts,
ticker.ts, jobs.ts, api.ts, router.ts, live.ts, domain/.
Everything in code and comments is English; only UI strings are translated (fr/en).
Package scope: @quiz/*. Node 22, TS strict, ESM, .js import suffixes (heig-classroom convention).
0. MVP scope lock¶
In (docs/00 §0.5 Phase 1, M only unless noted):
| Area | In MVP |
|---|---|
| Auth | edu-ID OIDC, roles student/teacher/admin — inherited from heig-classroom, not re-implemented |
| Org | courses, course_staff, course_pools, classrooms, roster CSV, join code (S), time bonus |
| Pool | private pools, categories, tags, draft→publish versions, search, soft delete, copy (S) |
| Types | mcq, short, cloze, code only |
| Evaluation | mode exam + exercise; poll is P2, out |
| Live | lobby, running/paused, autosave, SSE, server clock, +1/+5/+10, close |
| Grading | auto for the 4 types, manual override, regrade, release |
| Results | Swiss 1..6 at 0.1, CSV export, per-question view, student feedback |
| Dashboard | student × question grid, name/answer/result toggles |
| Admin | user list + role promotion, runner health, audit log |
Out of MVP, explicitly: LLM (all of F-LLM, rich type, llm matcher), drill, stats/item analysis, poll mode,
projection view, guest participants, canonical YAML export (S — WP is defined but last), GIFT import, CLI, MCP,
command palette (S — thin version only), Tiptap WYSIWYG (see §6.7), random variables ({{R1}}), tap test mode,
multi-attempt exercises, shared/public pools, api_tokens.
1. packages/core — the question-type contract¶
packages/core/
package.json exports: "." (contract + utils, no React), "./client" (React-typed props)
src/index.ts re-export of everything server-safe
src/contract.ts QuestionTypeServer, GradeResult, GradeContext, StudentView
src/client.ts QuestionTypeClient, EditorProps, PlayerProps, ReviewProps (imports `react` types only)
src/registry.ts defineServerRegistry / defineClientRegistry + lookup helpers
src/rng.ts seeded RNG: hashSeed, mulberry32, shuffle, pick
src/errors.ts UnknownQuestionType, ConfigMigrationError, RunnerUnavailable
src/runner.ts RunnerService interface + RunnerRequest/RunnerOutcome zod schemas
src/*.test.ts
packages/core must not import any qt-* package (cycle). The static wiring lives in a separate
packages/registry (./server and ./client entry points) that depends on core + the four qt packages.
See open decision D1.
1.1 Seeded RNG (src/rng.ts)¶
Chosen: xmur3 string hash → mulberry32. 30 lines, no dependency, deterministic across Node and browsers,
uniform enough for shuffling ≤ 12 items. attempts.seed is a random int4 (0 … 2^31-1) drawn at attempt creation.
/** 32-bit string hash (xmur3), used to derive one stream per purpose. */
export function hashSeed(...parts: (string | number)[]): number {
let h = 1779033703 ^ parts.join("\u0001").length;
const s = parts.join("\u0001");
for (let i = 0; i < s.length; i++) {
h = Math.imul(h ^ s.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
return (h ^= h >>> 16) >>> 0;
}
/** mulberry32 PRNG: returns a [0,1) generator. */
export function rng(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Fisher-Yates, pure: returns a new array. Same seed ⇒ same permutation, forever. */
export function shuffle<T>(items: readonly T[], seed: number): T[] {
const out = items.slice();
const next = rng(seed);
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(next() * (i + 1));
[out[i], out[j]] = [out[j]!, out[i]!];
}
return out;
}
/** The one and only seed derivation used by the platform. */
export function streamSeed(attemptSeed: number, itemId: string, purpose: string): number {
return hashSeed(attemptSeed, itemId, purpose);
}
Purposes in use: "items" (question order, seeded on attemptSeed + evaluationId), "choices" (mcq),
"options:<blankIndex>" (cloze selects). Rule: a permutation is never stored, always recomputed from
(attempt.seed, item.id, purpose). This makes reload, teacher preview and regrade identical.
1.2 GradeResult union¶
/** Points are on the item scale already (0 … maxPoints), rounded to 2 decimals. */
export interface GradedResult<D = unknown> {
kind: "graded";
points: number;
maxPoints: number;
/** Type-specific breakdown, stored verbatim in gradings.details. */
details: D;
/** Grading born validated (deterministic) or proposed (needs teacher eyes). */
state?: "validated" | "proposed";
/** Optional machine comment shown to the teacher, e.g. "runner unavailable". */
comment?: string;
}
export interface PendingRunnerResult {
kind: "pending";
via: "runner";
/** Fully assembled, server-side. Never contains student-supplied file names. */
request: RunnerRequest;
/** Partial details already known (e.g. assembled source hash). */
details?: unknown;
}
export interface PendingLlmResult {
kind: "pending";
via: "llm";
/** Phase 2. The MVP grading worker rejects this with `llm_unavailable`. */
request: LlmGradeRequest;
}
export type GradeResult<D = unknown> = GradedResult<D> | PendingRunnerResult | PendingLlmResult;
export const isGraded = <D>(r: GradeResult<D>): r is GradedResult<D> => r.kind === "graded";
1.3 QuestionTypeServer¶
export type QuestionTypeId = "mcq" | "short" | "cloze" | "code";
/** What the student is allowed to see right now. */
export interface StudentView {
/** Attempt seed; 0 for the teacher preview (stable preview). */
seed: number;
itemId: string;
/** Evaluation-level shuffle switch AND question-level shuffleable flag, ANDed by the caller. */
shuffle: boolean;
}
export interface GradeContext {
seed: number;
itemId: string;
attemptId: string;
/** evaluation_items.points — the scale the grader must produce points on. */
itemPoints: number;
now: Date;
/** Always present; may be the unavailable stub (throws RunnerUnavailable). */
runner: RunnerService;
/** Phase 2; undefined in MVP. */
llm?: LlmService;
}
export interface QuestionTypeServer<
TConfig = unknown,
TAnswer = unknown,
TStudent = unknown,
TSolution = unknown,
TDetails = unknown,
> {
readonly id: QuestionTypeId;
/** Bumped when configSchema changes shape; stored in question_versions.config_version. */
readonly configVersion: number;
readonly configSchema: z.ZodType<TConfig>;
readonly answerSchema: z.ZodType<TAnswer>;
readonly studentSchema: z.ZodType<TStudent>;
readonly solutionSchema: z.ZodType<TSolution>;
readonly detailsSchema: z.ZodType<TDetails>;
/** Config of a freshly created draft, valid against configSchema. */
emptyDraft(): TConfig;
/** Raise an old stored config to configVersion. Pure, total, never throws on a config it emitted before. */
migrate(config: unknown, fromVersion: number): TConfig;
/** F-EVAL-02 default item points. */
defaultPoints(config: TConfig): number;
/** Whether shuffling is meaningful for this config (mcq with shuffleChoices, cloze with selects). */
shuffleable(config: TConfig): boolean;
/**
* THE single content exit toward a student (docs/05 §5.7).
* Must never return the answer key, explanation, hidden test bodies, matchers,
* regexes, tolerances, internal name, tags or difficulty. Pure and tested by
* key-blacklist + key-value search on the serialized output.
*/
toStudent(config: TConfig, view: StudentView): TStudent;
/** Key + rationale for review, served ONLY when the feedback policy allows it. */
toSolution(config: TConfig, view: StudentView): TSolution;
/** Phase 2 random values; absent in MVP packages. */
randomize?(config: TConfig, seed: number): TConfig;
/** `answer === null` means "not answered": graders must return 0 points (F-GRADE-01). */
grade(config: TConfig, answer: TAnswer | null, ctx: GradeContext): GradeResult<TDetails> | Promise<GradeResult<TDetails>>;
/** Second half of a `pending: runner` grading. Pure, so it is unit-testable without a runner. */
finalizeRunner?(
config: TConfig,
answer: TAnswer | null,
ctx: Omit<GradeContext, "runner" | "llm">,
outcome: RunnerOutcome,
): GradedResult<TDetails>;
/** Text fed to the tsvector of question_versions.search. */
searchText(config: TConfig): string;
/** Canonical YAML mapping; identity when omitted. */
toCanonical?(config: TConfig): unknown;
fromCanonical?(raw: unknown): TConfig;
}
1.4 QuestionTypeClient (@quiz/core/client)¶
export interface EditorProps<TConfig> {
config: TConfig;
/** Partial patch; the host merges, validates lazily and autosaves the draft. */
onChange: (next: TConfig) => void;
/** Uploads an image and returns `asset:<uuid>` for the markdown. */
uploadAsset: (file: File) => Promise<string>;
disabled?: boolean;
}
export interface PlayerProps<TStudent, TAnswer> {
student: TStudent;
answer: TAnswer | null;
onChange: (next: TAnswer) => void;
/** Read-only when the attempt is submitted / paused / expired. */
readOnly: boolean;
/** Code type only: interactive run through POST /attempts/:id/run. */
run?: (payload: unknown) => Promise<unknown>;
}
export interface ReviewProps<TStudent, TAnswer, TSolution, TDetails> {
student: TStudent;
answer: TAnswer | null;
/** null when the feedback policy hides the key. */
solution: TSolution | null;
details: TDetails | null;
points: number | null;
maxPoints: number;
/** "teacher" shows everything; "student" respects the already-applied filtering. */
audience: "teacher" | "student";
}
export interface QuestionTypeClient<TConfig = unknown, TAnswer = unknown, TStudent = unknown, TSolution = unknown, TDetails = unknown> {
readonly id: QuestionTypeId;
/** i18n keys, not literals: "qt.mcq.label", "qt.mcq.hint". */
readonly labelKey: string;
readonly hintKey: string;
readonly Icon: ComponentType<{ className?: string }>;
/** React.lazy, so qt-code (Monaco) never enters the initial bundle (N-PERF-05). */
readonly Editor: LazyExoticComponent<ComponentType<EditorProps<TConfig>>>;
readonly Player: LazyExoticComponent<ComponentType<PlayerProps<TStudent, TAnswer>>>;
readonly Review: LazyExoticComponent<ComponentType<ReviewProps<TStudent, TAnswer, TSolution, TDetails>>>;
readonly Stats?: LazyExoticComponent<ComponentType<{ student: TStudent; answers: TAnswer[] }>>;
/** Empty answer for a fresh item (e.g. { selected: [] }). */
emptyAnswer(student: TStudent): TAnswer;
/** Drives the "empty / seen / done" progress segments (F-LIVE-09). */
isAnswered(answer: TAnswer | null): boolean;
/** One-line rendering for the dashboard inspection panel and the cell tooltip. */
summarize(answer: TAnswer | null, student: TStudent): string;
}
1.5 Registries¶
// packages/core/src/registry.ts
export function defineServerRegistry<T extends Record<string, QuestionTypeServer<any, any, any, any, any>>>(m: T): T { return m; }
export function makeLookup<T extends { id: string }>(m: Record<string, T>) {
return (id: string): T => {
const t = m[id];
if (!t) throw new UnknownQuestionType(id);
return t;
};
}
// packages/registry/src/server.ts (depends on core + qt-*)
import { mcqServer } from "@quiz/qt-mcq/server"; /* … */
export const serverRegistry = defineServerRegistry({ mcq: mcqServer, short: shortServer, cloze: clozeServer, code: codeServer });
export const questionType = makeLookup(serverRegistry);
export const QUESTION_TYPE_IDS = ["mcq", "short", "cloze", "code"] as const;
// packages/registry/src/client.ts
export const clientRegistry = { mcq: mcqClient, short: shortClient, cloze: clozeClient, code: codeClient };
export const questionTypeClient = makeLookup(clientRegistry);
1.6 Read/write pipeline in the API (single place, apps/api/src/modules/pool/service.ts)¶
/** Every read of a stored config goes through this. Never parse a raw jsonb elsewhere. */
export function loadConfig(type: string, raw: unknown, storedVersion: number) {
const t = questionType(type);
const migrated = storedVersion === t.configVersion ? raw : t.migrate(raw, storedVersion);
return t.configSchema.parse(migrated); // throws 422 config_invalid
}
/** Every write. Drafts are always rewritten at the current configVersion (docs/05 §5.2). */
export function saveConfig(type: string, config: unknown) {
const t = questionType(type);
return { config: t.configSchema.parse(config), configVersion: t.configVersion };
}
1.7 Runner interface (packages/core/src/runner.ts) — HTTP service behind an interface¶
export const RunnerLanguage = z.enum(["c", "cpp", "python", "js", "rust"]);
export const RunnerRequest = z.object({
language: RunnerLanguage,
files: z.array(z.object({ name: z.string().max(64), content: z.string().max(200_000) })).min(1).max(8),
compileArgs: z.string().max(400).default(""),
action: z.enum(["check", "run"]),
limits: z.object({ timeMs: z.number().int().min(100).max(20_000), memoryMb: z.number().int().min(16).max(512), outputKb: z.number().int().min(1).max(256) }),
cases: z.array(z.object({ name: z.string(), stdin: z.string().max(64_000) })).max(50),
/** "interactive" (student clicked Run) or "grading" (background). Maps to the runner's two queues. */
priority: z.enum(["interactive", "grading"]).default("grading"),
});
export const RunnerOutcome = z.object({
compile: z.object({ ok: z.boolean(), stdout: z.string(), stderr: z.string(), ms: z.number() }),
cases: z.array(z.object({
exitCode: z.number().int().nullable(), stdout: z.string(), stderr: z.string(),
ms: z.number(), timedOut: z.boolean(), oom: z.boolean(), truncated: z.boolean(),
})),
});
export interface RunnerService {
/** Throws RunnerUnavailable when no engine is configured/healthy; throws RunnerBusy on 429. */
run(req: RunnerRequest): Promise<RunnerOutcome>;
health(): Promise<{ ok: boolean; languages: string[]; queued: number; avgMs: number | null; reason?: string }>;
}
Three implementations, selected by RUNNER_MODE env (stub | http | fake):
| Impl | File | Behaviour |
|---|---|---|
UnavailableRunner (default in dev) |
apps/api/src/modules/runner/unavailable.ts |
run() throws RunnerUnavailable; health() returns { ok:false, languages:[], reason:"not_configured" } |
HttpRunner |
apps/api/src/modules/runner/http.ts |
POST ${RUNNER_URL}/run, 30 s timeout, one retry on 502/503, no retry on 429 (surfaces RunnerBusy) |
FakeRunner |
apps/api/src/modules/runner/fake.test.ts + apps/web/src/mock/ |
Scripted outcomes for tests and dev:mock |
No container engine is required to develop or test anything except WP3's apps/runner itself.
2. The four question types¶
Common conventions: every config carries configVersion: 1; prompt/text are markdown with asset:<uuid> image
references; all points produced by graders are round2(fraction × ctx.itemPoints).
2.1 mcq (docs/04 §4.4)¶
// packages/qt-mcq/src/schema.ts
export const McqChoice = z.object({
text: z.string().min(1).max(2000),
correct: z.boolean().default(false),
});
export const McqConfig = z.object({
configVersion: z.literal(1),
prompt: z.string().min(1).max(20_000),
choices: z.array(McqChoice).min(2).max(12),
mode: z.enum(["single", "multiple"]).default("single"),
maxSelections: z.number().int().min(1).max(12).optional(),
policy: z.enum(["all_or_nothing", "partial", "penalized"]).default("all_or_nothing"),
penalty: z.number().min(0).max(1).default(1),
allowNegative: z.boolean().default(false),
shuffleChoices: z.boolean().default(true),
})
.refine((c) => c.choices.some((x) => x.correct), { message: "mcq.no_correct_choice" })
.refine((c) => c.mode !== "single" || c.choices.filter((x) => x.correct).length === 1, { message: "mcq.single_needs_one" })
.refine((c) => c.policy !== "all_or_nothing" || true)
.refine((c) => c.mode !== "single" || c.policy === "all_or_nothing", { message: "mcq.single_policy" });
export const McqAnswer = z.object({
/** Canonical indices into config.choices, ascending, unique. */
selected: z.array(z.number().int().min(0).max(11)).max(12),
});
export const McqStudent = z.object({
prompt: z.string(),
/** `id` is the canonical index; the ARRAY ORDER is the shuffled display order. */
choices: z.array(z.object({ id: z.number().int(), text: z.string() })),
mode: z.enum(["single", "multiple"]),
maxSelections: z.number().int().optional(),
});
export const McqSolution = z.object({ correct: z.array(z.number().int()) });
export const McqDetails = z.object({
policy: z.enum(["all_or_nothing", "partial", "penalized"]),
correct: z.array(z.number().int()), // C set
selected: z.array(z.number().int()),
c: z.number().int(), w: z.number().int(), C: z.number().int(), W: z.number().int(),
fraction: z.number(),
});
Stripping rule (toStudent): keep prompt, choices[].text, mode, maxSelections. Drop correct,
policy, penalty, allowNegative. Shuffle with shuffle(indexed, streamSeed(seed, itemId, "choices")) when
view.shuffle && config.shuffleChoices.
Grading (answer === null ⇒ selected = []):
C = #correct, W = #incorrect, c = |selected ∩ correct|, w = |selected \ correct|
all_or_nothing : f = (c === C && w === 0) ? 1 : 0
partial : f = (c - w) / C
penalized : f = c / C - penalty * (W > 0 ? w / W : 0)
f = clamp(f, allowNegative ? -1 : 0, 1)
points = round2(f * itemPoints)
maxSelections is a player-side guard only; a payload exceeding it is truncated to the first maxSelections
entries server-side and logged in details (never a hard reject — never lose an answer). State: validated.
Example config:
configVersion: 1
prompt: |
Soit `int *p` pointant sur l'adresse `0x1000`. Que vaut `p + 1` ?
choices:
- { text: "`0x1001`", correct: false }
- { text: "`0x1004`", correct: true }
- { text: "`0x1008`", correct: false }
mode: single
policy: all_or_nothing
shuffleChoices: true
configVersion: 1
prompt: "Lesquelles de ces déclarations sont valides en C17 ?"
choices:
- { text: "`int a[] = {1,2,3};`", correct: true }
- { text: "`int a[3] = {};`", correct: true }
- { text: "`int a[] ;`", correct: false }
- { text: "`int a[-1];`", correct: false }
mode: multiple
policy: penalized
penalty: 0.5
allowNegative: false
2.2 short (docs/04 §4.5)¶
const Base = { points: z.number().min(0).max(1).default(1) };
export const ShortMatcher = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("exact"), value: z.string().min(1).max(500),
caseSensitive: z.boolean().default(false), trim: z.boolean().default(true),
collapseSpaces: z.boolean().default(true), ...Base }),
z.object({ kind: z.literal("regex"), pattern: z.string().min(1).max(300),
flags: z.string().regex(/^[imsu]*$/).default("i"), ...Base }),
z.object({ kind: z.literal("number"), value: z.number(), tolerance: z.number().min(0).default(0),
toleranceMode: z.enum(["abs", "rel"]).default("abs"),
unit: z.string().max(16).optional(), unitRequired: z.boolean().default(false), ...Base }),
z.object({ kind: z.literal("date"), value: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
toleranceDays: z.number().int().min(0).default(0), ...Base }),
z.object({ kind: z.literal("time"), value: z.string().regex(/^\d{2}:\d{2}$/),
toleranceMinutes: z.number().int().min(0).default(0), ...Base }),
// phase 2, accepted by the schema but rejected at publication in MVP:
z.object({ kind: z.literal("llm"), rubric: z.string(), reference: z.string().optional(), ...Base }),
]);
export const ShortConfig = z.object({
configVersion: z.literal(1),
prompt: z.string().min(1).max(20_000),
kind: z.enum(["text", "number", "date", "time"]).default("text"),
placeholder: z.string().max(80).optional(),
matchers: z.array(ShortMatcher).min(1).max(20),
});
export const ShortAnswer = z.object({ text: z.string().max(500) });
export const ShortStudent = z.object({ prompt: z.string(), kind: z.enum(["text","number","date","time"]), placeholder: z.string().optional() });
export const ShortSolution = z.object({ /** human rendering, e.g. "0x1004" or "≈ 3.14 ± 1 %" */ expected: z.array(z.string()) });
export const ShortDetails = z.object({
matchedIndex: z.number().int().nullable(),
matchedKind: z.string().nullable(),
normalized: z.string(),
fraction: z.number(),
});
Normalisation pipeline (packages/domain/src/short.ts, shared by all matchers):
1. NFC-normalise, replace NBSP/thin space by space, CRLF→LF.
2. trim if set; collapseSpaces if set (/\s+/g → " ").
3. For exact: caseSensitive === false ⇒ compare with localeCompare(…, { sensitivity: "base" })? No — use
toLocaleLowerCase("fr") on both sides; accents stay significant. (Decision D9.)
4. For number: strip the unit suffix (case-insensitive, optional space; if unitRequired and absent ⇒ no match),
strip space/apostrophe thousand separators, , → .; parse with Number(); reject NaN.
Match iff |x − value| ≤ tolerance (abs) or ≤ |value| × tolerance (rel).
5. For date/time: accept dd.mm.yyyy, dd/mm/yyyy, yyyy-mm-dd, hh:mm, hhhmm, hh h mm; normalise to
ISO; compare with the tolerance in days / minutes.
6. For regex: compile new RegExp("^(?:" + pattern + ")$", flags) — full match per spec. Guards: pattern ≤ 300 chars,
input ≤ 500 chars, compilation wrapped in try/catch at publication time (invalid regex = publication error).
Matchers are evaluated in order; the first match wins and yields fraction = matcher.points.
No match ⇒ fraction = 0, matchedIndex = null. Empty/absent answer ⇒ 0 without running matchers.
State: validated. A config containing an llm matcher is rejected at publication in MVP with llm_not_available.
Stripping (toStudent): keep prompt, kind, placeholder. Drop the whole matchers array.
Example:
configVersion: 1
prompt: "Quelle est la taille en octets d'un `int` sur une cible 32 bits ?"
kind: number
matchers:
- { kind: number, value: 4, tolerance: 0, toleranceMode: abs, unit: "octets", points: 1 }
configVersion: 1
prompt: "Donnez la directive qui inclut l'en-tête d'entrées-sorties standard."
kind: text
matchers:
- { kind: exact, value: "#include <stdio.h>", collapseSpaces: true, points: 1 }
- { kind: regex, pattern: "#\\s*include\\s*[<\"]stdio\\.h[>\"]", flags: "i", points: 1 }
- { kind: exact, value: "include <stdio.h>", points: 0.5 }
2.3 cloze (docs/04 §4.6)¶
export const ClozeConfig = z.object({
configVersion: z.literal(1),
/** Markdown with {{…}} holes. */
text: z.string().min(1).max(20_000),
caseSensitive: z.boolean().default(false),
shuffleOptions: z.boolean().default(true),
}).superRefine((c, ctx) => { /* parseCloze must succeed and yield ≥ 1 blank */ });
export const ClozeAnswer = z.object({
/** One entry per blank, in order of appearance. `null` = untouched. */
blanks: z.array(z.string().max(200).nullable()).max(50),
});
Parser (packages/domain/src/cloze.ts, the single source of truth, used by editor preview, toStudent and grader):
export type ClozeBlank =
| { index: number; weight: number; kind: "text"; answers: string[] }
| { index: number; weight: number; kind: "select"; options: string[]; correct: number[] }
| { index: number; weight: number; kind: "number"; value: number; tolerance: number; mode: "abs" | "rel" }
| { index: number; weight: number; kind: "regex"; pattern: string; flags: string };
export interface ClozeParse {
/** Markdown with each blank replaced by the sentinel `⸢{index}⸣`. */
template: string;
blanks: ClozeBlank[];
errors: { at: number; message: string }[];
}
export function parseCloze(text: string): ClozeParse;
Grammar, applied to the content between {{ and the matching }}:
| Step | Rule |
|---|---|
| Escape | \{{ produces a literal {{ and is not a blank. \|, \}, \*, \\ are literal inside a blank. |
| Weight | Optional leading \d+(\.\d+)?\* ⇒ weight, default 1. {{2*Newton}} |
# |
#<number>:<tol> ⇒ number; a trailing % on <tol> ⇒ mode:"rel" (tol/100), else abs. {{#3.14:1%}} |
/ … /flags |
regex, flags restricted to imsu, full-match anchored like short. {{/^[0-9a-f]+$/i}} |
contains = at the start of ≥ 1 alternative |
select: split on unescaped \|; an alternative starting with = is correct (the = is stripped); options keeps the authoring order as canonical. {{=Newton\|Maxwell\|Faraday}} |
| otherwise | text: split on unescaped \| ⇒ answers[], all accepted. {{Newton\|Isaac Newton}} |
Unterminated {{ |
recorded in errors, rendered literally |
Blanks inside fenced code blocks stay active: the sentinel is a plain string and the code-block renderer splits on it.
Answer encoding: text/number/regex blanks store the typed string; select blanks store the canonical option
index as a decimal string ("2"), so shuffling never affects stored answers (D4).
Student view:
export const ClozeStudent = z.object({
template: z.string(), // sentinel-bearing markdown, no answers anywhere
blanks: z.array(z.discriminatedUnion("kind", [
z.object({ index: z.number().int(), weight: z.number(), kind: z.literal("input"), numeric: z.boolean() }),
z.object({ index: z.number().int(), weight: z.number(), kind: z.literal("select"),
options: z.array(z.object({ id: z.number().int(), label: z.string() })) }),
])),
});
text, number and regex blanks all collapse to kind:"input" (numeric only drives inputmode="decimal") —
so a student cannot tell a regex blank from a plain one, and no pattern/tolerance/answer leaks. select options are
shuffled with streamSeed(seed, itemId, "options:" + index) when view.shuffle && config.shuffleOptions; id is
the canonical index.
Grading (packages/domain/src/cloze.ts#gradeCloze):
for each blank b: ok(b) = match(given[b.index], b) // reuses the short.ts normalisation for text/number/regex
earned = Σ weight(b) where ok(b)
total = Σ weight(b)
fraction = total > 0 ? earned / total : 0
points = round2(fraction * itemPoints)
select: ok iff Number(given) ∈ b.correct. text: normalised equality against any of answers, case folded
unless config.caseSensitive. State: validated.
Details: { perBlank: [{ index, weight, kind, ok, given, expected }], earned, total, fraction } —
expected is present only in the teacher-facing details (the student results endpoint strips it unless the
feedback policy reveals the key, see §4.6).
Example:
configVersion: 1
caseSensitive: false
text: |
La loi de {{Newton|newton}} lie force, masse et accélération : **F = m·a**.
Complétez la boucle :
```c
for (int i = 0; i < {{#10:0}}; i++) {
total {{+=|+ =}} tab[i];
}
```
L'unité SI de la force est le {{=newton|joule|watt|pascal}}, de symbole {{2*/^N$/}}.
2.4 code (docs/04 §4.7)¶
export const CodeConfig = z.object({
configVersion: z.literal(1),
prompt: z.string().min(1).max(20_000),
language: z.enum(["c", "cpp", "python", "js", "rust"]),
template: z.string().max(40_000).default(""),
files: z.array(z.object({ name: z.string().regex(/^[\w.-]{1,40}$/), content: z.string().max(64_000) })).max(4).default([]),
action: z.enum(["check", "run"]).default("run"),
compileArgs: z.string().max(400).default(""),
limits: z.object({
timeMs: z.number().int().min(100).max(10_000).default(2000),
memoryMb: z.number().int().min(16).max(512).default(128),
outputKb: z.number().int().min(1).max(256).default(64),
}).default({}),
runsPerMinute: z.number().int().min(1).max(30).default(10),
allOrNothing: z.boolean().default(false),
tests: z.object({
mode: z.literal("io"), // "tap" is phase 3
compare: z.object({
trimTrailing: z.boolean().default(true),
ignoreCase: z.boolean().default(false),
numeric: z.object({ epsilon: z.number().min(0), mode: z.enum(["abs", "rel"]) }).nullable().default(null),
}).default({}),
cases: z.array(z.object({
name: z.string().min(1).max(60),
stdin: z.string().max(16_000).default(""),
expected: z.string().max(16_000),
visible: z.boolean().default(false),
points: z.number().min(0).max(100).default(1),
})).min(1).max(30),
}),
});
export const CodeAnswer = z.object({
/** One entry per EDITABLE region of the template, in order. */
regions: z.array(z.string().max(20_000)).max(20),
lastRun: z.object({
at: z.iso.datetime(), requestId: z.uuid(),
compileOk: z.boolean(), passed: z.number().int(), total: z.number().int(),
}).nullable().optional(),
});
Lock regions. packages/domain/src/lockedTemplate.ts:
export interface TemplateSegment { kind: "locked" | "editable"; index: number | null; text: string }
/** A line is a marker iff, once leading whitespace and the language's line-comment prefix
* (`//`, `#`, `--`) are stripped, it equals `@@lock` or `@@endlock`. Markers are part of
* the locked text (they are comments and must survive into the compiled source). */
export function splitTemplate(template: string, language: CodeLanguage): TemplateSegment[];
/** Rebuilds the source. Throws if regions.length !== editable count. NEVER accepts client text
* for a locked segment (docs/04 §4.7). */
export function assembleSource(template: string, language: CodeLanguage, regions: string[]): string;
readOnly decorations).
Student view:
export const CodeStudent = z.object({
prompt: z.string(),
language: z.enum(["c","cpp","python","js","rust"]),
segments: z.array(z.object({ kind: z.enum(["locked","editable"]), index: z.number().int().nullable(), text: z.string() })),
limits: z.object({ timeMs: z.number(), memoryMb: z.number(), outputKb: z.number() }),
runsPerMinute: z.number().int(),
/** VISIBLE cases only, with stdin and expected. */
visibleCases: z.array(z.object({ name: z.string(), stdin: z.string(), expected: z.string(), points: z.number() })),
/** Hidden cases exist but stay opaque during the attempt (docs/06 Q8). */
hiddenCount: z.number().int(),
hiddenPoints: z.number(),
filesPreview: z.array(z.object({ name: z.string(), bytes: z.number().int() })),
allOrNothing: z.boolean(),
});
stdin/expected, compare, compileArgs, files[].content, action.
(files[].content is dropped because a data file may contain the answer; the runner injects it server-side.
Exposing filesPreview lets the player say "data.csv is available".)
Grading, two phases:
grade(config, answer, ctx): GradeResult {
if (!answer || answer.regions.every(r => r.trim() === "")) return { kind:"graded", points:0, maxPoints:ctx.itemPoints, details:{ empty:true, … }, state:"validated" };
const source = assembleSource(config.template, config.language, answer.regions);
return { kind: "pending", via: "runner", request: {
language: config.language,
files: [{ name: mainFileName(config.language), content: source }, ...config.files],
compileArgs: config.compileArgs, action: "run", limits: config.limits,
cases: config.tests.cases.map(c => ({ name: c.name, stdin: c.stdin })),
priority: "grading",
}};
}
finalizeRunner(config, answer, ctx, outcome): GradedResult {
if (!outcome.compile.ok) return zero with details.compile;
const per = config.tests.cases.map((c, i) => ({ …, ok: compareOutput(c.expected, outcome.cases[i].stdout, config.tests.compare) && outcome.cases[i].exitCode === 0 && !timedOut && !oom }));
const earned = per.filter(p => p.ok).reduce((s,p) => s + p.points, 0);
const total = config.tests.cases.reduce((s,c) => s + c.points, 0);
const fraction = config.allOrNothing ? (earned === total ? 1 : 0) : (total ? earned / total : 0);
return { kind:"graded", points: round2(fraction * ctx.itemPoints), maxPoints: ctx.itemPoints, details: { compile, cases: per, earned, total }, state:"validated" };
}
compareOutput (packages/domain/src/compareOutput.ts), applied in order:
1. CRLF → LF on both sides.
2. trimTrailing: strip trailing spaces/tabs of every line, then strip trailing newlines of the whole string.
3. ignoreCase: toLowerCase() both sides.
4. numeric !== null: split both on /\s+/; unequal token count ⇒ false; token pairs that both parse as finite
numbers compare with |a-b| ≤ eps (abs) or |a-b| ≤ eps·|b| (rel); other pairs compare as strings.
5. otherwise strict ===.
Details shape:
export const CodeDetails = z.object({
runner: z.enum(["ok", "unavailable", "busy", "error"]),
compile: z.object({ ok: z.boolean(), stderr: z.string().max(4000), ms: z.number() }).nullable(),
cases: z.array(z.object({
name: z.string(), visible: z.boolean(), points: z.number(), ok: z.boolean(),
exitCode: z.number().nullable(), ms: z.number(), timedOut: z.boolean(), oom: z.boolean(),
/** Present only for visible cases (or for the teacher). */
expected: z.string().optional(), actual: z.string().optional(), stderr: z.string().optional(),
})),
earned: z.number(), total: z.number(), sourceSha256: z.string().length(64),
});
Runner unavailable path (MVP default). RunnerUnavailable in the grading.runner job ⇒ write
{ source:"auto", state:"proposed", points:0, details:{ runner:"unavailable", … }, comment:"runner_unavailable" }.
The grading panel lists these as "to grade by hand" with the assembled source visible and a points field.
Nothing blocks; the evaluation can still be released.
Example config:
configVersion: 1
prompt: "Écrivez la somme des éléments du tableau lu sur stdin."
language: c
template: |
#include <stdio.h>
// @@lock
int main(void) {
int n; if (scanf("%d", &n) != 1) return 1;
int tab[128]; for (int i = 0; i < n; i++) scanf("%d", &tab[i]);
// @@endlock
int total = 0;
// votre code ici
printf("%d\n", total);
// @@lock
return 0;
}
// @@endlock
action: run
compileArgs: "-Wall -Wextra -std=c17"
limits: { timeMs: 2000, memoryMb: 128, outputKb: 64 }
runsPerMinute: 10
allOrNothing: false
tests:
mode: io
compare: { trimTrailing: true, ignoreCase: false, numeric: null }
cases:
- { name: "trois éléments", stdin: "3\n1 2 3\n", expected: "6\n", visible: true, points: 1 }
- { name: "tableau vide", stdin: "0\n", expected: "0\n", visible: true, points: 1 }
- { name: "négatifs", stdin: "4\n-1 -2 3 5\n", expected: "5\n", visible: false, points: 1 }
2.5 The toStudent safety test (docs/05 §5.7, N-SEC-04) — mandatory in every qt package¶
// packages/qt-*/src/toStudent.test.ts
const FORBIDDEN_KEYS = ["correct", "matchers", "answers", "expected", "pattern", "value", "tolerance",
"policy", "penalty", "compare", "compileArgs", "explanation", "internalName", "tags", "difficulty", "rubric"];
it("leaks no key", () => {
const out = JSON.stringify(type.toStudent(FULL_FIXTURE, { seed: 7, itemId: "i", shuffle: true }));
for (const k of FORBIDDEN_KEYS) expect(out).not.toContain(`"${k}"`);
for (const secret of SECRET_VALUES) expect(out).not.toContain(secret); // e.g. "0x1004", "Newton", hidden stdin
});
apps/api/src/modules/live/studentView.test.ts iterating the registry.
3. Drizzle schema, per module (apps/api/src/db/<module>.ts, re-exported by db/schema.ts)¶
Common columns omitted below: id uuid pk (uuid v7, app-generated), created_at timestamptz not null default now(),
updated_at timestamptz where the row mutates. All timestamps timestamptz, all times UTC.
3.1 Inherited from heig-classroom — keep, prune, extend¶
| Table | Status | Change for quiz |
|---|---|---|
users |
exists | keep id, oidc_sub, email, display_name/given_name/family_name, role(student\|teacher\|admin), locale, date_format, last_login_at, anonymized_at; add theme text; drop github_user_id, github_login, github_linked_at, email_prefs |
user_emails |
exists | keep as-is (user_id, email unique, source login\|idp\|roster) |
user_idp_claims |
exists | keep as-is |
sessions |
exists | keep as-is (sid_hash char(64) pk) |
audit_log |
exists | keep; replace the action catalogue with the quiz one (§3.6) |
avatars |
exists | keep as-is |
classrooms |
exists | rework: course_id replaces teacher_id/org_login; add period text not null, join_code text unique, join_code_enabled bool, archived_at |
enrollments |
exists | keep classroom_id, user_id, email, status(pending\|claimed), claimed_at, conflict_flag; add time_bonus_percent int not null default 0, note text |
courses, course_staff |
exists (as classroom_staff) |
course_staff is classroom_staff renamed and re-pointed at courses; courses is new but trivially the old classroom head |
organizations, assignments, student_repos, grade_runs, webhooks, scheduled_tasks, milestones |
delete | GitHub-specific |
3.2 db/pool.ts¶
| Table | Columns | Constraints / indexes |
|---|---|---|
pools |
name text nn, visibility text nn default 'private' {private,shared,public}, owner_id uuid → users nn, is_personal bool nn default false |
idx pools(owner_id); partial unique (owner_id) where is_personal |
categories |
pool_id uuid → pools cascade nn, parent_id uuid → categories cascade, name text nn, position int nn default 0 |
idx categories(pool_id, parent_id, position) |
questions |
pool_id nn, category_id uuid → categories set null, type text nn, internal_name text nn, difficulty smallint nn default 2 check 1..5, shuffleable bool nn default true, randomizable bool nn default false, created_by uuid → users, origin_question_id uuid → questions, deleted_at timestamptz |
unique (pool_id, lower(internal_name)) where deleted_at is null; idx questions(pool_id, type); idx questions(category_id) |
question_tags |
question_id uuid → questions cascade, tag text nn |
pk (question_id, tag); idx question_tags(tag) |
question_versions |
question_id nn, number int (null = draft), config jsonb nn, config_version int nn, explanation text nn default '', search tsvector generated, published_at, published_by uuid → users, change_note text, deprecated_at, deprecation_note text |
unique (question_id, number); partial unique (question_id) where number is null; idx (question_id, number desc); GIN search |
assets |
owner_id uuid → users nn, pool_id uuid → pools, sha256 char(64) nn, mime text nn, bytes int nn, width int, height int, path text nn |
unique (sha256); idx assets(pool_id) |
question_version_assets |
version_id uuid → question_versions cascade, asset_id uuid → assets |
pk (version_id, asset_id) |
course_pools |
course_id, pool_id |
pk (course_id, pool_id) |
pool_members |
phase 2 — create the table now, no routes | pk (pool_id, user_id) |
search is a generated column: to_tsvector('simple', coalesce(internal_name,'') || ' ' || coalesce(search_text,''))
where search_text text is a plain column the service fills with type.searchText(config) on every draft save and
publication (Postgres cannot call TS). So: add search_text text nn default '' and generate search from
search_text + the joined internal_name via a trigger-free GENERATED ALWAYS AS (to_tsvector('simple', search_text)) STORED.
3.3 db/evaluation.ts¶
| Table | Columns | Constraints / indexes |
|---|---|---|
evaluations |
classroom_id uuid → classrooms cascade nn, title text nn, mode text nn {exam,exercise,poll}, state text nn default 'draft' {draft,scheduled,lobby,running,paused,closed,grading,released}, settings jsonb nn, grading_scale jsonb nn, feedback_policy jsonb nn, opens_at, closes_at, duration_s int, access_code text, ip_allowlist text[], started_at, paused_at, closed_at, released_at, released_grades jsonb, modified_after_release bool nn default false, created_by uuid → users |
idx evaluations(classroom_id, state); partial idx (state) where state in ('lobby','running','paused') |
evaluation_items |
evaluation_id cascade nn, position int nn, question_version_id uuid → question_versions nn, points numeric(6,2) nn, milestone bool nn default false |
unique (evaluation_id, position) deferrable initially deferred (reorder in one UPDATE); idx (question_version_id) |
settings jsonb, validated by EvaluationSettings in contracts:
export const EvaluationSettings = z.object({
navigation: z.enum(["free", "forward_only", "milestones"]).default("free"),
presentation: z.enum(["zen", "continuous", "student_choice"]).default("zen"),
lobby: z.enum(["skip", "auto", "manual"]).default("manual"),
shuffleItems: z.boolean().default(false),
shuffleChoices: z.boolean().default(true),
timing: z.enum(["duration", "deadline", "manual"]).default("duration"),
showProgressBar: z.boolean().default(true),
logVisibility: z.boolean().default(true),
requireFullscreen: z.boolean().default(false),
});
// Linear only since ADR-052: the `threshold` kind was replaced by bonus items.
export const GradingScale = z.object({
kind: z.literal("linear"),
rounding: z.enum(["nearest","up","down"]).default("nearest"),
});
export const FeedbackPolicy = z.object({
when: z.enum(["none", "on_release", "immediate"]).default("on_release"),
showAnswer: z.boolean().default(true), // the student's own answer
showKey: z.boolean().default(false), // the solution
showExplanation: z.boolean().default(false),
showHiddenCaseNames: z.boolean().default(true), // docs/06 Q8
showTeacherComment: z.boolean().default(true),
});
3.4 db/live.ts¶
| Table | Columns | Constraints / indexes |
|---|---|---|
attempts |
evaluation_id cascade nn, user_id uuid → users nn, state text nn default 'not_started' {not_started,in_progress,submitted,expired}, seed int nn, started_at, deadline_at, bonus_s int nn default 0, extra_s int nn default 0, submitted_at, closed_at, closed_by text {server,student,teacher}, last_item_id uuid, present_at timestamptz |
unique (evaluation_id, user_id); partial idx (deadline_at) where state='in_progress' (ticker); idx (evaluation_id) |
answers |
attempt_id cascade nn, item_id uuid → evaluation_items cascade nn, payload jsonb nn, revision int nn default 0, marked_done bool nn default false, first_seen_at, updated_at nn |
unique (attempt_id, item_id); idx answers(attempt_id); idx answers(item_id) |
attempt_events |
attempt_id cascade nn, kind text nn {visibility,focus,ip_change,reconnect,time_added,paused,resumed,run}, at timestamptz nn default now(), details jsonb |
idx (attempt_id, at); retention: pruned with the classroom |
run_requests |
attempt_id, item_id, user_id, requested_at, finished_at, ok bool, ms int, error text |
rate limit (F-EVAL/N-SEC-07) + admin metrics; idx (user_id, requested_at desc) |
guest_participants |
phase 2 — not created in MVP |
3.5 db/grading.ts + db/results.ts¶
| Table | Columns | Constraints / indexes |
|---|---|---|
gradings |
answer_id uuid → answers cascade nn, item_id uuid nn (denormalised for the by-question query), points numeric(6,2) nn, max_points numeric(6,2) nn, source text nn {auto,llm,manual}, state text nn {proposed,validated,superseded}, details jsonb, confidence text {low,medium,high}, comment text, graded_by uuid → users, graded_at nn default now(), supersedes_id uuid → gradings, regrade_note text |
partial unique (answer_id) where state='validated'; idx (answer_id); idx (item_id) where state='validated' |
answer_flags |
phase 2 — table created, no routes | |
llm_calls |
phase 2 — not created in MVP | |
settings |
key text pk, value jsonb nn |
global parameters (F-ADMIN-03 stub) |
Note: attempts has no grade column. Grades are recomputed from validated gradings and frozen into
evaluations.released_grades at release (invariant 5).
released_grades jsonb shape:
z.object({
releasedAt: z.iso.datetime(),
totalPoints: z.number(),
scale: GradingScale,
rows: z.array(z.object({ attemptId: z.uuid(), userId: z.uuid(), points: z.number(), grade: z.number(), perItem: z.record(z.string(), z.number()) })),
})
3.6 audit_log action catalogue (closed TS union)¶
classroom.delete, classroom.archive, roster.import, roster.remove, question.publish, question.delete,
question.restore_version, question.deprecate, evaluation.start, evaluation.pause, evaluation.resume,
evaluation.extend, evaluation.close, evaluation.delete, grading.override, grading.regrade,
results.release, results.rerelease, user.role_change.
3.7 Migrations¶
Drizzle SQL migrations in apps/api/drizzle/, applied at boot (N-OPS-04), additive only.
MVP is delivered as two migrations: 0001_base (pruned heig-classroom tables + org) and 0002_quiz
(pool, evaluation, live, grading). Tests use PGlite (apps/api/src/test/db.ts, inherited).
4. packages/contracts — HTTP routes and SSE grammar¶
Layout: src/{common,org,pool,evaluation,live,grading,results,realtime,admin}.ts, all re-exported by index.ts.
Every route is declared as a triple { params, body|query, response } of zod schemas; the Fastify handler parses with
the same object the web client uses to type its call.
Conventions:
- Base path /api/v1. Auth by session cookie (__Host-quiz_sid) + double-submit CSRF header x-csrf-token
on non-GET (inherited). Bearer API tokens: phase 2, same surface.
- Auth column: T = teacher staff of the course owning the resource (or admin); S = student enrolled and
owner of the attempt; A = admin; Any = any session.
- Errors: { error: string, message?: string, details?: unknown }.
403 forbidden, 404 not_found (indistinguishable from "no access", per guards.ts motif), 409 conflict,
410 attempt_closed, 422 validation_failed, 429 rate_limited.
4.1 org¶
| Method | Path | Auth | Body / query | Response |
|---|---|---|---|---|
| GET | /me |
Any | — | Me { id, displayName, email, role, locale, theme, dateFormat } |
| PATCH | /me |
Any | { locale?, theme?, dateFormat? } |
Me |
| GET | /courses |
T | — | CourseSummary[] { id, name, code, classroomCount, poolIds } |
| POST | /courses |
T | { name, code } |
Course |
| GET | /courses/:courseId |
T | — | CourseDetail { course, staff[], pools[], classrooms[] } |
| PATCH | /courses/:courseId |
T | { name?, code? } |
Course |
| POST | /courses/:courseId/staff |
T | { email } |
Staff[] |
| DELETE | /courses/:courseId/staff/:userId |
T | — | 204 |
| PUT | /courses/:courseId/pools |
T | { poolIds: uuid[] } |
Pool[] |
| POST | /courses/:courseId/classrooms |
T | { name, period } |
Classroom |
| GET | /classrooms/:id |
T | — | ClassroomDetail { classroom, course, counts, evaluations: EvaluationSummary[] } |
| PATCH | /classrooms/:id |
T | { name?, period?, joinCodeEnabled? } |
Classroom |
| POST | /classrooms/:id/archive |
T | { archived: boolean } |
Classroom |
| DELETE | /classrooms/:id |
T | { confirmName: string } |
204 (F-ORG-09; audited) |
| GET | /classrooms/:id/roster |
T | — | RosterRow[] { enrollmentId, userId?, name, email, status, timeBonusPercent, note, conflictFlag } |
| POST | /classrooms/:id/roster/import |
T | text/csv body |
{ created, merged, errors: RosterError[] } |
| PATCH | /classrooms/:id/roster/:enrollmentId |
T | { timeBonusPercent?, note? } |
RosterRow |
| DELETE | /classrooms/:id/roster/:enrollmentId |
T | — | 204 (attempts preserved) |
| POST | /classrooms/join |
Any | { code } |
{ classroomId } (F-ORG-06) |
Reuses packages/domain/src/roster.ts verbatim + a timeBonusPercent column in the permissive header detection.
4.2 pool¶
| Method | Path | Auth | Body / query | Response |
|---|---|---|---|---|
| GET | /pools |
T | — | PoolSummary[] { id, name, visibility, questionCount } |
| POST | /pools |
T | { name } |
Pool |
| GET | /pools/:id |
T | — | PoolDetail { pool, categories: CategoryNode[], tags: string[] } |
| POST | /pools/:id/categories |
T | { name, parentId? } |
Category |
| PATCH | /categories/:id |
T | { name?, parentId?, position? } |
Category |
| DELETE | /categories/:id |
T | — | 204 (questions move to root) |
| GET | /pools/:id/questions |
T | ?q&type[]&tag[]&difficulty[]&categoryId&limit&cursor |
{ items: QuestionRow[], nextCursor } |
| POST | /pools/:id/questions |
T | { type, internalName, categoryId? } |
QuestionDetail (draft pre-filled by emptyDraft()) |
| GET | /questions/:id |
T | — | QuestionDetail { meta, draft: { config, explanation, configVersion, updatedAt }, versions: VersionRow[], latestPublished } |
| PATCH | /questions/:id |
T | { internalName?, categoryId?, difficulty?, tags?, shuffleable? } |
QuestionMeta |
| PUT | /questions/:id/draft |
T | { config, explanation } |
{ updatedAt, valid: boolean, issues: ZodIssueLite[] } — autosave; invalid drafts are STORED, not rejected (F-QST-02) |
| POST | /questions/:id/publish |
T | { changeNote? } |
VersionRow — full configSchema.parse here; 422 with issues |
| GET | /questions/:id/versions/:number |
T | — | { config, explanation, configVersion, publishedAt, changeNote } |
| POST | /questions/:id/versions/:number/restore |
T | — | QuestionDetail (copies into draft) |
| POST | /questions/:id/versions/:number/deprecate |
T | { note } |
VersionRow |
| DELETE | /questions/:id |
T | — | 204 soft; 409 in_use on ?hard=1 if referenced |
| POST | /questions/:id/copy |
T | { targetPoolId, categoryId? } |
QuestionDetail |
| POST | /questions/:id/preview |
T | { source: "draft" \| number } |
{ student: unknown, itemPoints: number } — teacher preview, seed: 0 |
| POST | /questions/:id/try |
T | { source, answer } |
{ points, maxPoints, details, solution } — grades in-process with the stub-aware runner; never persisted (F-QST-09) |
| POST | /pools/:id/assets |
T | multipart, ≤ 5 MB, image/png\|jpeg\|gif\|webp\|svg+xml |
{ id, url: "/assets/:id", width, height } |
| GET | /assets/:id |
Any authorised to see the containing content | — | bytes, cache-control: private, max-age=31536000, immutable |
QuestionRow = { id, type, internalName, difficulty, tags, categoryId, latestNumber, hasDraftChanges, updatedAt, deprecated }.
4.3 evaluation¶
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| GET | /classrooms/:id/evaluations |
T | — | EvaluationSummary[] |
| POST | /classrooms/:id/evaluations |
T | { title, mode, preset? } |
Evaluation |
| GET | /evaluations/:id |
T | — | EvaluationDetail { evaluation, items: ItemRow[], totalPoints, staleItems: uuid[], attemptCount } |
| PATCH | /evaluations/:id |
T | { title?, settings?, gradingScale?, feedbackPolicy?, opensAt?, closesAt?, durationS?, accessCode?, ipAllowlist? } |
EvaluationDetail — 409 locked if any attempt exists and the field is structural |
| DELETE | /evaluations/:id |
T | { confirmTitle } |
204 |
| POST | /evaluations/:id/items |
T | { questionIds: uuid[] } |
ItemRow[] — freezes each question's current published version; 422 no_published_version; 409 locked / 409 items_frozen (below) |
| PATCH | /evaluations/:id/items/:itemId |
T | { points?, milestone? } |
ItemRow — 409 locked / 409 items_frozen |
| PUT | /evaluations/:id/items/order |
T | { itemIds: uuid[] } |
ItemRow[] — 409 locked / 409 items_frozen |
| DELETE | /evaluations/:id/items/:itemId |
T | — | 204 — 409 locked / 409 items_frozen |
| POST | /evaluations/:id/items/update-versions |
T | { itemIds?: uuid[] } (omit = all) |
ItemRow[] — 409 attempts_exist (F-EVAL-03) / 409 items_frozen |
| POST | /evaluations/:id/duplicate |
T | { classroomId?, title } |
Evaluation |
| POST | /evaluations/:id/preview |
T | — | AttemptView with fake attempt (seed: 0, read-only) |
| POST | /evaluations/:id/state |
T | { to: "draft"\|"scheduled"\|"lobby" } |
Evaluation — the operational transitions are in live below |
The item list (the five /items routes) is editable only in draft and scheduled with no attempt (@quiz/domain itemListLock, issue #79): an attempt answers 409 locked (attempts_exist for update-versions), and an evaluation opened to students (lobby and later) that nobody has entered yet answers 409 items_frozen.
ItemRow = { id, position, points, milestone, questionId, type, internalName, versionNumber, latestVersionNumber, deprecated }.
4.4 live¶
Teacher side:
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| GET | /evaluations/:id/dashboard |
T | ?includeAnswers=0\|1 |
DashboardView (below) |
| POST | /evaluations/:id/start |
T | { confirm: true } |
Evaluation — lobby\|scheduled → running |
| POST | /evaluations/:id/pause |
T | — | Evaluation |
| POST | /evaluations/:id/resume |
T | — | Evaluation |
| POST | /evaluations/:id/close |
T | — | Evaluation — closes open attempts, enqueues grading |
| POST | /evaluations/:id/extend |
T | { minutes: 1\|5\|10, scope: "all"\|"attempt", attemptId? } |
{ updated: number } (F-LIVE-11/12) |
| GET | /evaluations/:id/attempts/:attemptId |
T | — | AttemptInspect { student, items: [{ item, studentConfig, answer, grading, solution }] } (F-DASH-05) |
| GET | /evaluations/:id/raw-export |
T | — | application/json full dump (N-RES-06) |
export const DashboardView = z.object({
evaluation: z.object({ id, state, startedAt, pausedAt, closesAt, serverNow }),
items: z.array(z.object({ id, position, points, type, internalName })),
rows: z.array(z.object({
attemptId: z.uuid().nullable(), userId: z.uuid(), displayName: z.string(),
/** Stable per-row pseudonym when names are hidden (F-DASH-02). */
pseudonym: z.string(),
state: z.enum(["not_started","in_progress","submitted","expired"]),
online: z.boolean(), lastSeenAt: z.iso.datetime().nullable(),
deadlineAt: z.iso.datetime().nullable(), timeBonusPercent: z.number().int(),
points: z.number().nullable(), maxPoints: z.number(),
cells: z.array(z.object({
itemId: z.uuid(),
status: z.enum(["empty","seen","in_progress","done"]),
verdict: z.enum(["correct","partial","wrong","pending"]).nullable(),
points: z.number().nullable(), revision: z.number().int(),
summary: z.string().nullable(), // only when includeAnswers=1
})),
})),
totals: z.array(z.object({ itemId: z.uuid(), completion: z.number(), successRate: z.number().nullable() })),
});
pseudonym = deterministic adjective+animal from hashSeed(evaluationId, userId) — stable across reloads.
Student side:
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| GET | /student/home |
Any | — | StudentHome { open: EvaluationCard[], upcoming: [], results: ResultCard[] } |
| POST | /evaluations/:id/attempt |
S | { accessCode? } |
AttemptView \| LobbyView — idempotent INSERT … ON CONFLICT DO NOTHING; 403 access_code_invalid, 403 ip_not_allowed, 409 not_open |
| GET | /attempts/:id |
S | — | AttemptView |
| PUT | /attempts/:id/answers/:itemId |
S | AutosaveRequest |
AutosaveResponse — see §4.7 |
| POST | /attempts/:id/answers/:itemId/done |
S | { done: boolean } |
{ done, nextItemId? } — 409 irreversible in forward_only |
| POST | /attempts/:id/position |
S | { itemId } |
204 |
| POST | /attempts/:id/submit |
S | { confirm: true } |
{ state: "submitted", submittedAt } |
| POST | /attempts/:id/events |
S | { kind, details? } |
204 — rate-limited to 60/min (F-EVAL-13 logging) |
| POST | /attempts/:id/run |
S | { itemId, regions: string[], stdin?: string } |
202 { requestId } — result arrives via SSE runner.result; 429 rate_limited per runsPerMinute; 503 runner_unavailable |
export const AttemptView = z.object({
attempt: z.object({ id, state, startedAt, deadlineAt: z.iso.datetime().nullable(), lastItemId: z.uuid().nullable(), serverNow }),
evaluation: z.object({ id, title, mode, state, settings: EvaluationSettings, pausedAt: z.iso.datetime().nullable(), totalPoints }),
items: z.array(z.object({
id: z.uuid(), position: z.number().int(), points: z.number(), type: z.string(), milestone: z.boolean(),
/** Output of type.toStudent — opaque to the core contracts. */
student: z.unknown(),
answer: z.unknown().nullable(), revision: z.number().int(), markedDone: z.boolean(),
})),
});
export const LobbyView = z.object({
evaluation: z.object({ id, title, state, announcedDurationS: z.number().int().nullable() }),
navigation: Navigation, // settings.navigation: the lobby's first rule (§6.3)
present: z.number().int(), enrolled: z.number().int(),
timeBonusPercent: z.number().int(), serverNow: z.iso.datetime(),
});
items is shuffle(items, streamSeed(attempt.seed, evaluationId, "items")) when
settings.shuffleItems; position stays the canonical position for grading and CSV export.
4.5 grading¶
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| POST | /evaluations/:id/grading/run |
T | { itemIds?: uuid[] } |
202 { jobId } — singleton per evaluation |
| GET | /evaluations/:id/grading/progress |
T | — | { done, total, pending: { runner, llm }, failed } |
| GET | /evaluations/:id/grading |
T | ?itemId&state&anonymous=1 (by question only, ADR-044) |
GradingQueue |
| POST | /answers/:answerId/gradings |
T | { points, comment: string (required), details? } |
Grading — manual, born validated, supersedes the previous (F-GRADE-05) |
| POST | /gradings/:id/validate |
T | { points?, comment? } |
Grading |
| POST | /evaluations/:id/grading/validate-batch |
T | { itemId?, state?: "proposed", confidence?: "high" } |
{ validated: number } |
| POST | /evaluations/:id/items/:itemId/regrade |
T | { note, toVersionNumber? } |
202 { jobId } (F-GRADE-06) |
| POST | /evaluations/:id/release |
T | { confirm: true } |
{ releasedAt, rows: number } |
export const GradingQueue = z.object({
order: z.enum(["question", "student"]),
items: z.array(z.object({ id, position, internalName, type, points })),
entries: z.array(z.object({
answerId: z.uuid(), attemptId: z.uuid(), itemId: z.uuid(),
label: z.string(), // pseudonym when anonymous=1, else display name
answer: z.unknown().nullable(), student: z.unknown(), solution: z.unknown(),
grading: z.object({ id, points, maxPoints, source, state, details, comment, confidence, gradedAt, regradeNote }).nullable(),
history: z.array(z.object({ id, points, source, state, gradedAt, comment })),
})),
counts: z.object({ total: z.number(), validated: z.number(), proposed: z.number(), missing: z.number() }),
});
4.6 results¶
| Method | Path | Auth | Query | Response |
|---|---|---|---|---|
| GET | /evaluations/:id/results |
T | — | ResultsView { rows, stats, totalPoints, scale, released } |
| GET | /evaluations/:id/results.csv |
T | — | text/csv; charset=utf-8 with BOM, ; separator (F-RES-02) |
| GET | /evaluations/:id/results/by-question |
T | — | ByQuestion[] { item, student, solution, explanation, distribution, successRate, avgMs } |
| GET | /student/results |
Any | — | ResultCard[] |
| GET | /attempts/:id/feedback |
S | — | StudentFeedback = StudentResults \| FeedbackPending (deviations W6-8, W6-9) |
export const ResultsView = z.object({
totalPoints: z.number(), scale: GradingScale, released: z.boolean(), modifiedAfterRelease: z.boolean(),
rows: z.array(z.object({ userId, displayName, email, attemptId: z.uuid().nullable(),
perItem: z.record(z.string(), z.number()), points: z.number(), grade: z.number(),
durationS: z.number().int().nullable(), state })),
stats: z.object({ count, mean, median, stdev, min, max,
histogram: z.array(z.object({ bucket: z.number(), count: z.number() })) }), // buckets of 0.5 grade
});
export const StudentResults = z.object({
evaluation: z.object({ id, title, releasedAt }),
points: z.number(), totalPoints: z.number(), grade: z.number(),
items: z.array(z.object({
position: z.number().int(), points: z.number().nullable(), maxPoints: z.number(),
student: z.unknown(),
answer: z.unknown().nullable(), // only if feedbackPolicy.showAnswer
solution: z.unknown().nullable(), // only if feedbackPolicy.showKey
explanation: z.string().nullable(), // only if feedbackPolicy.showExplanation
details: z.unknown().nullable(), // filtered: hidden case bodies removed
comment: z.string().nullable(), // only if feedbackPolicy.showTeacherComment
})),
});
studentResultsView() in modules/results/service.ts is the only place where the feedback policy is applied,
and it is the second half of the §5.7 content gate. details filtering for code: hidden cases keep
{ name, ok, points } and lose expected/actual/stderr unless showKey.
CSV columns: email;last_name;first_name;q1;q2;…;total;grade — headers from internal_name truncated to 30 chars;
numbers with . decimal separator; grade at one decimal.
4.7 Autosave protocol (docs/05 §5.4) and the 410 rule¶
export const AutosaveRequest = z.object({
payload: z.unknown(), // validated by type.answerSchema server-side
revision: z.number().int().min(1), // client-local monotonic counter
clientTs: z.iso.datetime(),
});
export const AutosaveResponse = z.object({
/** The revision now in the database. */
revision: z.number().int(),
/** Present ONLY when the write was rejected as stale: the client adopts it. */
payload: z.unknown().optional(),
accepted: z.boolean(),
serverNow: z.iso.datetime(),
});
export const AttemptClosed = z.object({
error: z.literal("attempt_closed"),
reason: z.enum(["deadline", "submitted", "evaluation_closed", "paused"]),
deadlineAt: z.iso.datetime().nullable(),
serverNow: z.iso.datetime(),
});
Server algorithm (modules/live/service.ts#saveAnswer), one statement, no read-modify-write:
INSERT INTO answers (id, attempt_id, item_id, payload, revision, first_seen_at, updated_at)
VALUES ($id, $attempt, $item, $payload, $revision, now(), now())
ON CONFLICT (attempt_id, item_id) DO UPDATE
SET payload = EXCLUDED.payload, revision = EXCLUDED.revision, updated_at = now()
WHERE answers.revision < EXCLUDED.revision
RETURNING payload, revision;
{ accepted:false, revision, payload }.
- Gate before the write, in this order:
1. attempt belongs to req.user — else 404.
2. attempt.state !== "in_progress" ⇒ 410 attempt_closed (submitted).
3. evaluation.state === "paused" ⇒ 410 attempt_closed reason paused (client greys the UI; writes resume on evaluation.state).
4. evaluation.state not in (running) ⇒ 410 reason evaluation_closed.
5. now() > attempt.deadline_at + interval '3 seconds' ⇒ 410 reason deadline (F-LIVE-07).
The 3 s grace is a constant in packages/domain/src/deadline.ts (GRACE_MS = 3000), shared with the ticker.
6. type.answerSchema.safeParse(payload) fails ⇒ 422 answer_invalid (never persists garbage).
- On success: publish dashboard.cell through the 250 ms coalescer, return { accepted:true, revision, serverNow }.
Client rules (apps/web/src/attempt/autosave.ts):
- debounce 300 ms per item; at most one in-flight request per item, the next waits with the latest payload;
- revision increments locally on every change, never resets;
- on network error: exponential backoff capped at 5 s, "offline" indicator after 3 s without ack, unacked payloads
kept in memory and replayed on reconnect (N-RES-02);
- on 410: stop all writes for that attempt, show "temps écoulé", switch to read-only;
- on accepted:false: adopt the server payload/revision for that item (last-writer-wins by revision).
4.8 SSE grammar (docs/05 §5.4) as zod¶
GET /events?watch=evaluation:<uuid> or ?watch=attempt:<uuid> (omitted = user topics only).
Server verifies authorisation on the requested subject, then implicitly subscribes to user:<id> and,
for staff, teacher:<id> + classroom:<id> for every accessible classroom.
Named SSE events (event: <name>\ndata: <json>), :ping comment every 25 s, plus a clock every 10 s
(every 1 s while the watched subject is an attempt of a running evaluation — docs/07 §7.3).
// packages/contracts/src/realtime.ts
export const Topic = z.union([
z.literal("admin"),
z.templateLiteral([ "classroom:", z.string() ]),
z.templateLiteral([ "teacher:", z.string() ]),
z.templateLiteral([ "user:", z.string() ]),
z.templateLiteral([ "evaluation:", z.string() ]),
z.templateLiteral([ "attempt:", z.string() ]),
]);
export const SnapshotEvent = z.object({ type: z.literal("snapshot"), serverNow: z.iso.datetime(),
subject: z.string(), state: z.unknown() }); // DashboardView | AttemptView | LobbyView
export const ClockEvent = z.object({ type: z.literal("clock"), serverNow: z.iso.datetime() });
export const EvaluationStateEvent = z.object({ type: z.literal("evaluation.state"),
evaluationId: z.uuid(), state: EvaluationState,
pausedAt: z.iso.datetime().nullable(), closesAt: z.iso.datetime().nullable(), serverNow: z.iso.datetime() });
export const AttemptDeadlineEvent = z.object({ type: z.literal("attempt.deadline"),
attemptId: z.uuid(), deadlineAt: z.iso.datetime(), bonusS: z.number().int(),
reason: z.enum(["teacher_extend","pause_resume","start"]) });
export const AttemptClosedEvent = z.object({ type: z.literal("attempt.closed"),
attemptId: z.uuid(), closedBy: z.enum(["server","student","teacher"]) });
export const DashboardCellEvent = z.object({ type: z.literal("dashboard.cell"),
evaluationId: z.uuid(), attemptId: z.uuid(), itemId: z.uuid(),
status: z.enum(["empty","seen","in_progress","done"]), revision: z.number().int(),
points: z.number().nullable(), summary: z.string().nullable() });
export const DashboardPresenceEvent = z.object({ type: z.literal("dashboard.presence"),
evaluationId: z.uuid(), userId: z.uuid(), online: z.boolean(), lastSeenAt: z.iso.datetime() });
export const LobbyCountEvent = z.object({ type: z.literal("lobby.count"),
evaluationId: z.uuid(), present: z.number().int(), enrolled: z.number().int() });
export const RunnerResultEvent = z.object({ type: z.literal("runner.result"),
requestId: z.uuid(), itemId: z.uuid(),
result: z.discriminatedUnion("status", [
z.object({ status: z.literal("ok"), compile: z.object({ ok: z.boolean(), stderr: z.string() }),
cases: z.array(z.object({ name: z.string(), ok: z.boolean(), stdout: z.string(),
expected: z.string(), ms: z.number(), timedOut: z.boolean() })) }),
z.object({ status: z.literal("unavailable") }),
z.object({ status: z.literal("busy") }),
z.object({ status: z.literal("error"), message: z.string() }),
]) });
export const GradingProgressEvent = z.object({ type: z.literal("grading.progress"),
evaluationId: z.uuid(), done: z.number().int(), total: z.number().int(),
phase: z.enum(["auto","runner","done"]) });
/** heig-classroom-style refresh hint, for everything that is not the live path. */
export const HintEvent = z.object({ type: z.literal("hint"),
topics: z.array(z.string()), kinds: z.array(z.enum(["pool","evaluations","roster","results","grading","admin"])),
notice: z.object({ kind: z.enum(["info","success","warning","error"]), message: z.string() }).nullish() });
export const ServerEvent = z.discriminatedUnion("type", [ SnapshotEvent, ClockEvent, EvaluationStateEvent,
AttemptDeadlineEvent, AttemptClosedEvent, DashboardCellEvent, DashboardPresenceEvent, LobbyCountEvent,
RunnerResultEvent, GradingProgressEvent, HintEvent ]);
export type ServerEvent = z.infer<typeof ServerEvent>;
Routing table (who receives what):
| Event | Published to | Coalescing |
|---|---|---|
snapshot |
the opening connection only | — |
clock |
every connection | 10 s (1 s on an attempt: subject of a running evaluation) |
evaluation.state |
evaluation:<id> |
none |
attempt.deadline, attempt.closed |
attempt:<id> |
none |
dashboard.cell |
evaluation:<id> staff connections only |
250 ms per (attemptId,itemId) |
dashboard.presence, lobby.count |
evaluation:<id> |
1 s |
runner.result |
user:<id> |
none |
grading.progress |
teacher:<id> |
1 s |
hint |
any topic | none |
Server-side filtering: a connection carries a Set<Topic> and a role flag; dashboard.* is dropped for a
student connection even if it watches the same evaluation:<id> topic (a student in the lobby legitimately watches
evaluation:<id> for lobby.count and evaluation.state).
4.9 admin¶
| Method | Path | Auth | Response |
|---|---|---|---|
| GET | /admin/users?q&role |
A | AdminUser[] { id, displayName, emails, role, lastLoginAt } |
| POST | /admin/users/:id/role |
A | { role } → AdminUser (audited) |
| GET | /admin/health |
A | { db: Check, runner: Check, disk: { freeBytes, totalBytes }, lastBackupAt } |
| GET | /admin/runner |
A | { ok, languages, queued, avgMs, errors24h, mode: "stub"\|"http" } (F-ADMIN-02) |
| GET | /admin/audit?limit&cursor&action&actorId |
A | { entries: AuditEntry[], nextCursor } |
5. State machines and the grading flow¶
5.1 Evaluation state machine¶
┌──────────────────────── teacher: reopen (no attempts) ───────┐
▼ │
draft ──schedule──▶ scheduled ──openLobby──▶ lobby ──start──▶ running ⇄ paused ─┘
│ │ │ │ │
└────start (skip)─────┴──────────────────────┴─────────────────┘ │
close │ close
▼ ▼
closed ──grade job──▶ grading ──all validated──▶ (closed, gradedAt set)
│
teacher release
▼
released
Stored as a single evaluations.state column. grading is entered automatically when the grading job starts and
left when it finishes; the UI shows closed + progress. graded from docs/01 §1.4 is not a stored state — it is
state = 'closed' AND all answers have a validated grading; see decision D6.
| Transition | Trigger | Preconditions | Side effects |
|---|---|---|---|
draft → scheduled |
teacher POST /state {to:"scheduled"} |
≥ 1 item, timing valid (exam needs durationS or closesAt, F-EVAL-04) |
items frozen against further version updates once an attempt exists |
scheduled → lobby |
teacher, or ticker at opensAt when settings.lobby !== "skip" |
— | students may join and see LobbyView; lobby.count starts |
lobby\|scheduled → running |
teacher POST /start (lobby: manual), or ticker (lobby: auto when present == enrolled, or lobby: skip at opensAt) |
— | started_at = now(); for each present attempt: state='in_progress', started_at, deadline_at computed; publish evaluation.state + attempt.deadline |
running → paused |
teacher POST /pause |
mode exam only |
paused_at = now(); autosave answers 410 paused; countdown frozen client-side |
paused → running |
teacher POST /resume |
— | every in_progress attempt gets deadline_at += (now − paused_at); publish attempt.deadline reason pause_resume |
* → closed |
teacher POST /close, or ticker at closes_at, or ticker when all attempts terminal |
— | remaining in_progress → expired, closed_by='server'; enqueue grading.evaluation |
closed → released |
teacher POST /release |
no proposed grading left (or forced with ?force=1) |
transaction: compute all grades, write released_grades + released_at, audit, hint to students |
released → released |
regrade after release | — | modified_after_release = true, released_grades recomputed |
5.2 Attempt state machine¶
not_started ──student opens/lobby join──▶ (row exists, state not_started)
not_started ──evaluation starts OR student enters a running evaluation──▶ in_progress
in_progress ──student POST /submit──▶ submitted (closed_by = 'student')
in_progress ──ticker: now > deadline + 3 s──▶ expired (closed_by = 'server')
in_progress ──teacher closes the evaluation──▶ expired (closed_by = 'teacher')
submitted / expired: terminal, both gradable (docs/01 §1.4)
deadline_at at the transition to in_progress (packages/domain/src/deadline.ts):
settings.timing |
deadline_at |
|---|---|
duration |
startedAt + durationS × (1 + bonusPercent/100) + extraS |
deadline |
closesAt + round(announcedWindow × bonusPercent/100) + extraS, announcedWindow = closesAt − opensAt − closesAtShiftS (#253) |
manual |
null (only the teacher closes) |
Latecomers in duration mode get the full duration (docs/06 Q3). extraS accumulates teacher extensions.
5.3 What the ticker does (1 s period, apps/api/src/ticker.ts, advisory lock, adapted from heig-classroom)¶
every 1000 ms, under pg_try_advisory_lock:
1. expire attempts:
UPDATE attempts SET state='expired', closed_at=now(), closed_by='server'
WHERE state='in_progress' AND deadline_at IS NOT NULL
AND deadline_at + interval '3 seconds' <= now()
RETURNING id, evaluation_id, user_id;
→ publish attempt.closed + dashboard.cell refresh
2. open scheduled evaluations: scheduled → lobby at opens_at (lobby != 'skip')
scheduled → running at opens_at (lobby == 'skip')
3. auto-start: lobby + settings.lobby == 'auto' + present == enrolled → running
4. auto-close: running|paused with closes_at <= now() → closed (+ enqueue grading)
running with every attempt terminal → closed (+ enqueue grading)
5. presence sweep: mark connections silent > 45 s offline, publish dashboard.presence
5.4 Grading job flow (docs/05 §5.6, adapted to the stub runner)¶
Queues (pg-boss): grading.evaluation (singleton key = evaluationId), grading.answer, grading.runner
(priority low, concurrency 2), export.pool.
POST /close or ticker auto-close
└─▶ enqueue grading.evaluation { evaluationId } (singletonKey = evaluationId)
grading.evaluation handler:
for each (attempt × item) of the evaluation, in batches of 200:
answer = answers row or null
if a validated non-superseded grading already exists → skip (idempotent)
if answer is null → write { source:'auto', state:'validated', points:0, details:{empty:true} } (F-GRADE-01)
else:
config = loadConfig(type, version.config, version.config_version)
r = await type.grade(config, answer.payload, ctx) // ctx.runner = the configured RunnerService
switch r.kind:
'graded' → insert grading (state = r.state ?? 'validated'), supersede any previous validated
'pending' via 'runner' → enqueue grading.runner { answerId, request }
'pending' via 'llm' → insert { source:'llm', state:'proposed', points:0,
comment:'llm_not_available' } // MVP has no LLM
publish grading.progress every 25 answers and at the end
grading.runner handler (concurrency 2, retryLimit 2, backoff 5 s):
try outcome = await runner.run(request) → finalizeRunner → insert validated grading
catch RunnerUnavailable → insert { source:'auto', state:'proposed', points:0,
details:{ runner:'unavailable' }, comment:'runner_unavailable' } (no retry)
catch RunnerBusy → throw (pg-boss retries with backoff)
catch other → after retries: insert { state:'proposed', points:0, details:{runner:'error'} }
Writing a grading is always the same transaction (modules/grading/service.ts#writeGrading):
BEGIN;
UPDATE gradings SET state='superseded' WHERE answer_id=$1 AND state='validated';
INSERT INTO gradings (…, supersedes_id = <the id just superseded>) VALUES (…);
COMMIT;
(answer_id) WHERE state='validated' makes a concurrent double-write fail loudly.
Regrade (POST /items/:itemId/regrade): optionally repoints evaluation_items.question_version_id to a newer
version, then enqueues grading.evaluation restricted to that item with regradeNote = "re-graded with version N"
stamped on every new grading; previous ones become superseded. If the evaluation was released,
modified_after_release = true and the release is recomputed.
Manual override always wins: source:'manual', state:'validated', comment mandatory (F-GRADE-05).
6. Web app¶
apps/web/src/ keeps the heig-classroom skeleton: main.tsx, App.tsx, Shell.tsx, Header.tsx, router.ts,
api.ts, i18n.tsx, theme.ts, notify.tsx, confirm.tsx, ui.tsx, style.css, mock/, scripts/screenshots.mjs.
New code lives in feature folders: pool/, evaluation/, live/, grading/, results/, student/, realtime/.
Superseded on 2026-09-23:
ui.tsxis nowui/{layers,controls,page,live,forms,state,combobox}.tsxbehind the./uibarrel (PRs #36, #41, #43, #47),i18n.tsxisi18n/{en.ts,fr.ts,index.tsx}(PR #56) andmock/index.tsis split by module (PR #24). The file names in §6 are those of the plan.
6.1 Routes (router.ts, extended union)¶
export type Route =
| { view: "home" } // role-dependent: TeacherHome | StudentHome
| { view: "courses" } | { view: "course"; courseId: string }
| { view: "classroom"; classroomId: string }
| { view: "pool"; poolId: string; questionId?: string }
| { view: "question"; questionId: string; tab: "edit" | "try" | "versions" }
| { view: "evaluation"; evaluationId: string; tab: "questions" | "settings" | "attempts" }
| { view: "live"; evaluationId: string }
| { view: "grading"; evaluationId: string }
| { view: "results"; evaluationId: string; tab: "students" | "questions" }
| { view: "attempt"; evaluationId: string } // student: lobby OR player, server decides
| { view: "studentResults"; attemptId: string }
| { view: "settings" } | { view: "admin" };
/courses/:id, /classrooms/:id, /pools/:id, /questions/:id, /evaluations/:id,
/evaluations/:id/live, /evaluations/:id/grading, /evaluations/:id/results, /take/:evaluationId,
/results/:attemptId, /settings, /admin.
6.2 Teacher screens¶
| Screen | Mockup | Main components | ui.tsx primitives |
|---|---|---|---|
TeacherHome / |
— (derived from heig-classroom TeacherHome.tsx) |
CourseCard, RecentEvaluations, QuickStart |
PageHeader, Card, Stat, EmptyState, Button |
CourseView /courses/:id |
— | ClassroomList, StaffSection, PoolLinks |
PageHeader, SectionHeading, T table, Menu, Sheet (add staff) |
ClassroomView /classrooms/:id |
— | RosterTable (reused), RosterImport (reused + bonus column), EvaluationList, NewEvaluationSheet |
Tabs, T+SortHeader, Sheet, useConfirm, Badge |
PoolView /pools/:id |
08-pool.html |
CategoryTree (left), QuestionTable (centre: name, type, difficulty 5-dot, tags, version, stats), FilterBar (q, type, tags, difficulty), QuestionSidePanel (right: prompt preview, versions, "Add to evaluation"), BulkBar (multi-select) |
SearchInput, Segmented, Badge, T+SortHeader, Menu, Sheet, Skeleton, EmptyState |
QuestionEditor /questions/:id |
01-editeur-qcm.html, 02-editeur-code.html |
EditorShell (header: internal name, type badge, autosave state, Publier primary; tabs Edit / Try / Versions), MarkdownField (prompt), lazy type.Editor, MetaPanel (tags, difficulty, category, shuffleable), PublishDialog (change note + validation issues), VersionHistory (list + JSON diff), TryPanel (lazy type.Player + type.Review on POST /try) |
PageHeader, Tabs, Field, Select, Switch, Segmented, Badge, Alert, Modal, Sheet, Kbd |
EvaluationConfig /evaluations/:id |
— (mockups 03/08 style) | 3-step layout per docs/08 §8.2: ItemsStep (ordered list, drag handle, points input, milestone toggle, "stale version" badge + update button, AddQuestionsSheet reusing QuestionTable), TimingStep (mode, timing, duration, opens/closes, lobby, navigation, presentation, shuffle — presets "Quiz noté 20 min" / "Exercice de la semaine"), AdvancedDisclosure (scale, feedback policy, access code, IP allowlist), LaunchStep (summary + Démarrer) |
Tabs/Segmented, Field, Select, Switch, SettingRow, Alert, Sheet, Stat |
LiveDashboard /evaluations/:id/live |
03-dashboard-live.html |
LiveHeader (title, countdown, Pause/Reprendre, +1/+5/+10 menu, Clôturer), ToggleBar (names n / answers r / results s — Switch), StudentGrid (sticky first column, one Cell per item: shape+icon+tint, never colour alone), TotalsRow, InspectModal (a modal, every answer of one student stacked via type.Review, prev/next student; replaced the in-flow InspectPanel), PresenceDot, Legend, FullscreenToggle |
PageHeader, Switch, Menu, Badge, T, Tip, useConfirm, Progress, Spinner |
GradingPanel /evaluations/:id/grading |
mockups/grading.html (ADR-044, superseding 04-correction.html) |
QuestionBar (question menu + ProgressSegments stepper), GradingToolbar (state / graded-by / confidence Segmented, Anonymise Switch, the single accent action: Validate N → Next question → Results), GradingTable (verdict glyph, the type's grading columns, points, row actions; the expected answer pinned first), AnswerPanel (a Sheet: type.Review, explanation, history, the inline adjustment form), RegradeSheet |
Segmented, Switch, Sheet, TableHead, Button, IconButton, Badge, Alert, Menu, ProgressSegments |
Results /evaluations/:id/results |
— | ResultsTabs, GradeTable (email, name, points, grade mono-tabular right-aligned, duration), StatsRow (Stat ×5), Histogram (svg, reuse charts.tsx), ExportButton (CSV), ByQuestionView (linear scroll: prompt, key, explanation, distribution bars, success rate) |
Tabs, T+SortHeader, Stat, Card, Button, EmptyState |
AdminPanel /admin |
— (reused) | UserTable + role menu, HealthCard, RunnerCard, AuditTable |
reused from heig-classroom |
6.3 Student screens¶
| Screen | Mockup | Main components | primitives |
|---|---|---|---|
StudentHome / |
05-etudiant-accueil.html |
OpenEvaluations (card with countdown + single primary Rejoindre/Commencer), UpcomingList, RecentResults (grade mono right-aligned), EmptyState per course |
PageHeader, Card, Badge, Button, EmptyState, Stat |
Lobby /take/:id (state lobby) |
06-etudiant-attente.html |
PresenceRing (SVG ring, the only living element), Instructions (3 fixed lines), TimeBonusBadge, ConnectionState, discreet Quitter link, no primary action |
new Ring component, Badge, LinkButton |
Player "zen" /take/:id (state running) |
07-etudiant-zen.html |
PlayerShell (top: title, Countdown, SyncState icon+word, ProgressSegments 4 states clickable when navigation allows), lazy type.Player, ItemFooter (Marquer comme faite primary, prev/next Alt+←/→), SubmitDialog, OfflineBanner, PausedOverlay, ExpiredOverlay |
Button, Badge, Alert, Modal, useConfirm, Kbd, new Countdown, ProgressSegments, SyncBadge |
StudentResults /results/:attemptId |
— (07 review variant) | GradeHeader (Stat points + grade), per-item type.Review cards with points badge, explanation block, teacher comment |
Card, Stat, Badge, Alert, EmptyState |
6.4 New shared primitives to add (docs/07 §7.4 "à ajouter")¶
In apps/web/src/ui.tsx (or packages/ui if it exceeds ~6 components):
Ring (lobby progress ring), Countdown (server-clock driven), Cell (verdict shape+icon+tint),
ProgressSegments, SyncBadge, MarkdownView (sanitised + KaTeX), PointsInput, DifficultyDots,
Pseudonym. Each follows the hgc-ui rules: five states on every async surface, keyboard complete,
never colour alone, screenshot before "done".
6.5 Realtime client (apps/web/src/realtime/)¶
useEventStream(watch?: "evaluation:<id>" | "attempt:<id>")
→ EventSource("/events?watch=…"), parses ServerEvent with zod
→ "snapshot" : seeds the TanStack Query cache for the watched subject
→ "clock" : feeds useServerClock (median of the last 5 offsets, RTT/2 corrected)
→ live-domain events: applied directly to the cache with queryClient.setQueryData
→ "hint" : invalidateQueries on the listed kinds (heig-classroom behaviour)
→ onerror : EventSource reconnects natively; on reopen a fresh snapshot arrives
+ a 60 s safety refetch of the watched query (docs/05 §5.4 point 3)
useServerClock() → { serverNow(): number, offsetMs }
6.6 Code editor (Monaco), lazy¶
qt-code/src/Player.tsxandEditor.tsximport Monaco throughconst Monaco = lazy(() => import("./MonacoHost"))inside an already-lazy client entry, somonaco-editoronly enters a route that actually shows a code question (N-PERF-05).- Use
@monaco-editor/reactwithloader.config({ paths: { vs: "/vendor/monaco/vs" } })served from the same origin (N-SEC-02 CSP: no CDN). Bundled viavite-plugin-monaco-editorrestricted to["c","cpp","python","javascript","rust"]languages and theeditor.mainworker only. No language server. - Locked regions: one Monaco instance over the assembled template, with
editor.createDecorationsCollectionmarking locked rangesisWholeLine, classlocked-region(background--surface-2,--fg-faint), plus anonDidChangeModelContentguard that reverts an edit intersecting a locked range. Belt and braces: the server reassembles from the regions anyway. - Fallback: if Monaco fails to load, render a
<textarea class="mono">per editable region. The answer shape is identical, so nothing is lost.
6.7 Markdown editing in MVP — decision¶
MVP ships a plain markdown textarea + live preview, not Tiptap. Justification:
- The single source of truth is markdown either way (docs/05 §5.10), so the WYSIWYG can be added later behind the
existing
Sourcetoggle without any data migration — the field staysstring. - Tiptap +
tiptap-markdown+ KaTeX + image-paste + table-paste is ~3 days of integration work and a permanent fidelity risk (round-tripping markdown through a ProseMirror document loses constructs). That risk is not on the MVP critical path: the MVP's goal is "run a graded quiz in class" (docs/00 §0.3). - The profane path is preserved cheaply with three affordances that cost hours, not days: a small toolbar
(bold / italic / code / list / heading / link / image / equation) that inserts markdown at the cursor,
paste-an-image → upload → insert
, and a side-by-side preview. - The renderer built for the preview is the same one used by the Player, the Review and the student results, so the sanitised + KaTeX pipeline is written once and is genuinely needed in MVP.
Concretely, apps/web/src/markdown/:
- MarkdownView.tsx: unified + remark-parse + remark-math + remark-gfm + remark-rehype +
rehype-katex + rehype-sanitize with a strict schema (no raw HTML, src restricted to
/assets/… same-origin, N-SEC-05) + rehype-react. Cloze sentinels are swapped for inputs by a
custom text node handler.
- MarkdownField.tsx: Segmented "Écrire | Source | Aperçu", textarea in JetBrains Mono, toolbar,
paste/drop image handler, Ctrl+Shift+M toggle persisted per user.
- katex CSS + fonts self-hosted.
Tiptap becomes WP-later, replacing only MarkdownField's "Écrire" pane. Recorded as decision D11.
6.8 Command palette¶
CommandPalette.tsx + commands.ts + fuzzy.ts are reused from heig-classroom. MVP registers only:
navigation commands, "new question of type X", "new evaluation", pool search with #tag / type: / diff:
prefixes, and the contextual evaluation actions (start / pause / +5 min / close / release) when the live screen
is mounted. That is ~80 lines on top of an existing component; the S-priority full palette is deferred.
7. packages/domain — pure functions (no DB, no I/O, 100 % unit-tested)¶
packages/domain/src/
grade.ts Swiss scale
deadline.ts deadline & grace computation, time bonus
mcqScore.ts the three policies
cloze.ts parser + grader
short.ts normalisation + matchers
compareOutput.ts code output comparison
lockedTemplate.ts @@lock split / assemble
roster.ts (reused from heig-classroom, + timeBonusPercent column)
stats.ts mean / median / stdev / histogram / distribution
pseudonym.ts deterministic adjective+animal from a seed
round.ts round2, roundToTenth
index.ts
7.1 Swiss grade scale (F-RES, F-EVAL-10)¶
export type Rounding = "nearest" | "up" | "down";
export type Scale = { kind: "linear"; rounding?: Rounding }; // ADR-052: no `threshold`
export const MIN_GRADE = 1;
export const MAX_GRADE = 6;
/** Rounds to one decimal, half away from zero (never banker's rounding). */
export function roundToTenth(x: number, mode: Rounding = "nearest"): number {
const scaled = x * 10;
const r = mode === "up" ? Math.ceil(scaled - 1e-9)
: mode === "down" ? Math.floor(scaled + 1e-9)
: Math.round(scaled + (scaled >= 0 ? 1e-9 : -1e-9));
return r / 10;
}
/**
* points → Swiss grade 1.0 … 6.0 at 0.1: 1 + 5 · points / total, capped at 6.
* The total leaves the bonus items out (ADR-052), so points may exceed it.
* total <= 0 ⇒ 1.0. Negative points (allowNegative) clamp to 1.0.
*/
export function gradeFromPoints(points: number, total: number, scale: Scale): number {
if (!(total > 0)) return MIN_GRADE;
const raw = 1 + 5 * (points / total);
return Math.min(MAX_GRADE, Math.max(MIN_GRADE, roundToTenth(raw, scale.rounding ?? "nearest")));
}
0/20 → 1.0, 12/20 linear → 4.0, 20/20 → 6.0, 21/18 (bonus) → 6.0,
9/18 → 3.5, -2/20 → 1.0, 3/7 linear → 3.1 (rounding), total 0 → 1.0.
7.2 Cloze parser — see §2.3. Public surface:¶
export function parseCloze(text: string): ClozeParse;
export function clozeStudentTemplate(parse: ClozeParse, seed: number, itemId: string, shuffle: boolean): ClozeStudent;
export function gradeCloze(parse: ClozeParse, blanks: (string | null)[], caseSensitive: boolean): ClozeGrade;
\{{ escape, \| inside an alternative, a blank inside a fenced
code block, an unterminated {{, weights summing correctly, a select with two = options, empty answers.
7.3 MCQ policies¶
export function mcqFraction(input: {
correct: readonly number[]; selected: readonly number[];
choiceCount: number; policy: McqPolicy; penalty: number; allowNegative: boolean;
}): { fraction: number; c: number; w: number; C: number; W: number };
W = 0 guard for penalized, allowNegative floor at −1, C = 0 impossible (schema refine).
7.4 Time bonus and deadline¶
export const GRACE_MS = 3000;
export function attemptDeadline(input: {
timing: "duration" | "deadline" | "manual";
startedAt: Date; durationS: number | null;
opensAt: Date | null; closesAt: Date | null;
timeBonusPercent: number; extraS: number;
}): Date | null;
export function bonusSeconds(input: { timing; durationS; opensAt; closesAt; timeBonusPercent }): number;
export function isWritable(deadline: Date | null, now: Date): boolean; // now <= deadline + GRACE_MS
closesAt − opensAt,
manual ⇒ null, extension accumulation, grace boundary at exactly +3000 ms and +3001 ms.
7.5 Seeded shuffle — shuffle/streamSeed live in @quiz/core/rng and are re-exported by domain¶
(packages/domain depends on packages/core; core depends on nothing but zod). Tests: same seed ⇒ same output,
different seed ⇒ different output for n ≥ 4 in ≥ 99 % of seeds, permutation property, empty/1-element arrays,
stability across Node and jsdom.
7.6 Stats¶
export function describe(values: number[]): { count; mean; median; stdev; min; max };
export function histogram(grades: number[], step = 0.5): { bucket: number; count: number }[]; // 1.0 … 6.0
export function mcqDistribution(answers: { selected: number[] }[], choiceCount: number): number[];
8. Work breakdown for parallel agents¶
Dependency graph:
WP0 skeleton (already running, another agent)
├─ WP1 core + domain ──┬─ WP2 qt-mcq/short/cloze ─┐
│ ├─ WP3 qt-code + runner IF ┤
│ └─ WP4 api pool ───────────┤
├─ WP4 ── WP5 api evaluation+live+realtime ── WP6 api grading+results
└─ (contracts: authored inside WP4/WP5/WP6, consumed by WP7..WP10)
WP7 web pool/editors ← WP2, WP3, WP4
WP8 web evaluation+dash ← WP5
WP9 web student player ← WP5, WP2, WP3
WP10 web grading/results ← WP6
WP11 runner service ← WP3 (contract only) — fully parallel, needs a machine with Podman
WP12 e2e + screenshots ← all
Rule for every WP: pnpm build && pnpm typecheck && pnpm test && pnpm lint green before hand-off.
Rule for web WPs: a screenshot at 1440×900 and 390×844, light and dark, per screen (hgc-ui rule).
Rule for contracts: a route is not "done" until its zod schemas are exported from @quiz/contracts and used by
both sides.
WP1 — packages/core + packages/domain + packages/registry skeleton¶
| Inputs | §1, §7 of this plan; heig-classroom packages/domain for style |
| Outputs | Published contract every other WP compiles against |
| Files | packages/core/src/{contract,client,registry,rng,errors,runner,index}.ts; packages/domain/src/{grade,deadline,mcqScore,cloze,short,compareOutput,lockedTemplate,stats,pseudonym,round,index}.ts; packages/registry/src/{server,client}.ts (initially empty maps behind a TODO); packages/*/package.json, tsconfig.json, vitest.config.ts |
| DoD | All §7 functions implemented and exported; QuestionTypeServer/Client compile; defineServerRegistry typed; the whole package tree builds with "moduleResolution": "bundler" and dual ./server ./client exports |
| Tests | 100 % line coverage on packages/domain (enforced in vitest.config.ts); RNG determinism test with a golden permutation table; roundToTenth boundary table; cloze parser table from docs/04 §4.6 |
| Non-goals | No qt package, no React component |
WP2 — qt-mcq, qt-short, qt-cloze (server + client)¶
| Inputs | WP1; §2.1–2.3 |
| Outputs | Three packages registered in packages/registry |
| Files | per package: src/schema.ts, src/grade.ts, src/server.ts, src/client.tsx, src/Editor.tsx, src/Player.tsx, src/Review.tsx, src/Stats.tsx (mcq only), src/canonical.ts, src/*.test.ts, package.json with exports { "./server", "./client" } |
| DoD | emptyDraft() output validates; toStudent leak test green; graders handle answer === null; Editors autosave via onChange only (no internal fetch); Players are controlled components; migrate(cfg, 1) is identity |
| Tests | per type: config schema accept/reject table (≥ 10 cases), grading truth table (mcq: 3 policies × 5 selections; short: one case per matcher kind × match/near-miss/miss; cloze: 8 syntax rows + weights + shuffle stability), toStudent blacklist + secret-value search, Editor/Player Testing-Library smoke test (render, type, onChange fired) |
| Non-goals | No Monaco, no LLM matcher implementation (schema only, rejected at publish) |
WP3 — qt-code + the runner interface and its stub¶
| Inputs | WP1; §2.4, §1.7 |
| Outputs | packages/qt-code; apps/api/src/modules/runner/{index,http,unavailable}.ts |
| Files | qt-code/src/{schema,grade,server,client,Editor,Player,Review,MonacoHost,canonical}.tsx; apps/api/src/modules/runner/*; packages/core/src/runner.ts finalised |
| DoD | grade() returns pending: runner with a request assembled server-side; finalizeRunner() is pure and fully testable with a fixture RunnerOutcome; RUNNER_MODE=stub is the dev default and the whole system works with it; POST /attempts/:id/run returns 503 runner_unavailable cleanly; Monaco loads lazily and locked regions are non-editable |
| Tests | splitTemplate/assembleSource (no markers, nested, wrong region count, every language comment style); compareOutput matrix (trailing WS, case, numeric abs/rel, token-count mismatch); finalizeRunner on: compile failure, all pass, partial, timeout, OOM, allOrNothing; UnavailableRunner throws RunnerUnavailable; HttpRunner against a msw/undici mock (200, 429, 503, timeout) |
| Non-goals | The actual container engine (WP11); tap mode; codeimage |
WP4 — apps/api module pool (+ org completion) + packages/contracts for both¶
| Inputs | WP1, WP2, WP3 (registry filled); §3.2, §4.1, §4.2 |
| Outputs | Working pool API on a PGlite test DB |
| Files | apps/api/src/db/{pool,org}.ts, db/schema.ts; apps/api/src/modules/pool/{routes,service,events}.ts; modules/org/{routes,service}.ts; modules/assets.ts; packages/contracts/src/{org,pool,common}.ts; apps/api/drizzle/0002_quiz.sql (pool part) |
| DoD | Draft autosave stores invalid configs with issues; publish is transactional and guarded by the partial unique index; version restore/deprecate work; search returns by tag/type/difficulty/text; asset upload dedupes by sha256 and serves same-origin; every route validated by a contracts schema; staffAccess-equivalent guard (poolAccess, courseAccess) returns 404 not 403 |
| Tests | pool.db.test.ts: publish → number 1, publish again → 2, concurrent publish → one wins; draft/publish round-trip through migrate; soft delete + 409 in_use; search ranking; roster import merge with the extra column; POST /questions/:id/try grades with the stub runner |
| Non-goals | Canonical YAML export, pool sharing, API tokens |
WP5 — apps/api modules evaluation + live + realtime + ticker¶
| Inputs | WP4; §3.3, §3.4, §4.3, §4.4, §4.8, §5.1–5.3 |
| Outputs | A quiz can be configured, started, taken, autosaved and closed, with SSE |
| Files | db/{evaluation,live}.ts; modules/evaluation/{routes,service}.ts; modules/live/{routes,service,studentView,events}.ts; modules/realtime/{routes,bus,coalesce,presence}.ts; ticker.ts; packages/contracts/src/{evaluation,live,realtime}.ts |
| DoD | Full state machine of §5.1 with every guard; deadline_at computed by @quiz/domain; autosave exactly as §4.7 including the three 410 reasons and the stale-revision path; studentView() is the only path producing student configs and is covered by the registry-wide leak test; SSE emits snapshot on connect, clock at 10 s / 1 s, coalesces dashboard.cell at 250 ms, and filters dashboard.* away from student connections; ticker expires attempts within 1 s + 3 s grace |
| Tests | evaluation.db.test.ts (state machine, item freeze, update-versions blocked by attempts); live.db.test.ts (attempt idempotent creation, autosave revision race with two concurrent writes, 410 at deadline + grace boundary, pause/resume shifts deadlines, extend all vs one); ticker.test.ts (expire, auto-open, auto-close, idempotent re-tick); realtime.test.ts (topic authorisation, coalescer emits once per window, snapshot shape) |
| Non-goals | Grading, results, poll mode |
WP6 — apps/api modules grading + results + jobs¶
| Inputs | WP5; §3.5, §4.5, §4.6, §5.4, §7.1 |
| Outputs | Close → auto grading → panel → release → CSV |
| Files | db/grading.ts; modules/grading/{routes,service,jobs}.ts; modules/results/{routes,service,csv}.ts; jobs.ts (queue names); packages/contracts/src/{grading,results}.ts |
| DoD | grading.evaluation is a pg-boss singleton per evaluation and idempotent; missing answers graded 0 validated; runner-pending answers go through grading.runner and degrade to proposed + runner_unavailable under the stub; manual override requires a comment and supersedes; regrade stamps regradeNote and flips modified_after_release; release writes released_grades in one transaction; CSV has a UTF-8 BOM and ; |
| Tests | grading.db.test.ts (run twice → no duplicate gradings; supersede chain; partial unique index enforced; batch validate filter); regrade.db.test.ts; results.test.ts (grade table against §7.1 fixtures, stats, CSV bytes start with EF BB BF, student feedback filtering per policy — key hidden, hidden case bodies stripped) |
| Non-goals | LLM grading, flags, item analysis |
WP7 — Web: pool, question editors, try panel¶
| Inputs | WP2, WP3, WP4; mockups 08-pool.html, 01-editeur-qcm.html, 02-editeur-code.html; §6.2, §6.7 |
| Outputs | A teacher can author and publish all 4 types |
| Files | apps/web/src/pool/{PoolView,CategoryTree,QuestionTable,FilterBar,QuestionSidePanel,BulkBar}.tsx; apps/web/src/question/{QuestionEditor,MetaPanel,PublishDialog,VersionHistory,TryPanel}.tsx; apps/web/src/markdown/{MarkdownView,MarkdownField,katex.css}.tsx; apps/web/src/questionTypes.ts (client registry hookup); i18n keys |
| DoD | Each type's Editor mounted lazily; draft autosaves 500 ms after the last keystroke with a visible state (Enregistré / Non enregistré / Hors ligne); publish shows zod issues inline; Try panel grades through POST /try and renders Review; markdown preview sanitised with KaTeX; image paste uploads and inserts; 5 states per async surface; screenshots captured |
| Tests | Testing Library: create → edit → publish flow per type with an MSW-mocked API; filter bar composes query params; MarkdownView sanitisation test (<script>, onerror=, external src all stripped); keyboard: Ctrl+S, Ctrl+Shift+P, Ctrl+Shift+M |
WP8 — Web: evaluation config + live dashboard¶
| Inputs | WP5; mockup 03-dashboard-live.html; §6.2 |
| Outputs | A teacher configures, starts, supervises and closes a quiz |
| Files | apps/web/src/evaluation/{EvaluationConfig,ItemsStep,TimingStep,AdvancedDisclosure,AddQuestionsSheet,LaunchStep}.tsx; apps/web/src/live/{LiveDashboard,StudentGrid,Cell,ToggleBar,InspectModal,LiveHeader,Legend}.tsx; apps/web/src/realtime/{useEventStream,useServerClock}.ts; new ui.tsx primitives (Cell, Countdown, Ring) |
| DoD | The three-screen flow of docs/08 §8.2 with the named presets; stale-version badges and one-click update; dashboard applies dashboard.cell / presence events to the cache without refetch; toggles n/r/s and Space for pause; inspection panel is in-flow, not a modal; fullscreen mode; grid stays usable at 30 students × 12 questions (sticky first column, horizontal scroll, no layout thrash) |
| Tests | Reducer test for the event→grid application (cell update, presence, out-of-order revision ignored); useServerClock offset median test with synthetic clock skew; keyboard shortcut tests; snapshot-seeded render test |
WP9 — Web: student home, lobby, zen player¶
| Inputs | WP5, WP2, WP3; mockups 05, 06, 07; §6.3 |
| Outputs | A student takes a quiz end to end |
| Files | apps/web/src/student/{StudentHome,Lobby,Player,PlayerShell,ProgressSegments,SubmitDialog,OfflineBanner,PausedOverlay}.tsx; apps/web/src/attempt/{autosave.ts,useAttempt.ts}; ui.tsx additions (SyncBadge, ProgressSegments, Ring, Countdown) |
| DoD | Autosave exactly as §4.7 client rules (300 ms debounce, one in-flight per item, local revision, backoff, offline after 3 s, replay on reconnect, stop on 410); reload restores answers and position (F-LIVE-06); countdown driven by the server clock; forward_only and milestones enforced client-side and server-side; keyboard complete (Alt+←/→, Ctrl+Enter); works at 360 px; code player runs visible cases and shows stdin / expected / got / verdict, and degrades gracefully when the runner is unavailable |
| Tests | autosave.test.ts with fake timers: debounce, single flight, stale adoption, 410 stop, offline/online transitions; player reducer tests; a jsdom "reload" test restoring state from GET /attempts/:id; axe check on the player |
WP10 — Web: grading panel + results + export¶
| Inputs | WP6; mockup 04-correction.html; §6.2 |
| Outputs | A teacher validates, overrides, regrades, releases and exports |
| Files | apps/web/src/grading/{GradingPanel,EntryList,EntryDetail,HistoryPopover,RegradeSheet,BatchBar}.tsx; apps/web/src/results/{ResultsView,GradeTable,StatsRow,Histogram,ByQuestionView,ExportButton}.tsx |
| DoD | By-question and by-student traversal, anonymous by default; one accent action only (batch validate); per-criterion / per-case / per-blank detail always shown, never a bare score; v + → validates and advances; override requires a comment; release behind useConfirm naming the evaluation; CSV downloads with the right headers; histogram and stats match @quiz/domain |
| Tests | Panel navigation and keyboard tests; override form validation; MSW-mocked release flow; ByQuestionView distribution rendering for mcq/short/cloze/code |
WP11 — apps/runner (HTTP service, Podman)¶
| Inputs | packages/core/src/runner.ts (WP3) only — no dependency on any other WP |
| Outputs | A container-backed implementation of POST /run |
| Files | apps/runner/src/{server,engine,queue,limits}.ts; apps/runner/images/{c,cpp,python,js,rust}/Containerfile; apps/runner/infra/seccomp/codespace.json (copied); deploy/compose.prod.yml addition |
| DoD | Implements the RunnerRequest/RunnerOutcome contract byte for byte; two queues (interactive priority over grading), configurable concurrency (default 4), 429 beyond the queue depth limit; hardening flags from apps/codespace/images/c-dev/run-hardened.sh (--network none, --read-only, --tmpfs /work:size=32m, --memory, --cpus 1, --pids-limit 64, --userns=auto, --cap-drop ALL, --security-opt no-new-privileges, seccomp profile, --runtime runsc when available); wall clock enforced by the service, output truncated at outputKb; GET /health returns languages and queue stats |
| Tests | Per language: hello-world, compile error, infinite loop → timedOut, memory bomb → oom, output flood → truncated, network attempt → fails, /etc/passwd write → fails; N-PERF-04 load test (30 runs in 10 s under 5 s p95) |
| Blocked by | Needs a machine with Podman. Everything else ships without it thanks to RUNNER_MODE=stub. |
WP12 — e2e, mock browser, screenshots, ops¶
| Inputs | WP7–WP10 |
| Outputs | Confidence + the deployable artifact |
| Files | apps/web/src/mock/ fixtures for every new screen; apps/web/scripts/screenshots.mjs updated; e2e/*.spec.ts (Playwright); deploy.sh guard "refuse if an evaluation is running/lobby"; docs/OPERATIONS.md |
| DoD | One e2e per main journey (N-QUAL-02): teacher authors + publishes a question; teacher configures and starts a quiz; student takes it with a simulated disconnection; teacher closes, grades, releases; student reads their feedback. dev:mock renders every screen with ?as=teacher / ?as=student and no backend |
| Tests | The e2e themselves; screenshot diff for the 10 mockup-backed screens in light and dark |
Suggested wave plan for parallel agents - Wave A (parallel): WP1, WP11 (contract-only start). - Wave B: WP2, WP3, WP4. - Wave C: WP5, WP7. - Wave D: WP6, WP8, WP9. - Wave E: WP10, WP12.
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 |
Indices reveal nothing (correctness is not exposed); the alternative (opaque per-attempt tokens) needs server-side state for no security gain |
| D4 | Cloze answer encoding for dropdown blanks | select blanks store the canonical option index as a decimal string; other blanks store the typed string |
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) |
Bounded input makes catastrophic backtracking a worker-level slowdown at worst, not a request-path DoS. Revisit with re2 if a real case appears |
| 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 |
10. Quick reference for implementing agents¶
- Read this file, then
docs/07(what already exists), then the mockup for your screen. Do not re-read the whole spec. - Never write a raw jsonb config: go through
loadConfig/saveConfig(§1.6). - Never build a student payload by hand: go through
studentView()(modules/live/service.ts), which callstype.toStudentand strips core metadata. - Never compute a grade in a route:
@quiz/domain/grade#gradeFromPoints. - Never compute a deadline in a route:
@quiz/domain/deadline#attemptDeadline,GRACE_MS. - Never publish an event directly:
modules/realtime/bus.ts#publish. - Never read a module's
routes.tsfrom another module: call itsservice.ts. - A table belongs to exactly one module; other modules read it by join, never write it.
- Code, comments, commits and docs in English; UI strings through
t()infr+en.
Deviations¶
Append-only log of places where the implementation departs from the plan above. Each entry names the work package, what changed and why. Everything not listed here follows §1–§10 verbatim.
WP1 — packages/core, packages/domain, packages/registry¶
| # | Plan | Implemented | Why |
|---|---|---|---|
| W1-1 | §1 lists packages/core exports . and ./client |
./server, ./client and ./rng |
The WP1 brief requires an explicit ./server entry; ./rng makes the @quiz/core/rng import path of §7.5 real. . was a byte copy of ./server that nothing ever imported and is gone (audit P-18) |
| W1-2 | §7.1 puts roundToTenth in grade.ts, the §7 file list puts it in round.ts |
round.ts owns Rounding, roundToTenth, round2 and clamp; grade.ts imports them |
The two statements contradict each other and a symbol cannot be exported twice through index.ts |
| W1-3 | §1.1 exports shuffle |
shuffle, plus seededShuffle as a documented alias |
The WP1 brief names the function seededShuffle; both names denote the same function, so neither WP2..WP6 nor the supervisor has to adapt |
| W1-4 | §1.5 makeLookup(m: Record<string, T>) |
makeLookup(m: Readonly<Record<string, T \| undefined>>) |
A registry that is still empty (or partial until WP3 lands) is typed Partial<Record<QuestionTypeId, …>>, which does not satisfy Record<string, T> under exactOptionalPropertyTypes. A full registry is still accepted unchanged |
| W1-5 | §1.5 declares QUESTION_TYPE_IDS in packages/registry/src/server.ts |
Declared in @quiz/core (it is the source of the QuestionTypeId union) and re-exported by @quiz/registry/server and @quiz/registry/client |
Keeps the constant and the union from drifting. The import path named in the plan keeps working |
| W1-6 | §1 file list names three error types | Adds RunnerBusy and an abstract QuizCoreError base |
§1.7 requires the runner to throw RunnerBusy on a 429; the base class gives the API one instanceof to map to a status |
| W1-7 | §1.2/§1.3 use LlmGradeRequest and LlmService without defining them |
Minimal shapes in packages/core/src/llm.ts, marked Phase 2 |
GradeResult and GradeContext do not compile without them. Phase 2 may change the shapes freely; nothing in the MVP constructs one |
| W1-8 | The WP1 brief forbids any |
Two aliases use it: AnyQuestionTypeServer and AnyQuestionTypeClient in core/src/registry.ts |
As written in §1.5. A registry erases five unrelated type parameters, and QuestionTypeServer's method parameters are contravariant, so unknown would make every concrete type unassignable. Confined to those two aliases; call sites go back through the concrete type via loadConfig/saveConfig |
| W1-9 | D5 writes the cloze sentinel ⟦b<i>⟧, §2.3 writes ⸢{index}⸣ |
⸢<index>⸣ (U+2E22, U+2E23), as §2.3 and as D5's own code points |
The two spellings name the same code points; §2.3 is the precise one. parseCloze additionally strips those two characters from the authoring text so a teacher cannot conjure a phantom input |
| W1-10 | §7.4 gives attemptDeadline / bonusSeconds inline input types |
Named interfaces BonusInput and DeadlineInput (same fields) |
Callers in WP5 need to name the type; the field list is unchanged |
| W1-11 | §7 names one function per concern | Domain also exports isPassing, remainingSeconds, truncateSelection, clozeTotalWeight, matchBlank, describeBlank, describeMatcher, hasLlmMatcher (superseded by publicationIssues, ADR-037), isValidPattern, compileFullMatch, regionCount, emptyRegions, mainFileName, uniquePseudonyms |
Additions, never replacements: the qt packages and the grading worker need them, and they belong in the pure layer rather than in an API module |
| W1-12 | §7.6 does not say what an empty series gives | describe([]) returns count: 0 and zeroes everywhere |
Total function; the results screen shows a dash on count === 0 |
| W1-13 | §2.4 says a marker line is a line comment (//, #, --) |
Per-language prefixes: // and /* … */ for c/cpp/js, // for rust, # for python |
-- belongs to no MVP language; a C template commonly writes /* @@lock */ |
| W1-14 | — | @quiz/domain gains subpath exports (@quiz/domain/grade, @quiz/domain/deadline, …) next to . |
§10 refers to @quiz/domain/grade#gradeFromPoints; the subpaths make that import real |
WP4 — apps/api module pool (+ org completion) + packages/contracts¶
| # | Plan | Implemented | Why |
|---|---|---|---|
| W4-1 | §3.7 delivers the MVP as 0001_base + 0002_quiz |
0000_init (bootstrap, untouched) + 0001_pool (pool tables, api_tokens, classrooms.join_code_enabled) |
The supervisor override forbids editing 0000_init.sql; the pool half of 0002_quiz therefore lands as 0001_pool, and WP5/WP6 append their own |
| W4-2 | §3.2 search is generated from search_text "plus the joined internal_name" |
search_text itself holds internal_name + type.searchText(config), and search is to_tsvector('simple', search_text) |
A generated column may only read its own row; joining another table is not expressible. PGlite 0.5 (PG 18) was verified to support the generated tsvector and its GIN index |
| W4-3 | §1.6 loadConfig(type, raw, storedVersion) |
loadConfig(type, row) where row is { config, configVersion } — the two columns of question_versions |
Callers always hold the row; the three-argument form invited passing the two halves from different rows. saveConfig is unchanged, and saveDraftConfig is the D16 variant that stores what does not parse |
| W4-4 | §4.2 GET /questions/:id returns the draft config |
A VALID draft is returned migrated; an INVALID one is returned exactly as stored | The editor always works at the current configVersion, and D16's promise ("a teacher may leave a question half-written") means the bytes of an invalid draft must come back untouched |
| W4-5 | §4.2 DELETE /questions/:id — 409 in_use on ?hard=1 |
409 in_use on both the soft and the hard delete |
One rule instead of two: a question an evaluation still points at does not leave the pool, whichever button was pressed. isVersionInUse is the WP5 seam and returns false until evaluation_items exists |
| W4-6 | §4.1 POST /classrooms/join { code } |
POST /app/api/join/:code |
The WP4 brief names this path; the code is in the URL the teacher projects. Any session may call it, and a wrong, disabled or archived classroom is the same 404 |
| W4-7 | §4.1 POST /classrooms/:id/archive { archived } |
The bootstrap's POST /archive + POST /unarchive pair, kept |
Already implemented, already called by the web app; a body-driven variant would be a second way to do one thing |
| W4-8 | WP4 file list says modules/org/{routes,service}.ts |
modules/org/ holds ONLY the completion (course detail, course_pools, join code, self-join); courses, staff, classrooms and roster stay in modules/courses.ts and modules/roster.ts |
Moving working routes under a new directory is churn the plan does not pay for. The org module is the union of the three files Superseded on 2026-09-23 (PR #57): courses, staff, classrooms and roster now live in modules/org/ with a service layer. |
| W4-9 | §4.8 HintEvent { type, topics, kinds } over GET /events?watch= |
The inherited bus: publish("pool", ["pool:<id>"]), topics computed at connection time, { type, notice } on the wire |
The watch= protocol and the typed ServerEvent union belong to WP5's realtime module. WP4 adds the pool kind, the pool:<id> topic and its authorisation (poolAccess), which is what the pool screens need now |
| W4-10 | §3.6 catalogue | Adds category.*, pool.*, question.*, course.pools_update, classroom.join_code, roster.join; PUT /draft is NOT audited |
Every write is audited except the autosave, which fires every few seconds per open editor: it would drown the append-only log, and the publication that follows carries the content |
| W4-11 | §4.2 asset allowlist includes image/svg+xml |
SVG is refused; the accepted type is decided by the MAGIC BYTES, not by the declared content-type |
An SVG is a script container; sanitising one is a project of its own. Sniffing also makes a lie about the content type harmless |
| W4-12 | — | registerForTests(type) in @quiz/registry/server, a no-op under NODE_ENV=production |
packages/registry is empty until WP2/WP3 land, and the pool module cannot be tested without a type. The fakes live in apps/api/src/test/fakeType.ts |
| W4-13 | WP4 DoD "concurrent publish → one wins" | Asserted as "two distinct numbers and exactly one draft", plus a direct test that a second draft row violates the partial unique index | PGlite is single-connection: two transactions are serialized, so the interleaving cannot be reproduced. What the indexes guarantee is what is asserted; a real Postgres run of the same test exercises the race |
| W4-14 | §4.2 POST /pools/:id/assets → { id, url: "/assets/:id" } |
url is /app/api/assets/:id |
The whole SPA API lives under /app/api (supervisor override of D18); the asset URL must be one the browser can actually fetch |
| W4-15 | §4.2 GET /assets/:id — "Any authorised to see the containing content" |
Any STAFF session reads an asset; a student gets 404 until WP5 wires the attempt | The store is deduplicated instance-wide (unique (sha256)), so assets.pool_id names the pool that uploaded the bytes first, not the pools that display them: a per-pool check would 404 the second teacher's own image. The id is a random uuid, and a colleague holding the same bytes obtains the same id by uploading them |
### WP3 — packages/qt-code, the runner interface and its stub |
| # | Plan | Implemented | Why |
|---|---|---|---|
| W3-1 | §2.4 CodeDetails.sourceSha256: z.string().length(64) |
.length(64).nullable(), plus an optional reason |
An unanswered question — or an answer that no longer fits the template — has no assembled source to hash, and the details must still say why |
| W3-2 | §2.4 has no per-case time budget | tests.cases[].timeMs (nullable, null = use limits.timeMs). The request asks for the LARGEST budget of the cases it sends; finalizeRunner re-applies each case's own limit to the measured time |
The WP3 brief requires it, and §1.7's wire contract carries one global limits.timeMs, so a tighter per-case budget can only be enforced on the way back |
| W3-3 | §2.4 has no reference solution | config.referenceSolution, used only by the editor's "try" button |
The WP3 brief requires it. It is dropped by toStudent and asserted absent by the leak test |
| W3-4 | §2.5 forbids the key "expected" in a student view |
expected IS published, for the VISIBLE cases only |
docs/04 §4.7 has the player show "stdin / expected output / got" for those cases. The hidden cases' names, inputs and outputs are covered by the secret-VALUE search, which is the check that matters |
| W3-5 | §2.4 says details hold expected/actual "only for visible cases (or for the teacher)" |
finalizeRunner stores the whole truth; studentDetails(details, { showHiddenCaseNames }) in grade.ts is the filter |
finalizeRunner has no audience parameter and the grading panel needs everything, so the redaction is a separate pure function the feedback policy (WP6) applies |
| W3-6 | §1.4 PlayerProps.run?: (payload: unknown) => Promise<unknown> |
The player also accepts a typed onRun(answer) => Promise<RunnerOutcome \| "unavailable"> |
Named by the WP3 brief. The contract's generic run stays as it is; the host adapts |
| W3-7 | §1.7 selects the runner with stub \| http \| fake |
RUNNER_MODE is stub \| http |
The WP3 brief. A fake is a test double the tests construct directly, not an env value that could ship |
| W3-8 | §2.4 locks regions with "Monaco readOnly decorations" |
Stacked surfaces: one read-only block per locked segment, one editor per editable region (documented in MonacoHost.tsx) |
A read-only range inside one model is a rendering rule; separate buffers mean the locked text is in no editable buffer at all, and the <textarea> fallback keeps the same property with no extra code |
| W3-9 | §2.4 stores cases[].visible |
Still visible; the editor shows the opposite switch, "Hidden" |
The stored spelling stays the spec's, the control matches the decision a teacher makes (and the WP3 brief) |
| W3-10 | — | HealthResponse.checks gains runner: "up" \| "down" \| "disabled" in packages/contracts |
/healthz must report the runner. stub reports disabled and never degrades the overall status, so a machine without a container engine is not unhealthy (decision D14) |
| W3-11 | — | packages/qt-code adds strings.ts (UI strings, English defaults, overridable through a strings prop), styles.ts (the class lists of apps/web/src/ui.tsx, mirrored) and segments.ts (marker-line display helpers) |
A package cannot import apps/web, and the authoritative split still comes from @quiz/domain/lockedTemplate — segments.ts only decides what is shown Superseded on 2026-09-23 (PR #51): the shared class lists of styles.ts moved to @quiz/ui, and the file is gone from qt-code. |
| W3-12 | — | packages/qt-code/tsconfig.json names "types": ["node"] |
grade.ts hashes the assembled source with node:crypto; the automatic @types sweep does not reach this package's own pnpm link |
### WP2 — packages/qt-mcq, qt-short, qt-cloze |
| # | Plan | Implemented | Why |
|---|---|---|---|
| W2-1 | The WP2 brief names the editor props value/onChange/issues and the player props config/answer/onChange/disabled |
The names of the §1.4 contract: config/onChange/disabled and student/answer/onChange/readOnly, plus optional issues, strings and renderMarkdown. A player also accepts disabled as an alias of readOnly |
packages/registry types every component as ComponentType<EditorProps<…>> / ComponentType<PlayerProps<…>>: a renamed required prop does not compile. The brief's disabled is honoured as an alias |
| W2-2 | §1.4 EditorProps.uploadAsset is required |
Optional on the three editors | The MVP editors are markdown textareas (D11) and upload nothing. An optional prop keeps the component assignable to EditorProps |
| W2-3 | §1.4 has no place for UI strings, markdown injection or draft issues | @quiz/core/client gains StringOverrides<K>, resolveStrings, MarkdownRenderer and ConfigIssue |
A qt-* package cannot import apps/web, so it ships English defaults and takes a typed partial override (N-I18N-01), takes the host's sanitised renderer as a prop, and places the issues[] of D16 next to the offending field. Defined once, as the brief asks |
| W2-4 | §2.1 McqDetails |
Adds truncated: boolean |
§2.1 requires a payload longer than maxSelections to be truncated "and logged in details", but the listed shape had nowhere to log it |
| W2-5 | §2.1 third refinement, .refine((c) => c.policy !== "all_or_nothing" \|\| true) |
Dropped | It is a tautology: it accepts every config and would only add noise to the issue list |
| W2-6 | §8 WP2 requires emptyDraft() to validate, without saying what it holds |
Language-free … placeholders (mcq: one prompt, two choices, the first correct; short: one exact matcher; cloze: … {{…}} …) |
Every string of the three schemas is min(1), so a draft cannot be empty. An English sentence would be content a French-speaking teacher has to delete, and a translated one would put UI strings in a stored config |
| W2-7 | §2.3 does not define a cloze solution shape |
{ blanks: [{ index, expected }] }, expected rendered by describeBlank |
toSolution is part of the contract and the review panel shows the key blank by blank; the index keeps it aligned with details.perBlank |
| W2-8 | §2 is silent on defaultPoints for cloze |
One point per blank (mcq and short default to 1) |
The blanks are already weighted against each other; making the item worth its blank count is what a teacher expects from "somme des poids" |
| W2-9 | §2.2 "compilation wrapped in try/catch at publication time (invalid regex = publication error)" | ShortConfigSchema refuses an uncompilable pattern, with the path ["matchers", i, "pattern"] and the key short.invalid_pattern |
Publication IS configSchema.parse (§1.6). Putting the check in the schema means the draft still saves (D16) and the error arrives with a path the editor can place |
| W2-10 | §2.2 "a config containing an llm matcher is rejected at publication with llm_not_available" |
The schema accepts it (so an imported question survives a round trip) and hasLlmMatcher (@quiz/domain/short) flags it; the refusal itself belongs to WP4's publish service. Superseded by publicationIssues (ADR-037): qt-short refuses it at publication with short.llm_not_available, and hasLlmMatcher is gone. |
A schema that refused it would also refuse the round trip the canonical format needs, and the error code belongs to the HTTP layer |
| W2-11 | §8 WP2 file list | Each package also carries src/strings.ts, src/ui.tsx and src/fixtures.ts; qt-cloze carries src/text.tsx |
The strings dictionary is the English half of W2-3; ui.tsx holds the handful of class strings the three components share (duplicated per package on purpose: packages/ui is a later WP and a package may not import apps/web); text.tsx is the D5 sentinel renderer and its fallback Superseded on 2026-09-23 (PR #51): the shared class strings moved to @quiz/ui; only qt-mcq keeps a ui.tsx, for its own components. |
| W2-12 | WP1 packages exclude *.test.ts from typecheck as well as from the build |
typecheck runs a second pass (tsconfig.test.json) that includes the tests |
Vitest does not typecheck. A test suite that nothing type-checks silently drifts from the contract it is supposed to hold, and "no any" cannot be enforced in it |
| W2-13 | The brief asks for the qt sources to be added to the @source directives of apps/web/src/style.css |
One line, @source "../../../packages/{qt-mcq,qt-short,qt-cloze}/src"; |
Measured: Tailwind 4 expands a brace list in a directory position but not a * (packages/qt-*/src produced none of the packages' utilities). WP3 adds qt-code to the same line |
| W2-14 | D5 leaves the cloze rendering to "the markdown renderer" | The player and the review take renderText({ template, renderBlank }); the built-in fallback handles paragraphs, fenced code, inline code, bold and italic |
The host's renderer must be able to place a React input at a sentinel, which a (source) => ReactNode signature cannot express. apps/web will pass its MarkdownView behind the same prop |
WP5 — apps/api modules evaluation + live + realtime + ticker¶
| # | Plan | Implemented | Why |
|---|---|---|---|
| W5-1 | §3.7 delivers the MVP as 0001_base + 0002_quiz |
0002_evaluation.sql on top of W4-1's 0001_pool |
The supervisor override forbids editing 0000_init.sql; each work package appends its own migration instead of rewriting a shared one |
| W5-2 | §3.4 creates run_requests |
Not created. The per-minute budget of POST /attempts/:id/run is counted from attempt_events where kind = 'run' |
The journal already records the fact, with the attempt it belongs to and the instant it happened — which is exactly what a sliding-window limit and the F-ADMIN-02 metrics read. One table fewer, and the retention rule (pruned with the classroom) is inherited |
| W5-3 | §3.4 says guest_participants is "phase 2 — not created in MVP" |
The table exists, empty, with no route | The WP5 brief asks for it. Creating it now settles the column set and saves a migration when guests land |
| W5-4 | §3.3 (evaluation_id, position) unique deferrable initially deferred |
A plain unique index, and renumber() moves every row out of the way (position + 100000) before giving it its final place, in ONE transaction |
drizzle-kit cannot emit a deferrable constraint, and hand-editing the generated SQL would desynchronise the snapshot it maintains. The two-phase update is portable, works on PGlite, and is asserted by the reorder test |
| W5-5 | §3.4 answers.payload jsonb not null |
Unchanged — and "seen, nothing typed" stores the JSON value null ('null'::jsonb) |
Marking a question done before answering it has to create the row. null is already what grade(config, null, …) means (F-GRADE-01), so nothing downstream learns a new case |
| W5-6 | §4.8 GET /events?watch= |
ONE handler at /app/api/events |
The SPA API lives under /app/api (supervisor override of D18). WP5 also mounted the handler at the inherited /app/events, on the belief that apps/web/src/live.ts still opened it; the web WPs had already migrated, so the alias never had a caller and was removed on 2026-09-22 (audit B-11) |
| W5-7 | §4.8 describes one grammar of named SSE events | A HINT goes out as an UNNAMED frame (data: {"type":"hint",…}), every typed live event as a NAMED one (event: clock, event: dashboard.cell, …) |
EventSource.onmessage only receives unnamed frames, so the shipped client keeps working unchanged while a new client subscribes by name. One stream, two readerships, no second endpoint |
| W5-8 | §4.8 HintEvent.notice.kind is info\|success\|warning\|error |
The inherited NoticeKind of contracts/src/notifications.ts (student_joined \| roster_conflict) |
That catalogue is the one apps/web renders and filters on. Two notice vocabularies would be two places to keep in step for no gain |
| W5-9 | §4.8 lists modules/realtime/routes.ts next to the existing modules/events.ts |
modules/events.ts is DELETED; its topic computation moved into modules/realtime/routes.ts |
The WP5 brief asks to extend the existing stream, not to duplicate it. Two SSE endpoints computing the same topic set is exactly the drift the brief forbids |
| W5-10 | §10 "never publish an event directly" | apps/api/src/events.ts (the bus) gains publishData and an Audience; publish and its call sites are untouched; modules/realtime/bus.ts is the only thing that calls either |
WP4's poolChanged keeps working verbatim, and the coalescing and topic decisions have one home |
| W5-11 | §4.4 POST /attempts/:id/run → 202 { requestId }, "result arrives via SSE" |
202 { requestId, result }: the runner call is awaited (bounded by RUNNER_TIMEOUT_MS) and the outcome is BOTH published on user:<id> and returned |
With RUNNER_MODE=stub — every development machine and CI runner — the honest answer is a synchronous 503 runner_unavailable, which the WP5 brief requires. Answering 202 and never delivering anything would be a lie. A client that lost its stream still gets its result |
| W5-12 | §4.4 POST /attempts/:id/run is "for code questions" |
The route is type-agnostic: any type declaring finalizeRunner works, and the cases sent are the ones the STUDENT VIEW already publishes (visibleCases, matched by name) or one ad-hoc case when stdin is given |
live must not import a qt-* package. Deriving the visible set from toStudent also makes the "no hidden case ever leaves the server" property true by construction rather than by review |
| W5-13 | §4.4 runsPerMinute comes from the question config |
Read from the STUDENT view of the item, default 10 when a type publishes none | The limit is one the student is shown, so it must be the same number on both sides; and the live module does not open configs it does not own |
| W5-14 | §4.4 POST /evaluations/:id/attempt → AttemptView \| LobbyView |
{ kind: "attempt" \| "lobby", view } |
A bare union of two object shapes is not discriminable by the client that shares the schema (invariant 7). One tag makes it a z.discriminatedUnion on both sides |
| W5-15 | §4.4 AttemptView |
items[].locked and evaluation.feedbackPolicy added |
Navigation is enforced on the SERVER (forward_only, milestones); the player must be told which items the server will refuse, rather than deducing it. The feedback policy decides what the player may show after a validation and is not worth a second request |
| W5-16 | §4.3 POST /evaluations/:id/preview → "AttemptView with fake attempt (seed: 0, read-only)" |
Same, with attempt.preview: true and a fixed attempt.id (PREVIEW_ATTEMPT_ID) |
The player needs one flag to know it must not write; a fixed id makes an accidental write hit nothing |
| W5-17 | §4.3 PATCH answers 409 locked "if the field is structural" without listing them |
Editable once an attempt exists: title, accessCode, ipAllowlist, feedbackPolicy. Everything else is 409 locked |
Those four change nothing a student already saw and nothing about their clock. The list is a constant in the service, next to the rule |
| W5-18 | §4.3 / F-EVAL-11 | An exam never stores feedbackPolicy.when = "immediate": the value is clamped to on_release on write. Amended by #78 (2026-09-24): no longer clamped but REFUSED (422 feedback_not_allowed, nothing written), and the rule also covers an exercise given a waiting room (lobby ≠ skip), which is sat in class. One pure rule, allowedFeedbackWhen in @quiz/domain, used by the API and the screen; the screen sends the on_release fallback in the same patch that adds a waiting room |
F-EVAL-11 reserves immediate feedback to exercise and poll. Clamping on write means no route has to re-check it later. The clamp hid a client error behind a 200, and the take-home / in-class split of the presets made "exercise" alone the wrong test |
| W5-19 | §4.4 teacher controls | Two routes the plan does not list: POST /evaluations/:id/attempts/:attemptId/close and …/reopen |
The WP5 brief asks for "close one / close all, reopen". Reopening recomputes the deadline from the ORIGINAL start plus everything already granted, so it is not a second full duration |
| W5-20 | §4.4 DashboardView.rows[].points, cells[].verdict, totals[].successRate |
null until WP6 writes gradings |
The MVP grid is a PROGRESS grid; the read model is already shaped for the grades and the shape does not change when they arrive |
| W5-21 | §4.4 /student/home → { open, upcoming, results } |
{ open, upcoming, past, serverNow } |
results is WP6's (a card there carries a grade). past is the same list without the grade, so WP6 adds a field instead of adding an endpoint |
| W5-22 | §5.3 step 5 "presence sweep" | The presence map lives in memory (modules/realtime/presence.ts); attempts.present_at keeps the durable last sign of life |
Presence changes several times a minute per student: on a table that is pure write amplification. The consequence is explicit — with WORKER_MODE split across processes each one knows its own connections — and the MVP runs one process (ADR-001, ADR-009). The bus is already shaped for LISTEN/NOTIFY the day that changes |
| W5-23 | §5.3 runs under pg_try_advisory_lock |
No advisory lock. Every task is a conditional UPDATE with RETURNING, which IS the claim |
The lock would serialise the whole tick for a guarantee the WHERE clause already gives per row. Two processes ticking together close each attempt exactly once between them, which is what the test asserts by re-ticking |
| W5-24 | — | apps/api/src/clock.ts: app.clock is decorated at build time, TestClock in the tests, and no service reads the wall clock |
The WP5 rule "time must be injectable so tests never sleep". The deadline boundary, the three-second grace and the pause shift are asserted by moving one object |
| W5-25 | — | JOBS_DISABLED=1 in config.ts; startJobs returns null and decorates nothing. Set by test/http.ts and by app.test.ts, which also drops to WORKER_MODE=web |
app.test.ts points the API at a dead port on purpose; pg-boss answered that by retrying and printing an ECONNREFUSED stack trace per attempt, and the ticker then did the same once a second. /healthz still reports jobs: "down", which is true |
| W5-26 | Invariant 4 says toStudent has one caller |
WP4's teacher preview (POST /questions/:id/preview) now goes through studentViewOf in modules/live/studentView.ts |
It was the second caller. Routing it through the same exit makes "one place to audit" literally true, and the registry-wide leak test then covers that route too |
| W5-27 | §2.5 / invariant 4 leak test | studentView.leak.test.ts sows markers into the SECRET-named fields of each type's emptyDraft(), keeping the config valid at every step, and asserts the forbidden-key list, the marker search AND that the stripped payload still satisfies the type's own studentSchema |
A type whose key lives inside its authoring text (cloze) or in a non-string field (mcq's correct) sows nothing; those are covered by the forbidden-key half and by the package's own toStudent.test.ts. The test prints which types the value search exercises, so a type that later adds a secret string field is covered with no edit here |
| W5-28 | §1.6 / §10 | studentView() also strips a forbidden-key list from whatever toStudent returned |
Defence in depth: a type that respects its contract loses nothing, and a type that slips gains no reach. Asserted with a deliberately rogue type |
WP7 — Web: pool, question editors, try panel¶
| # | Plan | Implemented | Why |
|---|---|---|---|
| W7-1 | §8 WP7 file list names apps/web/src/questionTypes.ts |
apps/web/src/questionTypes.tsx |
The module mounts the three surfaces behind Suspense and builds the renderMarkdown elements it injects, so it contains JSX and cannot be a .ts file |
| W7-2 | §5.2 has apps/web talk to the registry only |
@quiz/web declares @quiz/registry and @quiz/core as dependencies |
The registry erases the five type parameters of a client entry; the props the host passes (ConfigIssue, StringOverrides, MarkdownRenderer) are @quiz/core/client types, and a package used in a type position is still a dependency |
| W7-3 | §6.2 reaches a pool through a course page | Linking and unlinking a pool lives on the course card of TeacherHome; there is no /courses/:id route yet |
WP7 owns the pool, not the course screens. One card with the pools of a course is enough to reach every pool, and the route can land later without moving the control |
| W7-4 | Mockup 08-pool.html shows an "Uncategorized" node in the category tree |
No such node: the tree lists the real categories, and a search matches across the whole pool | "Uncategorized" is a filter, not a category, and it has no id to send to GET /pools/:id/questions. Search already answers "what is not filed", so a node that cannot round-trip through the API was not invented |
| W7-5 | §6.2 category ordering | Two buttons, move up / move down, on each node | Drag and drop needs a pointer, a keyboard fallback and a touch story for a list a teacher reorders twice a year. Two buttons are the same operation, accessible by construction |
| W7-6 | WP7 Tests name Ctrl+Shift+M without saying what it does |
It toggles the student preview panel of the question editor | Ctrl+S saves and Ctrl+Shift+P publishes; the third shortcut belongs to the third thing the editor screen does, which is showing the teacher what the student will read |
| W7-7 | §1.4 types every surface as ComponentType<EditorProps<…>> |
questionTypes.tsx casts the registry's Editor / Player / Review to a permissive props type before mounting them |
AnyQuestionTypeClient erases the five type parameters, so issues, strings and renderMarkdown are invisible through it. Every value passed is still built from the package's own exported defaults, and questionTypes.test.tsx fails on a renamed or untranslated key |
| W7-8 | W2-3 defines one MarkdownRenderer signature for every surface |
The host injects two: a block renderer for the editors' preview, and <MarkdownView as="span"> for the players and the reviews |
A statement preview is a document; a prompt inside a <legend>, a <p> or a table cell is phrasing content, and a <div> there is invalid HTML the browser reparents |
| W7-9 | §6.2 bulk actions on the selected questions | Sequential single-question calls, one per selected row | WP4 exposes no bulk route, and a loop keeps the per-question authorisation, the per-question error and the existing audit entries. A bulk endpoint can replace the loop without touching the screen |
| W7-10 | Mockup 08-pool.html gives the question table a "Réussite" column |
Dropped | The success rate comes from item analysis, which §0 puts out of the MVP. A column that would always read "—" is a promise the product does not keep |
| W7-11 | §4.2 asset upload | api() sets no Content-Type when the body is a FormData |
The browser must write the header itself to add the multipart boundary; a hand-set multipart/form-data produces a body the server cannot parse |
| W7-12 | N-I18N-01 / W2-3 have apps/web translate the packages' strings |
i18n.tsx carries the four packages' dictionaries — about 390 keys — and ships in the entry chunk |
The dictionaries are plain data the host owns; splitting them per type would fetch a second file before an editor can render its own labels. The surfaces themselves stay lazy, which is what N-PERF-05 asks Superseded on 2026-09-23 (PR #56): i18n.tsx is now i18n/{en.ts,fr.ts,index.tsx}; the dictionaries still ship in the entry chunk. |
WP6 — apps/api modules grading + results + jobs¶
| # | Plan | Implemented | Why |
|---|---|---|---|
| W6-1 | §3.7 delivers the MVP as 0001_base + 0002_quiz |
0003_grading.sql on top of W5-1's 0002_evaluation |
The supervisor override forbids editing 0000_init.sql; each work package appends its own migration |
| W6-2 | §3.5 gradings.answer_id uuid → answers nn, partial unique (answer_id) where state='validated' |
answer_id is NULLABLE, attempt_id is denormalised next to item_id, and a SECOND partial unique index (attempt_id, item_id) where state='validated' joins the one the plan names |
F-GRADE-01 grades an answer that does not exist ("une réponse absente vaut 0") and there is no answers row to hang it on. Writing one would mean the grading module writing the live module's table, which the conventions forbid. answers is itself unique on (attempt_id, item_id), so the two indexes can never disagree, and the index of §3.5 still holds for every answer that exists |
| W6-3 | §4.5 POST /answers/:answerId/gradings is the manual override |
Kept, plus POST /gradings/:id/override with the same body and the same service call |
A cell with no answer row (W6-2) has no answerId to address. The panel entry always carries a grading once the pass has run, so the grading id is the address that exists for every cell |
| W6-4 | §4.5 POST /grading/run → 202 { jobId } |
202 { evaluationId, itemIds, queued } |
Neither pg-boss's send nor the in-process development runner hands back a job id the client could do anything with. queued: false says the pass ran inline (W6-5) |
| W6-5 | §5.4 the pass always goes through the queue | With no queue — JOBS_DISABLED=1, every route test, a database unreachable at boot — enqueueEvaluationGrading RUNS the pass inline instead of dropping it |
A missing queue is a real configuration, not a failure. The queue is what makes the work durable and retried, not what makes it happen; and it makes the job assertable in a test with no timer and no sleep |
| W6-6 | §5.4 "POST /close or ticker auto-close → enqueue" without naming the seam |
closeEvaluation(db, evaluation, now, closedBy, app?) enqueues when the caller hands over the Fastify instance; the close route and the ticker's autoCloseDue both do |
The hook lives IN the transition, so the two entry points cannot drift apart, and the service keeps taking a Db rather than an app for everything else |
| W6-7 | §4.5 lists POST /evaluations/:id/release in the grading table |
It is a results route, next to the grade table it freezes |
§4.6 is where the release is specified and where released_grades is computed. One module owns the release, and it is the one that owns the grade |
| W6-8 | §4.6 GET /student/attempts/:id/results |
GET /attempts/:id/feedback is the route, and the only one |
The WP6 brief names /attempts/:id/feedback. The plan's path was first mounted on the same handler as an alias; nothing but its own test ever called it, so it was removed on 2026-09-22 (audit B-11), like the SSE alias of W5-6 |
| W6-9 | §4.6 StudentResults |
A discriminated union StudentFeedback = StudentResults \| FeedbackPending on available |
"Before the release the student sees nothing but 'results pending'" has to be a payload the client can branch on without guessing, and the negative case must carry NO question content at all — not a StudentResults with nulls everywhere |
| W6-10 | §4.6 ResultsView.rows[].state |
absent \| not_started \| in_progress \| submitted \| expired, and the rows are the class list, not the attempt list |
F-RES-02 exports "un étudiant par ligne". An absent student is a 1.0 that must appear in the export AND in the statistics, or every class average is flattered |
| W6-11 | F-GRADE-09 "une re-correction après publication met à jour les notes" | The student is shown the RECOMPUTED grade; released_grades is the record of what was published, and modified_after_release is what tells the teacher the two have drifted apart |
A frozen number the student sees and a corrected one the teacher sees would be the dispute the flag exists to prevent |
| W6-12 | §4.6 "details filtering for code" |
filterDetails(type, details, policy) in modules/results/service.ts dispatches on the type id and calls studentDetails from @quiz/qt-code/server |
QuestionTypeServer has no per-type details filter (deviation W3-5 put the redaction in a pure function instead), and adding one to the contract is WP1's call, not WP6's. code is the only MVP type whose details hold a secret |
| W6-13 | §3.5 llm_calls "phase 2 — not created in MVP"; settings (key, value) |
llm_calls and answer_flags ARE created, empty, with no route; settings is NOT created |
The WP6 brief asks for the two grading-side tables, and creating them now settles their column set. settings is the admin module's (F-ADMIN-03) and no route of this work package reads it |
| W6-14 | §4.5 GradingQueue.entries[].answerId: z.uuid() |
.nullable(), same reason as W6-2 |
An absent answer is an entry of the panel like any other; the teacher must be able to override the zero |
| W6-15 | §4.6 has no un-release | POST /evaluations/:id/unrelease |
A release published with a wrong key has to be withdrawable, and doing it by hand in the database is not an operation. Audited as results.unrelease |
| W6-16 | §4.6 ByQuestion.avgMs |
Always null |
Nothing records the time a student spent ON ONE ITEM: attempt_events journals visibility and runs, not per-question dwell time. The field stays in the contract so the screen does not change shape when WP12 adds the measurement |
| W6-17 | §3.6 catalogue | Adds grading.run, grading.validate and results.unrelease next to grading.override, grading.regrade, results.release and results.rerelease |
Same posture as W4-10: every write is audited. Validating a batch of forty proposals is a grading decision, and so is pressing "re-grade" |
| W6-18 | §5.4 regrade "previous ones become superseded" |
The regrade route stands every non-superseded grading of the item down BEFORE enqueuing the pass | The pass is idempotent by design (it skips a validated cell); a regrade that did not stand the old gradings down would therefore be a no-op. Nothing is deleted: the chain is the history §4.5 serves |
| W6-19 | §4.5 history is "the gradings of this answer" |
Ordered newest first, with superseded as the tiebreaker at equal graded_at |
The automatic pass and the override a teacher presses right after it can land in the same millisecond — certainly on the TestClock, which does not move by itself. A superseded grading is never newer than the one that replaced it |
| W6-20 | W5-21 /student/home past |
EvaluationCard gains grade: number \| null, filled for a released evaluation |
The field WP5 said WP6 would add, added where WP5 left room for it. null everywhere else: an unreleased evaluation must not leak the grade it would have |
| ### WP9 — Web: student home, lobby, zen player |
| # | Plan | Implemented | Why |
|---|---|---|---|
| W9-1 | §6.3 keeps StudentHome where it is |
apps/web/src/StudentHome.tsx (the classroom list) is DELETED; src/student/StudentHome.tsx replaces it with the three sections of /student/home plus the classroom list and the join-by-code card as its last section |
The student's home is their evaluations now (mockup 05). Two components called "student home" would have left the sidebar, the palette and App.tsx pointing at the old one. The sidebar row is renamed with it (shome.title, "Home" / "Accueil") |
| W9-2 | §6.5 realtime/useEventStream.ts seeds the query cache from snapshot |
attempt/attemptStream.ts: the same named frames, but a snapshot feeds the CLOCK and nothing else |
WP8 owns the shared hook (the supervisor unifies the two). A snapshot written into the cache while a student is typing would replace their own unacked answers with the server's older copy — the exact loss Autosave exists to prevent Superseded on 2026-09-23 (PR #55): attemptStream.ts is deleted; the player and the lobby use realtime/useEventStream.ts, one client per page. |
| W9-3 | §6.3 the lobby shows three fixed lines, the first being the navigation rule | The navigation line is shown only when the caller knows the setting; otherwise a clock line takes its place | LobbyView (§4.4) carries no settings, and a student in the lobby has no attempt to read them from. Announcing "free navigation" without knowing would be worse than not announcing it. Superseded 2026-09-23 (audit FF-22): LobbyView carries navigation, the rule is always the first line and the clock fallback is gone |
| W9-4 | §8 WP9 injects the host's MarkdownView into every type's player |
Injected as renderMarkdown into mcq and short. cloze keeps its own ClozeFallbackText, and code renders its prompt as plain text |
renderText (W2-14) must place a React input AT a sentinel; the host's pipeline produces sanitised HTML, which cannot host React children. CodePlayerProps accepts no renderer at all (W3-6 lists onRun and strings only) |
| W9-5 | — | MarkdownView gains inline, and style.css a .md-inline block |
Every place a question type calls the injected renderer is already a <p>, a <legend> or a <label>. A <div> inside a <p> is invalid markup that the browser repairs by closing the paragraph early, which moves the text the student is reading. inline renders a span and drops the wrapper of a single-paragraph source |
| W9-6 | §6.1 routes the attempt like any other page | /take/:id renders OUTSIDE Shell: no sidebar, no header |
A zen player (one question, one action) beside a navigation sidebar is not a zen player, and an exam is the one screen where the rest of the app must go away. Every other route keeps the shell |
| W9-7 | §4.4 / W5-11 POST /attempts/:id/run answers 202 { requestId, result } |
The host maps that result to the RunnerOutcome the code player expects (exitCode = ok ? 0 : 1, oom unavailable), and treats 429 rate_limited like 503 |
The SSE result shape (named cases, server-side verdict) and the runner shape (exit codes, per-case flags) are two different contracts; one of the two has to be adapted, and the host is the only place that knows both. A rate-limited run is "not now", which is what the degraded message already says |
| W9-8 | §8 WP9 has no results screen; §6.3 lists StudentResults |
student/Feedback.tsx is a "result not published yet" screen behind TODO(WP10) |
packages/contracts carries no GET /attempts/:id/feedback: the results module is WP10's. The route exists and the home page links to it, so WP10 fills a page instead of adding one |
| W9-9 | — | apps/web gains @quiz/core and @quiz/registry (dependencies) and axe-core (dev) |
The player mounts the type's Player through the client registry, and the DoD asks for an axe check on it |
| W9-10 | §8 WP9 lists SyncBadge, ProgressSegments, Ring and Countdown as additions to ui.tsx |
They were already there; WP9 consumes them unchanged | They landed with the primitive gallery. The player is their first real caller, and none of them needed a change Superseded on 2026-09-23 (PR #36): ui.tsx is split; these four live in ui/live.tsx. |
| W9-11 | Mockup 07 shows the question's TYPE beside its points | Only the points are shown | The type labels are qt.<id>.label keys that no dictionary holds yet; they belong to the question editor (WP7), and inventing them here would collide on the merge |
| ### WP8 — Web: evaluation configuration + live dashboard |
| # | Plan | Implemented | Why |
|---|---|---|---|
| W8-1 | §6.1 routes carry a tab in the union ({ view: "evaluation"; evaluationId; tab }) |
{ view: "evaluation"; id } and { view: "live"; id }; the step lives in ?step= through the existing useSearchParam |
The shipped router names its parameter id on every route and already has one mechanism for in-page tabs. Two ways to say "which tab" would be one too many, and the step has to survive a reload without adding a path segment per step |
| W8-2 | §6.5 useEventStream(watch) is a hook |
A hook over ONE module-level connection shared by every subscriber | The shell mounts useLiveUpdates on every screen and the dashboard mounts its own watcher on top. Two EventSources would mean two presence records, two snapshots and two clocks a round trip apart. live.ts now sits on the same connection and its behaviour is unchanged |
| W8-3 | §6.5 "a 60 s safety refetch" | Only for a WATCHER; the hint-only shell stream does not get one | Invalidating every query of the app once a minute is polling, which is the thing ADR-005's hint replaced. A watcher refetches its own query and nothing else |
| W8-4 | §4.8 snapshot seeds the cache |
It seeds it only while the answers toggle is OFF; with answers on it triggers a refetch of ?includeAnswers=1 |
The server builds the snapshot with includeAnswers: false (modules/realtime/routes.ts), because the stream does not know the teacher's toggles. Seeding from it would blank every answer on screen |
| W8-5 | — | The dashboard query uses keepPreviousData |
The answers toggle changes the query KEY. Without it, hiding the answers before projecting blanks the grid to a skeleton at the worst possible moment |
| W8-6 | §6.2 InspectPanel shows the answer through type.Review |
Resolved: InspectModal renders through QuestionReviewHost, which injects the inline MarkdownView |
Closed once MarkdownView gained its inline form; prompts now render in the teacher's modal exactly as the student sees them |
| W8-7 | §6.2 StudentGrid row actions |
The row's overflow menu is inside the STICKY first column, not at the end of the row | DESIGN.md puts actions in the last column; in a matrix that scrolls sideways the last column is off screen. An action a teacher has to scroll to reach is an action they do not take while twenty-four people are waiting |
| W8-8 | §6.2 ItemsStep has a drag handle |
Two arrows per row instead | Dragging thirty rows is pleasant with a mouse and impossible with a keyboard, and the order is exactly what a teacher tweaks one step at a time |
| W8-9 | §6.8 "contextual evaluation actions when the live screen is mounted" | commands.ts gains a small registry (registerContextualCommands) that buildCommands folds in; the dashboard registers into it |
The Shell builds the palette and has no access to a mutation living two components down. Threading live controls through the frame of every page to reach the palette is worse than a registry Superseded on 2026-09-23 (audit FC-03, PR #28): the contextual registry is gone; the dashboard registers through useScreenCommands like every other screen. |
| W8-10 | — | apps/web gains @quiz/core and @quiz/registry as dependencies |
The preview and the inspection panel render a question through its type's Player/Review, which is what the client registry is for (decision D1) |
| W8-11 | — | The mock addresses an evaluation by its STATE as well as by its id (/evaluations/running/live) |
Ids are UUIDs now (the SSE client validates every frame against the contracts), and a screenshot scene cannot name a generated UUID. The real API only knows uuids, and so does every id the mock puts on the wire |
| W8-12 | — | The grid's scroll container is relative |
The accessible names in the cells are sr-only, which is position: absolute. Without a positioned ancestor their containing block is the page, so they escaped the scroll container and stretched the DOCUMENT to the width of the grid — a page scrolling sideways on a phone, which is what the sticky column exists to prevent |
WP10 — Web: grading panel + results + export¶
| # | Plan | Implemented | Why |
|---|---|---|---|
| W10-1 | §4.6 StudentResults.items[] carries no type id |
StudentResultItem.type added to packages/contracts/src/results.ts and filled by studentFeedback() |
The feedback page mounts the question type's own Review, and without the id the browser would have to guess the type from the SHAPE of student — exactly the kind of reconstruction invariant 8 and the §5.7 gate exist to avoid. The id is not a secret: the student already saw the widget it names |
| W10-2 | §6.1 { view: "results"; tab } and { view: "studentResults"; attemptId } |
{ view: "results"; evaluationId } with the tab in ?tab=, and { view: "feedback"; attemptId } at /attempts/:id/feedback |
useSearchParam is how every other screen of this app holds a tab, and the route mirrors the endpoint the WP6 brief actually shipped (GET /attempts/:id/feedback, deviation W6-8) |
| W10-3 | §8 WP10 "batch validate … of this filter" | The batch bar appears in the BY QUESTION traversal only | BatchValidateBody scopes a batch by itemId, source and confidence — there is no attemptId. In a by-student traversal the only honest scope would be the whole evaluation, which is not what the button would appear to promise |
| W10-4 | §6.2 GradingHeader "single accent action = batch validate" |
The accent moves: "Run grading" holds it while NOTHING has been graded, the batch validation once anything has | With a pass half-done both were red at once, and the squint test then names neither. Running the pass is the only thing the screen can offer on an ungraded evaluation; after that, validating is the work |
| W10-5 | §6.2 EntryDetail shows the answer, the points field and the comment field inline |
Points and comment live in the override Sheet; the detail shows Validate, Adjust and the history |
.claude/skills/quiz-ui/SKILL.md rule 4 puts a form in a layer, and an editable points field on each of thirty rows is thirty forms open at once. Validating one still takes a single click (or v) |
| W10-6 | §6.5 the realtime client is useEventStream |
grading/progress.ts opens its own EventSource for the single named grading.progress frame |
The shared stream hook belongs to the live dashboard (WP8). grading.progress is a NAMED frame, which onmessage never receives (deviation W5-7), so the plain hint channel of live.ts cannot move a progress bar. One event, one query, one screen — and it steps aside the day the general hook lands |
| W10-7 | §6.2 GradingPanel reads the queue |
It also reads GET /evaluations/:id (items, title) and GET …/results/by-question (explanations) |
GradingQueue carries neither the evaluation's title nor the question's explanation, and the panel needs the item list before it can scope its first request. The by-question read is an aid: it never produces an error state, it only adds the explanation when it arrives |
| W10-8 | F-RES-03 "énoncé, bonne réponse" for every type | The per-question view prints a code question's solution.referenceSolution itself |
A type's Review renders its key BESIDE a graded answer, and a class debrief has no answer to show. The one documented field of the payload is read directly rather than fabricating a grading to make the component talk |
| W10-9 | — | results.csv is an <a download>, not a fetch |
It is a GET the browser already knows how to do, with the session cookie and the server's own content-disposition filename. A blob would cost a second copy in memory and lose the filename |
| W10-10 | §6.1 keeps WP9's /results/:id beside the feedback page |
WP9's { view: "studentResults" } route is DELETED; { view: "feedback"; attemptId } at /attempts/:id/feedback is the only student results page, and WP9's callers (the home's past cards, the player's finished screen) go through feedbackLink() |
Two routes to one page is two places to keep in step, and the pre-release case is the same screen: GET /attempts/:id/feedback answers available: false and the page renders that. The tails of /evaluations/:id/… are parsed in ONE switch in router.ts, with evaluation as the fallback |
| W10-11 | §8 WP10 mocks its own evaluations (e1, e2) |
The browser mock has ONE set of evaluations. A GRADING WORLD is derived from the closed and the released evaluation of WP8's section 3 — same ids, same items, same dashboard rows — and holds only what the first half has no use for: the answers and the chain of gradings |
Two parallel sets meant two GET /app/api/evaluations/:id registrations, the last silently winning, and a grading queue whose item ids matched nothing the configuration screen listed. Derived, /evaluations/<id>/grading opened from the classroom list is the screen the panel links to, and the results' absent rows are the live grid's rows without an attempt (deviation W6-10) |
Security pass (2026-09-20, after the API review)¶
| # | Plan | Implemented | Why |
|---|---|---|---|
| S-1 | GET /attempts/:id returns AttemptView |
Returns the AttemptOrLobby union ({kind:"lobby"} until the evaluation is running; attempt.readOnly afterwards) |
C1: no question content may leave the server before the start |
| S-2 | trustProxy: true |
TRUSTED_PROXIES (address list, default loopback + 172.16.0.0/12) |
H2: a forged left-most X-Forwarded-For bypassed the IP allowlist; a hop count is fail-closed on this Fastify |
| S-3 | Feedback strips code details only |
QuestionTypeServer.studentDetails(details, policy) hook in every type + a recursive forbidden-key strip when showKey is false |
H1: mcq/cloze/short keys were published inside gradings.details |
| S-4 | Auto-close at closesAt |
Auto-close at max(closesAt, last in-progress deadline) + GRACE_MS; extend all moves closes_at in deadline timing |
H4: accommodations were voided by the ticker |
WP11 — apps/runner (HTTP service, Podman)¶
| # | Plan | Implemented | Why |
|---|---|---|---|
| W11-1 | §8 files: src/{server,engine,queue,limits}.ts |
src/{server,app,config,engine,probe,execute,languages,images,queue,routes}.ts; no limits.ts |
The limits are three numbers of RunnerRequest, applied where they belong (--memory in engine.ts, the clocks in execute.ts); a module for them would only be a place to forget one. app.ts/server.ts split like apps/api, so a test builds the app without listening |
| W11-2 | One podman run per request implied |
One CONTAINER per request, sleep <ttl> its only process, driven by podman exec: files in on stdin, build, then each case |
A compiled artifact has to survive from the build to the cases, and nothing from the host may be mounted. One container start (~300 ms) per request instead of one per case is also what makes the p95 of thirty runs 635 ms |
| W11-3 | Hardening list of run-hardened.sh verbatim |
The same list minus --dns=none and --security-opt apparmor=…, plus --memory-swap equal to --memory |
Podman refuses --dns=none with --network none; the AppArmor profile exists to let gdb ptrace in a codespace and nothing here debugs. Without --memory-swap a memory bomb pages instead of dying, and oom never comes |
| W11-4 | --userns=auto always |
RUNNER_USERNS_AUTO=auto: always on rootful, probed once at startup rootless, dropped with a log line when the engine cannot do it |
Production is rootful and keeps the flag; a development workstation may have too small an /etc/subuid range, and silently dropping a hardening flag without saying so is how a hardening flag disappears |
| W11-5 | apps/runner/infra/seccomp/codespace.json (copied) |
apps/runner/infra/seccomp/runner.json, byte for byte the same profile |
It is this project's profile now; the name says whose it is. _from-codespace/ is deleted and credited in apps/runner/README.md |
| W11-6 | — | POST /run answers 503 language_unavailable for a language with no image |
The runner never pulls (no registry credential, and in production no route to one), so a missing image is a deployment fact. 503 is what the API already maps to RunnerUnavailable, which degrades to a proposed grade (D14) |
| W11-7 | GET /health returns languages and queue stats |
RunnerHealth plus running, concurrency, queueMax and an engine object (version, rootless, remote, userns, runtime, cgroups) |
RunnerHealth.safeParse on the API side drops what it does not know, so the contract is untouched and GET /admin/runner and a human curl get the whole truth |
| W11-8 | §2.4 per-case timeMs |
Every case gets limits.timeMs; the tighter per-case budget stays where deviation W3-2 put it, in finalizeRunnerCode |
RunnerRequest.cases[] carries only {name, stdin}: the wire has one budget, and the runner cannot enforce a limit it is not told about |
| W11-9 | — | ms is measured around the podman exec call, engine overhead included (~100 ms) |
Measuring inside the container would mean trusting the sandbox's clock or polluting the program's stdout. Documented in apps/runner/README.md: a per-case budget below ~200 ms is not useful |
| W11-10 | Integration tests run per language | They run for c and python, and skip themselves without a reachable Podman or without the image |
cpp and js share the same code path (their images are built and served all the same); rust is a 700 MB toolchain that images/build.sh only builds when named |