ADR-053 — Removing the entry codes: the classroom join code and the evaluation access code¶
Status¶
Accepted (2026-09-30, decided with the product owner). Delivered in two pull
requests: the classroom join code (F-ORG-06) goes with this ADR, migration
0041_drop_join_code; the access code of an exam or an exercise (F-EVAL-12)
goes in a second one, with the CHECK of decision 3. Amends ADR-014, ADR-025,
ADR-030, ADR-031 and ADR-032 (the last section lists what changes in each).
Amended by the addendum of 2026-10-11
(audit L4): a roster write attaches nobody; the claim runs at the person's
sign-in or their next own request.
Context¶
The platform had two codes a student typed to get in, besides the session code of a poll:
- The classroom join code (F-ORG-06). A teacher switched it on; a
student who typed it on their home took a seat on the roster, created on
the spot when no line matched (
POST /app/api/join/:code, auditedroster.join, the switchclassroom.join_code). - The access code of an evaluation (F-EVAL-12). A teacher set one on an
exam or an exercise; the student typed it on entering (
AttemptStartBody), a wrong one auditedevaluation.access_code_failed. It lives inevaluations.access_code, the column that also holds a poll's session code (ADR-014).
Three facts make both codes dead weight:
- The roster is the teacher's and the matching is automatic. Switch
edu-ID always releases the institutional address, and the sign-in path
records every address it reveals and claims each roster line whose e-mail
matches any of them (
apps/api/src/auth/claims.ts, GH-11). A student the teacher imported, by CSV or by hand, is in the classroom the first time they sign in; there is nothing to type. - Nobody used the join code. Production had no classroom with a join
code minted, none enabled, and no
classroom.join_codenorroster.joinrow in the audit log (checked on 2026-09-30). The one attempt to give it more room — a single "Enter a code" field for classroom and poll codes, PR #350 — was closed unmerged. - An access code proves no presence. A code written on the board is forwarded to an accomplice outside the room in seconds. It does not tell a student in the room from one at home, which is the only thing it was ever asked to do; it only adds a step where a student in the room can mistype.
Decision¶
1. The classroom join code is removed¶
A student enters a roster only through the teacher: the CSV import (F-ORG-04) or a line added by hand, then the claim at sign-in on any address edu-ID reveals (F-ORG-05). A student who arrives late in the semester, or whose address does not match, goes through the teacher, who adds or corrects the line.
Removed: the join card of the student home and its coach tip, the route
POST /app/api/join/:code, joinCodeEnabled on the classroom patch,
joinCode / joinCodeEnabled in the course detail, the service functions
that minted, toggled and redeemed the code, and the columns
classrooms.join_code and classrooms.join_code_enabled with their unique
index (migration 0041_drop_join_code).
Kept: the teacher's Join as student (POST /classrooms/:id/self-enroll,
roster.self_enroll, ADR-018), which is a staff member taking a seat in
their own classroom and has nothing to do with a code. student_joined
(F-NOTIF-08) stays, raised now by the claim at sign-in alone.
Why not keep it switched off by default: an unused path is still a route to defend, a switch to explain and a column to migrate. It can come back as a new decision if a real need appears (YAGNI).
2. The access code of an exam or an exercise is removed¶
The access code half of F-EVAL-12 is removed; its other half, the IP allowlist, stays and is still checked on every entry, retakes included. Nothing replaces the code as a "start signal": the release of graded evaluations by the teacher will play that role, in a separate change.
The controls that remain against a sitter working from elsewhere are the ones that do not travel with a message: the IP allowlist, Safe Exam Browser (ADR-027) and the kiosk stations (ADR-051), plus, later, a non-blocking alert on students who did not seem to sit from the room (issue #384). The waiting room is not a presence control and this ADR adds no rule to it.
Removed with the second pull request: the code field of the evaluation
settings and of the student's entry, accessCode from the evaluation patch
and from AttemptStartBody, the check and its audit action, and
accessCode from the MCP tool update_evaluation.
3. The poll session code stays, and owns the column¶
The session code of a poll (ADR-014) is unchanged. It lives in
evaluations.access_code, which therefore stays. From now on only a poll
may carry one, and the database says so with a one-way CHECK:
A data migration sets access_code to null on every non-poll evaluation
before the CHECK is added (production: none). EvaluationDetail.accessCode
stays nullable in the contract and means something for a poll only.
Renaming the column to session_code would be more honest, and is out of
scope: it touches every poll query for no change in behaviour.
Retired audit actions¶
classroom.join_code, roster.join and evaluation.access_code_failed
leave the audit union (apps/api/src/audit.ts). Their old rows, if any,
stay in audit_log as history: action is plain text, with no enum nor
CHECK, so nothing needs rewriting.
Consequences¶
- Destructive migration.
0041_drop_join_codedrops two columns: an image older than it selects them and cannot run against the new schema. A rollback past it needs the pre-migration dump (docs/development/deployment.md, Rollback). Deploy it outside an exam slot. - Nulling non-poll codes is irreversible, and harmless: no code is asked any more, and production held none.
- A student outside the roster depends on the teacher. There is no self-service path into a classroom; the teacher adds the line, and the claim runs at the student's next sign-in (F-ORG-05), or at once when the account already exists, by the claim the roster import or edit runs. (Replaced by the addendum of 2026-10-11: an import or an edit claims nothing; the claim runs at the student's sign-in or their next own request.)
- PR #350 stays closed; its "Enter a code" field has only one kind of code
left to take, the poll's, which has its own page (
/p/:code). - MCP clients that sent
accessCodetoupdate_evaluationget it refused by the schema (second pull request).
Alternatives considered¶
- Keep the join code, off by default. Nobody switched it on; it would keep a route, a column and a screen for a case the teacher already covers by adding a line.
- Keep the access code as an optional extra. It stops no one who has a friend in the room, and a teacher who sets it believes it does: a control that looks like one without being one is worse than none.
- Rename
access_codetosession_codenow. Clearer, but a migration and a sweep of the poll module for a name; left for a change that has another reason to touch the column.
What this amends¶
- ADR-014 (live polls). The session code is now the only value
evaluations.access_codemay hold (CHECK of decision 3); nothing else changes for polls. - ADR-025 (retakes). "The access code is not asked again" no longer applies: there is no access code. The network allowlist is still checked on every entry.
- ADR-030 (notification channels).
student_joinedis raised only by the claim at the student's own sign-in; the join-code path it also named is gone. - ADR-031 (evaluation templates).
access_codestays inevaluations_template_ckand out ofTemplatePatch(a template never held one); what an instance keeps of its own on a pull no longer includes an access code, and Duplicate copies none. - ADR-032 (per-user course preferences). Archiving a classroom no longer "closes its join code": there is none. The point of the ADR (a personal hide is not an archive) is unchanged.
Addendum 2026-10-11: a line is attached only when the person acts¶
Decided by the product owner on 2026-10-11, after finding L4 of the 2026-10-10 security audit: the claim that ran after every roster import and e-mail edit attached existing accounts, which showed the staff the last sign-in, id, avatar and GitHub login of a person who never visited the course, and let any address on a list link someone's account to a classroom.
- No staff write attaches an account. That claim is removed. An import, a line added by hand or an e-mail edit leaves the line pending, and a pending line carries nothing of any account. An address two accounts hold is flagged at claim time, by the rules of the sign-in (AU-21).
- The person's action claims: their sign-in, unchanged, and every
request of their own
portalsession — never an impersonation (ADR-034), aseborkiosksession (ADR-027, ADR-051) nor a bearer token (ADR-022, ADR-023). The sameclaimEnrollments, on the verified addresses already stored, firesstudent_joined(F-NOTIF-08) and the staff's roster hint. Its first step is one indexed query, the steady-state cost of a request. A line a teacher adds from the waiting room on exam day is claimed at the student's next request: a refresh lets them in.
Scope. Lines attached before this change stay attached. The
heig-classroom import (import-classroom/steps.ts, claimLines) is out of
scope: run by an admin from the command line, it carries over a claim the
person made in heig-classroom, matched by swissEduId or a verified address
— narrower, not the same leak. Login admission (issue #722) is not
implemented here; its ADR must state that the roster line counting as an
invitation is still pending at the admission check, so it is matched by
address, never read through a claimed seat.
Product cost. A student with an account who never opens Quiz stays
pending, like one without an account: not in the gradebook, no e-mail
reminder. student_joined now fires for every student at the start of
term, folded per classroom as before. A group repository created before a
member's first request invites them at their own Accept (F-PROJ-06). A
revoked claim whose address still matches is claimed again at its holder's
next request; to move a seat, the teacher changes its address.