ADR-017 — Moving a question to another pool¶
Status¶
Accepted (2026-09-22, asked for by the product owner). §5 amended by the third addendum of ADR-081 (the cut-over from tags to concepts): a question's concepts are the question's and travel with it; nothing is taught to the target pool, since the vocabulary is the instance's and the per-pool tag descriptions are gone (ADR-081, addendum §6).
Amended 2026-10-09: §8 refuses an assistant's course too, as ADR-068 and pool/moveRoutes.ts do.
Context¶
F-POOL-04 gives a question one way out of its pool: a COPY, which is a new
question that points back at its origin. There is no way to say "this question
was filed in the wrong pool". The product owner asked for one, in these terms:
drag a question onto a pool in the sidebar to move it there, warn when a
classroom already plays it, and offer the same thing to a multi-selection from
the bulk bar.
A move is not a copy followed by a delete. evaluation_items freezes a
question_version_id (F-EVAL-03), and DELETE /questions/:id refuses outright
while any evaluation points at a published version (409 in_use,
isQuestionInUse). Copy-then-delete would therefore either be impossible for
exactly the questions worth moving, or would leave two questions behind, one of
them a ghost that no list shows and every past evaluation resolves.
Three things about a question are per-POOL and not per-question, and a move has to answer for each of them:
- the internal name is unique per pool, case-insensitively
(
questions_pool_name_uq); - the category belongs to a pool, so the source pool's category is meaningless in the target;
- the tag vocabulary (
pool_tags, the one-line descriptions a teacher writes) is per pool, while the tags themselves (question_tags) are the question's.
And one thing is per COURSE: course_pools is the second way a pool is
reachable (poolAccess, ADR-013) and the set the evaluation builder draws
from. Moving a question out of a pool a course draws from, into one it does
not, leaves that course's staff unable to reach the question again — to put it
in the next evaluation, to re-freeze an item on a newer version — while the
items already frozen on it go on resolving perfectly.
Decision¶
-
The question keeps its id.
POST /app/api/questions/moveupdatesquestions.pool_idandquestions.category_idand nothing else: the versions, the draft, the history and theorigin_question_idprovenance travel untouched. Every evaluation item stays valid, and a move made by mistake is undone by moving back. -
One route, a list of ids.
MoveBodyis{ questionIds[], targetPoolId, categoryId?, linkCourses? }. The drag-and-drop sends a list of one; the bulk bar sends the selection. Two routes would be two places to keep the conflict rules in. -
Rights are the two halves of the operation:
contributoron EVERY source pool (what it takes to remove a question from one) andcontributoron the target (what it takes to create one there). A target pool the caller cannot reach is a404indistinguishable from a missing pool (invariant 6); a pool they can see but may not write to is a403, like every other pool write (ADR-013). -
A name clash refuses the batch —
409 name_taken, naming the offending internal names.copyQuestioninvents a free name (… (copy)) because a copy is a new object with no name of its own to defend; a move carries the name a teacher typed, which is what they search the palette for, and renaming it silently would be the move losing the thing being moved. The check runs before the transaction, covers the batch against itself (two pools can both hold aptr-01), and nothing moves when it fires. -
Tags travel, the vocabulary is taught, the source keeps its documentation.
question_tagsrows are the question's and are not touched. The target pool'spool_tagslearns the tags with an empty description, exactly ascopyQuestionalready does. The source pool'spool_tagsrows are LEFT: a teacher's sentence explaining what "pointeurs" means in that pool documents the pool, not the question that left, and deleting it on the way out would destroy writing nobody asked to destroy. Amended by ADR-081 (third addendum, the cut-over): the question's concepts (question_concepts) travel with it like any of its rows; the vocabulary is instance-wide, so there is nothing to teach the target pool and no per-pool description to leave behind. The paragraph above describes the tags before the cut-over. -
Assets stay with the source pool.
assets.pool_idis not rewritten. An asset is addressed by id and served by the asset route, which authorizes through the attempt or through a pool the caller reaches; moving the rows would be a second, silent write into a pool the caller is only borrowing a question from. -
A classroom that plays the question is a refusal, not a warning. When an evaluation item is frozen on a version of one of the questions, and the TARGET pool is not linked to that evaluation's course, the move is refused with
409 pool_not_linkedlisting the courses and their classrooms. The client turns that into a confirmation ("This question is used in classroom «…» — add the pool to its course?") and RETRIES withlinkCourses: true; the server then inserts thecourse_poolsrows in the same transaction as the move. "Linking the pool" means exactly one thing: a row incourse_pools, the same rowPUT /courses/:id/poolswrites. -
linkCoursesnever widens the caller's reach. A course on which the caller is not an owner (no staff seat, or an assistant's seat, ADR-068; Super Powers count as an owner) is answered409 course_forbidden, naming it, and nothing is linked; a course they do not reach is named by its code alone (pool/moveRoutes.ts). Otherwise a teacher could attach their pool to a colleague's course — and, throughpoolAccess, hand that colleague's whole staff a seat in it — by moving one question. -
Audit: one
question.moveentry per question (fromPoolId,toPoolId,categoryId,internalName) and onecourse.pools_updateper course the move linked, withreason: "question.move". No new action for the link: it is the same fact the course screen writes, and the reader of the log should not have to know two names for it.
Consequences¶
poolChangedfires for the source pool AND the target, so both open lists refresh; the linked courses get theircourseshint too.- The 409 is a THIRD answer on a question write, next to
in_useandvalidation. It carries a machineerrorand a humanmessage, so the SPA branches on the first and can show the second verbatim when it has no translation for a case. - A move inside the SAME pool is legal and simply re-files the question under
a category — which is what the bulk bar's existing "Move to a category"
already does through
PATCH /questions/:id. The two coexist: the patch is the cheap path when the pool does not change. - The sidebar becomes a drop target, so it now lists pools the caller may
WRITE to, not only the one being read. That is a navigation change, decided
in the same commit and documented in
Shell.tsx.
What this ADR does NOT decide¶
docs/spec/06-questions-ouvertes.md is untouched. In particular, nothing here
settles the canonical export/import of a pool (F-POOL-07): a move is an
in-database relocation and says nothing about what an archive should contain
when the same question has lived in two pools. The origin_question_id chain
still only records copies, and a move leaves no trace of where the question
used to live other than the audit log — deliberately: a question that has moved
IS a question of its new pool, and a permanent "came from" field would turn
every filing mistake into history.
Rejected alternatives¶
- Copy then soft-delete the original. Impossible for the questions that
matter (
409 in_userefuses the delete), and it would break the identity an evaluation item depends on. It also doubles the pool's row count for a filing operation. - Renaming on a clash, like
copyQuestion. Cheap to write, but it loses the name the teacher searches by, and for a twenty-question batch it would rename silently in the middle of a drag. - A warning instead of a refusal on
pool_not_linked. The client would own the rule, and a CLI or a second front end would move questions out of a course's reach with no prompt at all. The server refuses; the client asks. - Linking the pool automatically, with no question asked. It grants the course's whole staff a seat in the pool (ADR-013). That is a sharing decision, and sharing decisions are made on purpose.
- Moving the assets and the
pool_tagsdescriptions along. It would make a move write into a pool the caller may only be a contributor of, to delete documentation they did not write. (The descriptions are gone since the ADR-081 cut-over; the argument stands for the assets.)