ADR-031 — Evaluation templates at course level¶
Status¶
Accepted (2026-09-27, issue #151, decisions settled with the product owner on the issue). Folded on 2026-10-09: the addendum of 2026-09-28 (a–e, into §1, §4, §6 and §7), the pull's of 2026-09-28 (PR B, now §8) and Save as template linking its source of 2026-09-30 (now §9); the delivery plan (§7, addendum b) and the Rollback section are removed (the runbook owns procedures; its one lasting fact is a Consequence). The promote screen is deferred until a term of use of 2–4 (the former delivery plan's smallest slice, editing in place and the pull) shows what it must do (06 no. 25, issue #229).
Relations: requirements F-EVAL-18, F-EVAL-24 to F-EVAL-26, F-ORG-12. Amended by
ADR-053 (2026-09-30): an exam or an exercise has no access
code any more, so what this record says a template drops or an instance keeps no longer includes one;
TemplatePatch still refuses an accessCode key, as an unknown one. Amended by
ADR-052 (2026-09-30): the bonus flag of an item is content like its
milestone flag — a template carries it, a copy and a pull copy it, a change of it moves the revision and
shows in a pull's summary. The threshold grade scale a template could hold is gone. Amended by
ADR-092 (2026-10-09): a template's mode is editable in
place (mode joins TemplatePatch) and moves the revision; an instance keeps its own mode, a pull never
carrying it.
Context¶
A course is taught year after year (glossary: "persistent from one year to the next"), but everything an evaluation is — its questions, their points and order, its milestones, its settings, its grade scale, its feedback policy — lives in ONE classroom (F-EVAL-01). Next autumn's class of the same course starts from the evaluation of last autumn by F-EVAL-14's Duplicate, which copies whatever that one run became: its dates, its access code (removed since ADR-053), its IP list, and any change made in a hurry for one room. There is no place where "the exam of PRG1" is kept as such, and deleting last year's classroom deletes it (F-ORG-09).
Issue #151 asks for that place: an evaluation kept at the course level, from which each classroom's evaluation is made.
Three facts of the current schema shape the answer:
- the configuration of an evaluation is already grouped —
settings,grading_scale,feedback_policy,mcq_policy,duration_son the row, the frozen question versions, points, order and milestones inevaluation_items— and every rule on it (configLock, the feedback and retake rules, negative marking) is written once, against that row; - since the ADR-014 addendum of 2026-09-27,
classroom_id IS NULLalready means something: an anonymous poll, owned bycreated_by(evaluations_home_ck,ownedPollAccess). A template stored "without a classroom" would be read as a poll by every site that tests that null, and managed by its creator alone — a 404 for the rest of the course's staff, which breaks invariant 6; evaluation_items.question_version_idhas noON DELETEaction, so a pool whose versions an evaluation pins cannot be deleted today: the delete fails on the foreign key and the teacher reads a 500.
Decision¶
1. A template is a row of evaluations; there is no second table¶
A new column evaluations.course_id (FK courses, ON DELETE CASCADE) is set
on a template and on nothing else. Its presence is what makes a row a template:
there is no kind column to keep in step with it.
- Exactly one home.
evaluations_home_ckbecomes: a classroom (classroom_idset,course_idnull), OR a course — a template (course_idset,classroom_idnull), OR an owner — an anonymous poll (both null,mode = 'poll',created_byset). - A template carries nothing of a run, and the database says so:
evaluations_template_ckrequires, whencourse_idis set,opens_at,closes_atandaccess_codenull (the latter also forced byevaluations_access_code_poll_cksince ADR-053),ip_allowlistempty,state = 'draft',mode <> 'poll',revisionset andorigin_template_idnull. A template that carries a date, a code or an IP cannot be written by any path. - Revision and origin.
revision(integer, on a template, 1 at creation);origin_template_id(self FK,ON DELETE SET NULL) andorigin_revisionon an instance — the template it was made from, and at which revision. - One helper for "owned poll". Every site that reads
classroom_id IS NULLas "an anonymous poll" goes through ONE predicate —classroom_id is null and course_id is null and mode = 'poll'— in SQL and its row twin in TypeScript:ownedPollAccessandfindManagedEvaluation(guards.ts),staffOf(grading/events.ts),homeTopic(realtime/bus.ts), the poll views (poll/*). The helpers that only mean "no roster" (seatsOf,enrolledCounts) stay correct as they are: a template has no roster either. The partial indexevaluations_owned_poll_idxgets the same predicate. - Access. A loader
loadTemplatefinds a template throughstaffAccesson its course, the one predicate of invariant 6: every member of the course's staff manages the course's templates, and anyone else gets the 404 of a template that does not exist (a 404 test on every route, from the former delivery plan).loadEvaluationkeeps its inner join onclassrooms, so no generic evaluation route (items, settings, dashboard, grading, preview (addendum c, 2026-09-28), results, the MCP tools built on them) ever sees a template; (addendum c, 2026-09-28) the code is shared at the service level, not by widening a loader. - A separate contract.
EvaluationTemplatein@quiz/contractsis the template's own shape, withcourseIdandrevision;Evaluation.classroomIdstays non-null. - Editing goes through parallel routes (addendum c, 2026-09-28). Editing a
template uses routes of its own under
/app/api/templates/:id/..., loaded byloadTemplateonly (Access, above). ATemplatePatchcontract is the evaluation patch minusopensAt,closesAt,accessCodeandipAllowlist(since ADR-053 the evaluation patch has noaccessCode; the strict template patch still refuses one as an unknown key), declared.strict()so that an unknown key is refused rather than stripped: sending a run field is a 400 from the schema, never a silent no-op nor a 500 fromevaluations_template_ck. The pools a template may draw from are the course's linked pools:coursePoolIdstakes a home — a course, for a template, or a classroom — and stays the one source of the rule, beside the one testinLinkedPool. The web edits a template at/templates/:id(New template on the course page) with the evaluation editor's own blocks handed a template's routes rather than a copy of them, and shows the outdated-question flags (staleItemsofTemplateDetail); the API's template routes,TemplatePatchandTemplateDetailare F-EVAL-24 and F-EVAL-25.
2. Kinds: exam and exercise, never poll¶
A poll is created and started in one call (ADR-014) and has nothing to keep.
Save as template on a poll answers 422 template_poll; the CHECK above
refuses it underneath.
3. What a template holds¶
Everything except opens_at, closes_at, access_code (a poll's only, since
ADR-053) and ip_allowlist: the items (frozen versions, points, order,
milestones), settings in full — timing kind, lobby, navigation, presentation,
shuffling, Safe Exam Browser, fullscreen, visibility log, retakes, negative
marking — grading_scale, feedback_policy, mcq_policy and duration_s. An
instance whose timing is deadline is born without dates, and the existing
timing_incomplete guard (F-EVAL-04, #76) keeps it closed until the teacher
fills them.
4. Revision¶
revision increments on every committed change to the items, their points or
order, or to a template-level setting; a title change does not move it. There is
no explicit "publish revision" step. The counter is 1 at creation; editing a
template in place moves it.
Addendum d (2026-09-28), one helper bumps the revision: The revision moves in
ONE service helper, called by every template write inside that write's
transaction, as revision = revision + 1 in SQL (never read-modify-write).
There is exactly one bump per request that changes at least one stored
template-level value (items, points, order, settings); a title-only request, or
one that changes nothing, does not bump. A database trigger was considered and
rejected: it would be the first trigger of the repository, it puts the logic out
of sight of the code that reads it, and it would need disabling at creation,
where the items are written under revision 1.
2026-10-09: the change is compared on the EFFECTIVE settings, each optional
key read through its accessor (kioskOf, retakesOf, …), so a patch that
spells out a default the row left absent does not move the revision.
5. Deletion¶
- Deleting a template leaves its instances running; their
origin_template_idbecomes null (origin_revisionstays, as a record). - Deleting a course cascades to its templates, and the confirmation names how many.
- Deleting a classroom never touches a template: a template belongs to the course (F-ORG-09 reworded).
- Deleting a pool is refused (
409 pool_in_use) while a template or an evaluation pins one of its versions. The refusal names the holders the caller can open and only counts the others (hidden), so it never leaks another course's titles.
6. Smaller rules¶
- At instantiation, a question version marked deprecated warns and does not
block; a question whose pool is no longer linked to the course blocks
(
422 template_pool_unlinked, listing the items), because the instance could not have been authored with it either (F-EVAL-01). The same rule and the same answer apply to a duplicate (F-EVAL-14): every copy into a classroom goes throughcopyEvaluation, which checks the target course. - An instance is made in a classroom of the SAME course; any other classroom is the 404 of a classroom the template does not reach.
- Pulling a newer revision into an instance checks "no attempt" in the same transaction as the update (PR B, §8).
- A template stays
draftforever (the CHECK), so neither the ticker, nor the student home, nor any attempt path ever meets one. - "Start from a template" appears at evaluation creation only when the course has at least one template: the novice path (08) is unchanged.
- Audit:
template.create,template.instantiate,template.deletejoin the closed union ofaudit.ts(invariant 9), thentemplate.updatewith editing in place (A2), thentemplate.pullwith the pull (B). The 2026-09-28 delivery split named A1 (the course page, §7), A2 (an empty template and editing in place, §1 and §6) and B (the pull, §8). - An empty template (addendum b, 2026-09-28):
POST /courses/:id/templates(a new F-EVAL-24) creates one at course level, taking a title, a mode (exam|exercise) and a preset; apollis422 template_poll; audited astemplate.createwithoutfrom. It ships with editing a template in place, because an empty template that cannot be filled is a dead end, which is also why the course page (§7) had no New template button before it. - Instantiate copies what it records (addendum e, 2026-09-28):
Instantiate reads the revision and the items in one transaction that locks
the template row (
SELECT … FOR UPDATE), so theorigin_revisionrecorded on the instance is the revision whose content was copied, even while a colleague edits the template.
7. The course page is the one surface for templates¶
Addendum a (2026-09-28), settled with the product owner after the smallest slice shipped (issue #151): a teacher never discovered templates — a course was only a card on the Courses home, and the card hid its templates list while it was empty.
Every course gets a page of its own, /courses/:id (F-ORG-12): its classrooms
(the archived ones behind "Show archived"), its linked pools, and its evaluation
templates. Its one primary action is New classroom. On that page the templates
section is always shown, with an empty state that names the one door there
is today — Save as template in an evaluation's menu. The card on the Courses
home no longer lists templates at all: it is a summary whose title opens the
page. This reverses the slice-2 rule (the smallest usable slice, the former delivery
plan's second step) "the list shows nothing while empty", and
the novice home (08) stays as it was, since that list left the card instead of
growing an empty block on it. The "behind template" badge arrives with its fix,
the pull (§8), rather than as a warning nobody can act on.
8. Pulling a revision into an instance (PR B)¶
Addendum of 2026-09-28, PR B: Settled with the product owner before the pull was written (F-EVAL-26).
8.1 A pull replaces the questions, and only them¶
The instance's evaluation_items are replaced by copies of the template's — the
same frozen versions, points, order and milestones, under new ids. Everything
else of the instance is its own and stays: title, dates, access code (none since
ADR-053), IP list, settings (timing, waiting room, navigation, Safe Exam
Browser, retakes…), grade scale, feedback policy, MCQ policy, duration and
state. origin_revision then records the template revision whose items were
copied. A template that moved only in its settings therefore makes the instance
"behind" as well; pulling it changes no question and records the revision, and
the confirmation says exactly that. A teacher who wants a template's new
settings changes them on the instance: settings are the instance's to decide for
its room, questions are what a course shares.
8.2 Where it is allowed¶
Exactly where the item list is editable (itemListLock, issue #79): draft or
scheduled, and no attempt of anybody — a teacher's own test walk included
(ADR-018), with no special case. It is the same predicate the item writes use,
not a second check. A scheduled instance must still pass the move to scheduled
afterwards (assertReady, split out of guardTransition): a template emptied
of its questions cannot empty a scheduled instance (409 illegal_transition,
reason no_items). The pools and deprecated versions are read as by
Instantiate, through the same helpers: a question in a pool the course no
longer links blocks it (422 template_pool_unlinked), a deprecated version
warns. The template must still be the instance's origin AND a template of the
instance's own course, or there is none (409 no_template: the evaluation
exists, so this is a conflict of its state, not a 404). The POST carries the
revision the teacher confirmed; a template that moved again since is
409 template_moved, so a pull never copies a revision nobody looked at.
8.3 Where it is offered¶
A badge "template rev. N → M" on the evaluation's row of the classroom's list,
and a warning with its fix in the launch checklist (F-EVAL-23), beside the stale
question versions — both only where the pull would be accepted
(templatePullable in @quiz/domain, the same rule). Nothing in the evaluation
editor's header. Both open one confirmation, drawn from
GET /evaluations/:id/pull-template: the questions added, removed and changed
(version, points, milestone), whether the order moves, and "rev. N → M"; it says
the instance's own question changes are replaced, or, when no question differs,
that only the revision is recorded. The summary is computed by the server rather
than from two details in the browser: only the server can say which pools the
course still links, and the template's detail would be a second request under a
second loader for the same answer.
8.4 The transaction¶
Replacing the items gives them new ids, and answers.item_id and
gradings.item_id cascade on delete: a pull past the gate would silently delete
a student's work. So pullTemplate is one transaction that locks the TEMPLATE
first (SELECT … FOR SHARE, restricted to the instance's course), then the
INSTANCE (SELECT … FOR UPDATE), and re-reads under that lock the origin (a
delete may have nulled it), the state and the attempt count before it writes. It
is the order deleteTemplate takes — the template's row, then its instances'
through the ON DELETE SET NULL foreign key — and editTemplate locks the
template alone, so no two of them deadlock. The ticker's scheduled → lobby
UPDATE waits on the instance's lock and then opens the pulled questions. The
route is on the evaluation (POST /evaluations/:id/pull-template,
loadEvaluation, invariant 6); a template id there is the 404 of a missing
evaluation. Audited as template.pull with { templateId, from, to }; the
classroom's refresh hint is published as for any item write.
9. Save as template links its source¶
Addendum of 2026-09-30: Settled with the product owner (issue discussion of 2026-09-30).
9.1 The source becomes an instance of what it gave¶
Save as template now also sets the SOURCE's origin_template_id to the new
template and its origin_revision to 1, the template's revision: the evaluation
counts as made from the template it gave, exactly as if it had been instantiated
from it. "Instance" (glossary, §1) therefore means any classroom evaluation
linked to a template, whether Instantiate made it or it gave the template.
Everything built on the origin follows unchanged: once a colleague edits the
template, the source is "behind", and where a pull would be accepted
(templatePullable, §8.3) it wears the badge and its launch checklist the
warning. A closed or released source keeps the link as a record only: no badge,
no pull. The link is also the lineage a future promote screen (06 no. 25) would
stand on; this does not decide that screen.
9.2 A source that already had an origin is relinked¶
A source that was itself an instance of another template is relinked to the new
one: its content is now the new template's, so that is its truer origin. The
origin it loses is kept in the audit log: template.create carries
previousOrigin: { templateId, revision } | null beside from, so the log
alone can rebuild the old link. No new action in the audit union, and no entry
keyed on the source: from names it.
9.3 The transaction¶
saveAsTemplate is one transaction. It locks the source
SELECT … FOR NO KEY UPDATE — the lock its own UPDATE of the origin takes
anyway, and weaker than FOR UPDATE so that a running evaluation keeps taking
attempts (an attempt's foreign key takes FOR KEY SHARE on it) — re-reads it
under that lock, copies it into the course and links it. A pull on the same
source (§8.4: old template FOR SHARE, then the instance) waits on this lock or
this waits on it; no template row other than the new one is touched, so no lock
order can cross pullTemplate's, editTemplate's or deleteTemplate's. Item
writes on an evaluation take no row lock: one landing between the copy and the
link makes the source differ from its rev. 1 without a badge, which is what any
later edit of the source does too.
9.4 What is not done¶
- No backfill. Templates saved before this change leave their sources
unlinked. The
template.createrows would allow it, but it would raise badges on evaluations their teachers never linked. - Duplicate still makes an unlinked copy (F-EVAL-14), of a source as of any instance.
- An unlinked pool is not checked when copying into a course (it is when
copying into a classroom). A source holding a question of a pool its course
has since unlinked gives a template that Instantiate and a pull refuse
(
422 template_pool_unlinked), and the source's badge would open that refusal. Unchanged by this addendum; left as it was.
Consequences¶
- The null of
classroom_idno longer names one shape. Every site that tested it for "poll" now tests the owned-poll predicate:ownedPollAccessand the second query offindManagedEvaluationinguards.ts,staffOfingrading/events.ts,homeTopicinrealtime/bus.ts, theanonymousflag of the poll views and the public loader inpoll/*. A db test shows that a template is reachable through no poll route and by its creator through no owned-poll path. - A course's evaluation outlives its classrooms, and a new classroom starts from it in one action with nothing of last year's dates or codes.
- A pool pinned by an evaluation or a template can no longer be deleted; the teacher reads why instead of a 500. The old evaluations must go first — or the pool stays, which is the point of freezing versions (F-EVAL-03).
- Every existing rule on an evaluation's configuration applies to a template unchanged, because it is the same row read by the same code.
evaluationscarries four more nullable columns and a second CHECK. The migration (0019_evaluation_templates) adds columns and replaces two constraints; no existing row changes shape (none has acourse_id). It is not undone by dropping the columns: the CHECK of0018_poll_audiencecannot be restored while a template row exists, and an earlier image reads a template (a row with no classroom) as an owned poll, so a plain redeploy is unsafe once a template exists. Undoing it loses the templates (their instances are ordinary evaluations and stay).
Alternatives considered¶
- A separate
evaluation_templatestable (with its own items table). Every rule on an evaluation's configuration, every contract and every editor screen would have a second copy to keep in step, and instantiating would be a translation between two schemas. Rejected. - An
evaluation_specsextraction: the configuration moved to its own table, referenced by both an evaluation and a template. It is the cleanest model on paper, and a migration of every evaluation, every query that readssettingsand every item path, for a feature that needs none of that to work. Rejected. - A
kindcolumn (evaluation/template/poll). The home columns already say it; akindwould be a second source of truth that a row written by another path could contradict. Rejected:course_idpresent is the kind. - Storing a template as
classroom_id IS NULLwith a flag. Collides with the anonymous poll of ADR-014: the template would be owned by its creator (404 for the rest of the staff, invariant 6) and read as a poll by every site that tests that null. Rejected. - An explicit "publish revision" step, as for question versions. A question version is immutable because students answered it; a template is never answered — its instances are, and they hold their own copy. A draft/published split on the template would add a state and a button to guard nothing. Rejected: every committed change is the next revision.
- Polls as templates. A poll is created and started in one call and lives minutes; its "template" is the question itself, which the pool already keeps. Rejected (422).
Correspondence of old references¶
§1–§6 keep their numbers. The addenda are folded; code cites them as "addendum c", "PR B", "the addendum of 2026-09-30, §3":
| Old reference | Now |
|---|---|
| §7 Delivery (items 1–5, the 2026-09-28 re-split into A1, A2, B) | removed (docs/merge/PROGRESS.md, changes/); the promote screen's deferral is in the Status, the web editor in §1 |
| Addendum of 2026-09-28, a (the course page) | §7 |
| Addendum b (delivery re-split: A1, A2's empty template, B with its badge) | §6 (the empty template), §7 (the badge with its fix); the rest removed |
Addendum c (parallel routes, TemplatePatch, coursePoolIds) |
§1 |
| Addendum d (one helper bumps the revision) | §4 |
| Addendum e (Instantiate copies what it records) | §6 |
| "PR B", the addendum of 2026-09-28 on pulling, its §1–§4 | §8, §8.1–§8.4 |
| The addendum of 2026-09-30 ("third addendum"), its §1–§4 | §9, §9.1–§9.4 |