ADR-068 — Course staff roles: owner and assistant¶
Status¶
Accepted (2026-10-04, product owner decisions of 2026-10-02 and 2026-10-04).
Amended 2026-10-09 (product owner, ADR-079 §5): §3 only — the writes to a course's catalog of conditions (F-ORG-16) join the owner-only routes; reading it stays every member's.
Amended 2026-10-10 (product owner, ADR-081 sixth addendum): §3 only — the write to a course's list of concepts joins the owner-only routes; reading it stays every member's.
Amended 2026-10-10 (product owner): labels only — the owner role reads "Teacher"
("Enseignant") on screen; the stored role, the routes and the error codes
(owner_required, last_owner) are unchanged.
Amended 2026-10-10 (security audit findings M1 and L1; product owner's standing decisions; the poll exemption and "a correction published while an exercise runs does not lock grading" are the orchestrator's calls, pending the product owner's confirmation): §3, §9 and their consequence on §2 — deleting an evaluation that holds students' work or reached them, and any grading write on a released evaluation, join the owner's acts. These are the first routes whose needed role depends on the state of the entity they load; the check stays in the loader, before the body. The gradebook's derived absence follows (ADR-074 §9).
Amended 2026-10-11 (security audit L9, product owner's decision of 2026-10-10): §3
only — reopening a finished attempt whose student the feedback policy has already shown
the answer key (an exercise in immediate with showKey) joins the owner's acts, as an
audited derogation; any other reopening stays every member's.
Scope: what a member of a course's staff may do on the course, its classrooms and
its evaluations; who reaches them is unchanged (staffAccess, invariant 6).
Relations: amends F-ORG-12 (docs/spec/02-exigences-fonctionnelles.md), the
"Roles" rule of the reuse record (docs/spec/history/reuse-heig-classroom.md
§7.3) and decision D04 (c) of the merge (docs/merge/08-decisions.md); follows
the reach-then-role shape of ADR-013; admins
under ADR-054. Settled question 51
(docs/spec/history/settled-questions.md).
Context¶
Until now every seat of a course's staff held every right on the course
(docs/spec/history/reuse-heig-classroom.md §7.3; D04): a seat was the access predicate staffAccess, and nothing
else. heig-classroom's "assistant" label was dropped at the merge (D04 (c)).
The merge widened the staff from a classroom to the whole course (D04 (a)), and heig-classroom's assistants arrive with it. A teaching assistant then could remove the teacher from the staff, delete the course or one of its classrooms, unlink its pools or release a grade. The product owner asked for two roles, and for the line between them.
The pools already solved the same shape (ADR-013): a predicate that says who SEES an entity (404 when it fails, invariant 6), and a role that says what the caller may DO (403 when it fails, since they already know the entity exists).
Decision¶
-
Two roles on a seat —
course_staff.role,ownerorassistant(CourseRolein@quiz/contracts). An owner runs the course; an assistant does the day-to-day work in every classroom of it. -
Reach, then role.
staffAccessis unchanged and still defines reach: any seat, whatever its role, loads the course, its classrooms, evaluations, gradings… and anyone else gets the 404 of a missing entity. The role is a second step,requireCourseRoleinguards.ts, whose refusal is403 owner_required. It runs inside the route's loader (withCourseRole, the twin of the pools'withRole), after the 404 and before the body is parsed: an assistant's malformed body is the same 403 as a well-formed one. The pure rules areeffectiveCourseRole,courseRoleAllowsandstaffChangeRefusalin@quiz/domain(courseRole.ts). Amended 2026-10-10: the role a route needs may depend on the entity it loaded (an evaluation's deletion and its grading writes, §3); it is still decided in the loader, after the 404 and before the body, so an assistant's malformed body, or wrong confirmation title, is the same 403. -
What only an owner may do:
| Route | Why |
|---|---|
POST /courses/:id/staff, PATCH /courses/:id/staff/:uid, DELETE /courses/:id/staff/:uid (another's seat) |
who is on the staff, and with which role |
PATCH /courses/:id, DELETE /courses/:id |
the course's name, code and existence |
PUT /courses/:id/pools |
which pools the course draws from |
POST /questions/move with linkCourses |
the move links its target pool to the courses that play the questions: only to courses the caller owns. An assistant's course blocks it like a course out of reach (409 course_forbidden), but is named in full since they reach it; each blocking course's mayLink says which, and the web app offers no linking when one is false |
POST /courses/:id/classrooms, DELETE /classrooms/:id |
the course's classrooms |
POST /evaluations/:id/release, /unrelease, /publish-correction; POST /projects/:id/release (F-PROJ-14) |
what reaches the students as final |
POST /courses/:id/conditions, PATCH /courses/:id/conditions/:cid, PUT /courses/:id/conditions/order, POST /courses/:id/conditions/:cid/archive\|unarchive (amended 2026-10-09) |
the course's catalog of conditions, a course setting (F-ORG-16, ADR-079 §5); its list stays every member's |
PUT /courses/:id/concepts (amended 2026-10-10) |
the concepts the course declares, a course setting (F-ORG-12, ADR-081 sixth addendum); reading it (in GET /courses/:id) stays every member's, and no student route returns it |
DELETE /evaluations/:id once the evaluation is released, its correction published, or one attempt of a student seat exists — a poll's attempts (votes) do not count, only its release (amended 2026-10-10; the poll exemption pending the product owner's confirmation) |
the deletion cascades to attempts, answers, gradings, released grades, gradebook columns and marks, and drill cards: what students did, or were shown as final. A staff rehearsal (ADR-018) is not a student's attempt |
POST /evaluations/:id/attempts/:attemptId/reopen once the policy has shown the student the answer key (keyShownTo of the results module: an exercise in immediate with showKey) (amended 2026-10-11, audit L9) |
the student would rewrite their answers with the key in hand. The role depends on the attempt (reopenRole), decided in the loader like the two rows below: an assistant gets 403 owner_required, and the live grid offers Reopen only to an owner (reopenable); the owner's reopening is audited attempt.reopen with derogation: "key_shown". Retakes on and a published correction (ADR-050) stay refused to everybody |
Every grading write on a released evaluation: POST /evaluations/:id/grading/run, /grading/validate-batch, /items/:itemId/regrade; POST /answers/:answerId/gradings; POST /gradings/:id/override, /validate (amended 2026-10-10) |
after the release a correction changes a published grade (F-GRADE-09, F-NOTIF-07), which is the owner's as the release is. A correction published while an exercise runs (ADR-050) is no release and triggers nothing: its answers are still graded by every member (pending the product owner's confirmation) |
The rule of the two state-dependent rows below is pure, in @quiz/domain
(evaluationDeletionRole, evaluationGradingRole); the evaluation's payloads carry
the deletion's (deletionRole of EvaluationSummary and EvaluationDetail), so the
screens apply the server's rule. The delete decides it again inside its transaction,
on the row locked FOR UPDATE: creating an attempt takes FOR KEY SHARE on that row
(its foreign key), so an attempt created between the loader's count and the delete,
within the same request, is either counted or finds the evaluation gone (amended
2026-10-10). The grading role, by contrast, is decided once, in the loader: a release
committing between the loader and the write lets an assistant's write already in
flight, or a pass started before the release, land on the released evaluation. No lock
closes that window; such a change sets modifiedAfterRelease (F-GRADE-09), which the
results page shows, and it is accepted as such.
Everything else stays open to every member: hiding the course for oneself, templates, a classroom's settings (rename, archive, GitHub, journal, drill), the roster, the evaluations and their settings, deleting an evaluation no student took (and a poll not released), grading, validating and regrading until the release, reopening an attempt whose student has not been shown the key (amended 2026-10-10: grading, validating and regrading were open whatever the release; deletion was open whatever the evaluation held; amended 2026-10-11: reopening was open whatever the key).
-
The accepted loophole. An assistant may set an evaluation's feedback policy to
immediate, which shows students their results without a release. Closing it would mean splitting the evaluation settings by role for a case the product owner judged rare and visible (the setting is audited). It is accepted and documented, not closed. -
A course keeps at least one owner. Removing or demoting the last owner is
409 last_owner, which replaceslast_staff(the last seat is necessarily an owner, so the old rule is subsumed). The count and the write run in one transaction that reads the course's seatsFOR UPDATE: two owners demoting each other at the same instant cannot both pass the count, the second waits for the first and counts again. Only owners change roles; an owner may demote or remove themselves unless they are the last owner. -
Leaving. Any member may remove their OWN seat (
DELETE /courses/:id/staff/:uidwith their own id): the loader skips the role step for that one case, still before the body, and the last-owner rule still holds. -
A second seat is refused.
POST /courses/:id/stafffor an account already on the staff is409 already_staff(it used to be a silent no-op); a role changes by the PATCH, never by adding the person again. A new seat is anassistantunless the request saysowner— a default of the contract (StaffAdd), not of the database: the weaker role is the one a slip of the hand can give.createCourseseats its creator as owner explicitly. Audit:course.staff_addcarries the role,course.staff_removethe role the seat had, and the newcourse.staff_role_changecarries{ userId, from, to }. -
Administrators. Under Super Powers (reach
all, ADR-054) an admin is an owner of every course, seat or none; without them an admin resolves like any teacher — by their seat's role, and the 404 without one. -
The payloads say the role, once. Each staff member carries
role, and eachCourseSummaryofGET /coursesthe caller'smyRole— a list that holds every course the caller reaches, hidden ones and, under Super Powers, every course. The web app decides "is an owner" in one place,useIsCourseOwner(courseId)over that list (courseRoleAllows), and hides what only an owner may do instead of drawing buttons that can only fail (header actions on Classrooms, Linked pools and Members; Edit and Delete in the course settings, with one line saying why; the unlink; a classroom's Delete; Release, Unrelease and Publish the correction). Amended 2026-10-10: an evaluation's Delete (in the classroom's list and in its configuration's menu) is greyed out, with one line saying why, when the reader's role does not reach itsdeletionRole(useCourseRolewithcourseRoleAllows); the grading panel of a released evaluation is read-only for an assistant — no validate, adjust, re-grade or pass, in the palette either — with one line saying why, and its one primary action walks the questions. A classroom's detail names its course already;EvaluationDetailcarries itscourseId, because a classroom's course cannot be found in the list once the classroom is archived (the list's classrooms are the live ones). The server refuses anyway. -
Migration.
course_staff.role text NOT NULL DEFAULT 'owner'(migration0062_course_staff_role): every seat that existed becomes an owner, so nobody loses a right they had. The import from heig-classroom (src/import-classroom) maps a classroom's owner and itsteacherseats toowner, itsassistantseats toassistant, and never demotes: an account that owns one classroom of the course owns the course, and a seat Quiz already held keeps its role unless the import makes it an owner. -
Duplicating a classroom (F-ORG-10) is not built yet. It creates a classroom in the course, so it must use the owner scope like
POST /courses/:id/classrooms.
Consequences¶
- Invariant 6 keeps its one predicate for reach;
CLAUDE.mdsays that what a member may DO is the second step, a 403, as for pools. - The MCP tools call the HTTP routes, so they inherit the rule;
create_classroomandlink_pool_to_coursesay they are owner-only, andlist_coursesreturnsmyRole. - A per-classroom restriction of staff (D04's noted risk) remains a possible later refinement; the roles do not address it.
Alternatives considered¶
- A permission matrix per action. Rejected: two roles cover what was asked, and the pools' precedent shows one ordered role is enough to reason about.
- Refusing with a 404. Rejected: the assistant legitimately sees the course; a 404 would leave the screens unable to tell "gone" from "not yours to change" (ADR-013 §2).
- Database default
assistant. Rejected: the migration would have demoted every existing member, including every course's creator.