ADR-032 — Per-user display state lives in user_course_prefs¶
Status¶
Accepted (2026-09-27, issue #155, settled with the product owner; with the
table user_course_prefs (migration 0020_user_course_prefs), the routes
POST /courses/:id/hide and POST /courses/:id/unhide of the org module,
the hidden flag of CourseSummary, F-ORG-11 and 06 no. 26).
Amended by ADR-053 (archiving no longer closes a join code) and ADR-054 (cross-course admin access requires Super Powers). Personal hiding remains the decision of this record.
Amended 2026-10-09: the Rollback section is removed (the runbook owns operational procedures).
Context¶
A teacher keeps every course they ever taught: a course is persistent from one year to the next (01, "Course"), and deleting one deletes its classrooms, its evaluations and its results (F-ORG-09). After a few years the course list, the sidebar's course tree (#154) and the command palette are full of courses the teacher no longer teaches, and nothing lets them tidy that up.
Three facts shape the answer:
- the need is PERSONAL: "the courses I no longer teach". A colleague on the same staff may still teach it;
- staff rights are flat (docs/spec/07, 7.3): whatever one member can do to the course, every member can. A course-wide archive would let any one colleague take the course out of everyone's sight;
- an admin reaches every course without holding a seat on its staff
(
accessWhereinguards.ts). A column oncourse_staffwould leave the admin nothing to hide with — and the admin is the user with the longest list. Amended 2026-09-30 (ADR-054): an admin reaches a course without a seat only while their Super Powers run; the table of its own still holds, since hiding is personal and an admin with Super Powers may hide a colleague's course for themself.
Decision¶
-
A table of its own,
user_course_prefs(user_id, course_id, hidden_at), composite primary key, both foreign keysON DELETE CASCADE. It is owned by theorgmodule and holds one user's DISPLAY state for one course — never a right, never a state of the course. A row whosehidden_atis null is a visible course: the row is the home of any later per-user course preference (a favourite, an order), so unhiding clears the column rather than deleting the row. -
Hiding is a course route like the others.
POST /courses/:id/hideand/unhideload the course understaffAccess(invariant 6) — so an admin may hide too, and a caller off the staff gets the 404 of a missing course — and answer 204. They take no body, so the only input is theIdParamof@quiz/contracts. -
Navigation only.
GET /coursesstill returns every course the caller reaches, each with ahiddenflag computed for the caller. The SPA leaves hidden courses out of its navigation — the course list (until "Show hidden"), both sections of the sidebar (except the course being read) and the command palette — and nowhere else: the pickers read the same list and keep offering them, and so does the MCPlist_courses, because an assistant that cannot see a course creates it a second time. Every page of a hidden course stays readable. -
No audit event. The audit log records what happened to the platform's data; a hide changes what one person's screen shows and nothing else.
-
"Hidden", not "archived". "Archived" is a state of a classroom, seen by the whole staff, which also closes its join code (amended by ADR-053, 2026-09-30: classrooms have no join code any more; the point stands). Using the same word for a personal hide would promise the teacher an effect on their colleagues that does not exist. The course list therefore says "Show hidden", and the classrooms of a course keep "Show archived".
Consequences¶
- The course summary gains a field that depends on WHO asks. It is computed
by a left join in
listCourses, with no second query. - A global archive can still be added later, on
courses, without touching this table: the two would answer different questions. - Deleting a user or a course takes its preference rows with it.
Alternatives considered¶
course_staff.hidden_at. One column instead of a table, but an admin has no row there, and the preference would vanish with the seat.- A global archive on
courses. One colleague would hide the course from all, and it would tempt a cascade (running evaluations) that can break a live exam. - Filtering hidden courses on the server (
GET /courses?hidden=…). Two readings of one list, and the pickers, the MCP and the "Show hidden" toggle would each need to ask for the right one; one list with a flag is read once and filtered where the navigation is drawn.