Skip to content

Architecture

How the system works and how the code is organised, in eight diagrams. Each one is followed by a short explanation and a link to the text that settles it: a section of the architecture chapter of the specification, or a decision record. Where to find a file is the job of the repository layout page; this one is about the moving parts.

System context

The evaluation execution path uses one API process, one PostgreSQL and one runner service. In production the runner sits on a second VM; everything else shares one. The browser talks to Caddy only. This diagram covers the evaluation path. GitHub projects/journals, the LLM gateway and notification providers are additional integrations, described in spec 05 and their ADRs.

flowchart TB
  subgraph API["API monolith (Fastify, port 3000)"]
    direction LR
    R["Routes and services"]
    E["SSE streams"]
    K["Ticker, every second"]
    Q["Job queue (pg-boss)"]
  end
  T["Teacher browser"] --> C["Caddy (TLS, HTTP/2)"]
  S["Student browser"] --> C
  P["Phone on a poll"] --> C
  C --> API
  API --> DB[("PostgreSQL 17")]
  API -- "OIDC" --> IDP["Identity provider: Switch edu-ID, Keycloak in dev"]
  API -- "POST /run" --> RUN["Runner service (own VM in production, :3200 under pnpm dev)"]
  RUN -- "podman --remote" --> POD["One hardened container per request"]

Caddy terminates TLS and speaks HTTP/2 to the browser, which is what lets a page hold a long-lived SSE stream without eating one of the six HTTP/1.1 connections. The API is a modular monolith: one Fastify process serves the routes, the built SPA and the event streams, and runs the ticker and the job queue in the same process. The runner is a separate service reached by the API alone. In production it runs on a VM of its own, code.chevallier.io, behind that VM's Caddy, over HTTPS with a shared bearer token (ADR-016); pnpm dev starts it on the same machine, on port 3200, with no token. Either way it drives Podman over a socket to create one container per request. In production the identity provider is Switch edu-ID; in development it is a Keycloak realm from infra/keycloak, or the dev login. Settled by 5.1 Technical stack, 5.9 Deployment and ADR-001.

Repository and package graph

The diagram shows question-type dependency boundaries. Consult the repository map and workspace manifests for the complete package inventory, including journal rendering; this is not a package count.

flowchart TB
  API["apps/api"]
  WEB["apps/web"]
  RUNNER["apps/runner"]
  REG["registry (./server, ./client)"]
  CON["contracts"]
  DOM["domain"]
  CORE["core (./server, ./client, ./rng)"]
  UI["ui"]
  subgraph QT["the question types"]
    direction LR
    MCQ["qt-mcq"]
    SHORT["qt-short"]
    CLOZE["qt-cloze"]
    CODE["qt-code (code, codeimage)"]
    CIRCUIT["qt-circuit (./canvas too)"]
    RICH["qt-rich"]
    CATEGORIZE["qt-categorize"]
    DIAGRAMQT["qt-diagram"]
    BLOCKS["qt-blocks (Blockly, lazily)"]
    MATH["qt-math"]
  end
  DIAGRAM["diagram (the engine)"]
  API --> CON
  API --> REG
  API --> DOM
  WEB --> CON
  WEB --> REG
  WEB --> DOM
  RUNNER --> CORE
  REG --> QT
  REG --> CORE
  QT --> DOM
  QT --> UI
  QT --> CORE
  DIAGRAMQT --> DIAGRAM
  DIAGRAM --> UI
  DIAGRAM --> CORE
  UI --> CORE
  DOM --> CORE
  CORE -. "never: this arrow would be the cycle" .-> REG

core sits at the bottom: the QuestionTypeServer and QuestionTypeClient contracts, the seeded RNG and the runner interface, with no React at runtime in either entry point. Every qt-* package implements that contract, depends on domain for its pure rules and on ui for the primitives its editor and player share with the other types (ui depends on core only and never imports a qt-* package). registry is the only package that imports the qt-* packages: it is the static map from a type id to its implementation, split in ./server and ./client so the API never loads a component. If core held the registry, core would import qt-mcq and qt-mcq would import core, which is the cycle decision D1 breaks by moving the wiring one package up. The apps also depend on some qt-* packages directly (apps/web on all eleven, and on the diagram engine for its gallery, apps/api on qt-code, and both on core), which is omitted above because the registry already carries the edge. Settled by 5.2 Code modularity and decision D1 of the MVP plan.

API module map

Modules live under apps/api/src/modules/. A module never imports another module's routes.ts; it calls its service.ts. The arrows below are the service-level imports that actually exist in the code, kept to the ones that carry meaning.

flowchart TB
  ORG["org: courses, classrooms, rosters"]
  POOL["pool: questions, versions, assets"]
  EVAL["evaluation: items, settings, state table"]
  LIVE["live: attempts, answers, clock, studentView"]
  GRADING["grading: the pass, the panel"]
  RESULTS["results: grades, release, feedback"]
  POLL["poll: one question, a code, guests"]
  NOTIF["notifications"]
  RT["realtime: bus, SSE, presence"]
  RUNNERC["runner client (HttpRunner or stub)"]
  PREVIEW["preview: the teacher's stateless run-through"]
  MCP["mcp: the MCP server"]
  ROUTES["the /app/api routes"]
  ORG --> POOL
  EVAL --> LIVE
  LIVE --> EVAL
  LIVE --> GRADING
  LIVE --> RUNNERC
  GRADING --> EVAL
  GRADING --> LIVE
  GRADING --> RESULTS
  GRADING --> RUNNERC
  RESULTS --> GRADING
  RESULTS --> LIVE
  POLL --> EVAL
  POLL --> LIVE
  POLL --> POOL
  POOL --> NOTIF
  POOL --> LIVE
  RT --> LIVE
  LIVE --> RT
  PREVIEW --> EVAL
  PREVIEW --> LIVE
  PREVIEW --> GRADING
  MCP -. "in-process HTTP, the caller's token" .-> ROUTES

Three edges deserve a word. live and evaluation point at each other on purpose: the legality of a state change is evaluation's table, and the side effects on the attempts (starting them, moving their deadlines, expiring them) are live's, which calls back into applyState. live/studentView.ts is the one exit of question content towards a student (invariant 4), which is why pool, poll, grading and results all import it. pool/config.ts is the only file that imports @quiz/registry/server; every module that needs a question type goes through it, and those edges are left out above. Every live event is built and addressed in realtime/bus.ts; live, grading, poll, pool, results and notifications all call one of its named functions and never touch the emitter, and only the live edge is drawn. realtime calls back into live for the snapshot a stream opens with. preview is the teacher's preview of a whole evaluation: it draws a seed and reuses live's student view and grading's graders without writing a row (ADR-018). mcp imports no service at all: each MCP tool calls the same /app/api routes the web app calls, in process, with the caller's bearer token, so the access loaders, the contract validation and the audit apply unchanged (ADR-022). The roster import lives in org (org/roster.ts). auth (apps/api/src/auth/), guards.ts, http.ts and the two single-file plugins (admin.ts, avatar.ts) are omitted. Settled by 5.2 API modules and ADR-001.

The lifecycle of an evaluation

The state machine is a table, TRANSITIONS in apps/api/src/modules/evaluation/service.ts. A teacher action goes through one route per transition; the ticker moves an evaluation when a time comes.

stateDiagram-v2
  [*] --> draft: teacher creates
  draft --> scheduled: teacher schedules
  draft --> lobby: teacher opens the lobby
  draft --> running: teacher starts
  scheduled --> lobby: ticker at opens_at
  scheduled --> running: ticker at opens_at, lobby skipped
  scheduled --> draft: teacher
  lobby --> running: teacher starts, or ticker once everybody is present
  lobby --> draft: teacher
  running --> paused: pause, exam only
  paused --> running: teacher resumes
  running --> closed: teacher closes, or ticker past closes_at
  paused --> closed: teacher closes, or ticker past closes_at
  closed --> released: teacher releases
  released --> closed: teacher withdraws
  closed --> draft: teacher reopens, no attempt exists
  closed --> grading: listed, unused
  grading --> released: listed, unused
  released --> [*]

A poll takes the short path: POST /app/api/polls creates the evaluation already running, with one item and a session code, and POST /app/api/evaluations/:id/poll/end moves it to closed without ever releasing it, because its result is the tally on the beamer. The ticker handles three times: opens_at sends a scheduled evaluation to lobby (or straight to running when the lobby is skipped), a lobby in auto mode starts when every enrolled student is present, and closes_at plus the last deadline anybody holds, plus the 3 s grace, closes a running or paused evaluation. Closing, by a teacher or by the ticker, expires every open attempt and enqueues the grading pass in the same function. The grading state exists in the enum and in the table for the results module to tolerate it, but no code path in the repository moves an evaluation into it. Settled by 1.4 Lifecycles, ADR-006 and, for the poll, ADR-014.

One answer, live

What happens between a keystroke and the teacher's grid, and what happens when the deadline passes.

sequenceDiagram
  participant S as Student browser
  participant A as API
  participant B as Event bus
  participant T as Teacher dashboard
  participant K as Ticker
  Note over S: keystroke, 300 ms debounce, revision + 1
  S->>A: PUT /app/api/attempts/:id/answers/:itemId
  Note over A: receipt time is the server clock
  Note over A: upsert where revision < excluded.revision
  A-->>S: 200 accepted, revision, serverNow
  A->>B: dashboard.cell, coalesced 250 ms
  B-->>T: SSE dashboard.cell, staff only
  Note over K: every second, expire attempts past deadline + 3 s
  K->>B: attempt.closed, closedBy server
  B-->>S: SSE attempt.closed
  S->>A: PUT a late autosave
  A-->>S: 410 attempt_closed, reason, serverNow

The client never has more than one request in flight per item; the next one waits and leaves with the latest payload. The server's revision guard makes the write last-writer-wins: a stale write is answered with the stored payload, which the client adopts. Every response carries serverNow, and the client keeps the median of the last five offsets, corrected by half the round trip, to drive the countdown. The receipt time is the server's, the deadline is closed by the ticker, and a write later than deadline + 3 s is refused with 410 attempt_closed; the same GRACE_MS from @quiz/domain serves both the gate and the ticker. Settled by 5.4 Real time (autosave and clock) and ADR-006.

Grading and release

Closing an evaluation starts the automatic pass. The teacher then validates what the machine could only propose, and the release freezes the grades.

flowchart TB
  CLOSE["closed, by the teacher or the ticker"] --> JOB["job grading.evaluation, idempotent, one per request"]
  JOB --> CELL{"for each attempt and item"}
  CELL -- "a validated grading stands" --> SKIP["skip"]
  CELL -- "no answer" --> ZERO["0 points, validated"]
  CELL -- "deterministic type" --> AUTO["auto grading, validated"]
  CELL -- "code" --> RJOB["job grading.runner, low priority"]
  RJOB --> RUN["POST /run on the runner"]
  RUN -- "outcome" --> FIN["finalizeRunnerCode, validated"]
  RUN -- "runner unavailable" --> PROP["proposed, with a reason"]
  CELL -- "essay, with an LLM service" --> LLM["app.llm.grade: proposed, with a confidence"]
  LLM --> PANEL
  CELL -- "needs a human" --> PROP
  PROP --> PANEL["grading panel: validate, adjust, override with a comment"]
  PANEL --> WRITE["writeGrading: supersede the previous one, one transaction"]
  AUTO --> REL
  FIN --> REL
  WRITE --> REL["release: released_grades and released_at, one transaction"]
  REL --> FB["student feedback, through the feedback policy"]
  WRITE -. "after the release" .-> FLAG["modified_after_release, re-release"]

A grading is never updated in place: writeGrading supersedes the standing one and links to it, so every cell keeps its history. Every job is idempotent, because it checks for a validated grading before writing, and a machine with no container engine still closes, grades and releases; its code answers simply arrive in the panel as proposals. The grade freeze has two steps, which is the property results/service.ts takes from ADR-012: the gradings are the live, recomputable truth, and the release writes the frozen snapshot released_grades and released_at together in one transaction. A correction landing after that does not change the snapshot; it flags the evaluation modified_after_release, and the teacher re-releases. Feedback reaches a student only through studentFeedback, which applies the evaluation's policy (none, on_release, immediate). Settled by 5.6 Grading, 5.3 Transactions and ADR-012, written for the sibling project and carried over for its freeze property.

A code question, two runners

A student's Run button and the grade take two different paths on purpose. The browser runs a trial; the server computes the mark.

flowchart LR
  subgraph BROWSER["Browser trial run (an essay)"]
    direction TB
    RUNBTN["Run button, runtime = runno"] --> ASM1["source assembled from the student view segments"]
    ASM1 --> WORKER["Web Worker, Runno WASI, 2 s wall clock, no network"]
    WORKER --> SHOW["verdict shown to the student, never stored"]
    RUNBTN -. "no worker, other language" .-> FALLBACK["POST /app/api/attempts/:id/run, the backend trial"]
  end
  subgraph SERVER["Server grade (the mark)"]
    direction TB
    PASS["grading pass, gradeCode"] --> ASM2["source rebuilt from the STORED template and regions, with the teacher's compileArgs and hidden cases"]
    ASM2 --> HTTP["POST /run on apps/runner"]
    HTTP --> CONT["podman run --network none --read-only --userns=auto --cap-drop=ALL, seccomp, tmpfs /work"]
    CONT --> EXEC["sources on exec stdin, compile, one exec per case"]
    EXEC --> VERDICT["finalizeRunnerCode: validated grading"]
  end
  BROWSER ~~~ SERVER

The browser path exists because a printf should not need a container, and thirty students fiddling should not spend the runner's slots. It runs the program in a Web Worker so that terminate() is the stop button, with the .wasm runtimes served from this origin, and it only ever sees what toStudent already publishes: the visible cases, the extra files and compileArgs (public program inputs, ADR-096), never the hidden cases. Its result is labelled an essay, because WASI is not Linux and nothing signs the page. The grade always goes through ctx.runner: the source is assembled from the stored template and the student's editable regions (invariant 14), and one hardened container is created per request with the flag list containerArgs asserts. Nothing from the host is mounted; the sources travel in on podman exec's stdin, and /work is a tmpfs that dies with the container. Settled by ADR-015, 5.5 Runner and the runner invariants in CLAUDE.md.

Real time

One SSE stream per page, GET /app/api/events, with an optional ?watch=evaluation:<id> or ?watch=attempt:<id>. Writes never travel on it; they are REST requests.

sequenceDiagram
  participant V as Browser view
  participant A as API
  participant B as Event bus
  V->>A: GET /app/api/events?watch=evaluation:ID
  A->>A: load access, compute the topics: user, teacher, classroom, evaluation, attempt
  A-->>V: :connected, then event snapshot with the whole watched state
  loop every 10 s, 1 s on a running attempt
    A-->>V: event clock {serverNow}
  end
  loop every 25 s
    A-->>V: :ping
  end
  B->>A: a message with its topics and audience
  A-->>V: named frame if a topic matches and the audience allows
  Note over V: 30 s without a clock: close and reopen
  Note over V: refetch the query every 60 s regardless
  V->>A: reconnect: the snapshot is sent again, no replay

Authorisation is loaded once, at connection time: the topic set is computed from the user's seats and enrolments, and a dashboard.* frame is dropped for a student even when they watch the same evaluation:<id>. A hint frame is unnamed and carries no data; the client refetches its queries. A live event is a named frame with a typed payload from contracts, applied directly to the client state, and the noisy ones (dashboard.cell, dashboard.presence, lobby.count, poll.tally) are coalesced in the bus over a window per key. There is no Last-Event-ID and no replay buffer: a lost event is repaired by the snapshot on the next connection or by the safety refetch, and presence lives in memory in the realtime module. Settled by 5.4 Real time and ADR-005.