Skip to content

ADR-081 — Concepts replace tags: one instance-wide, bilingual vocabulary, curated by the admin

Status

Accepted (2026-10-08) by the product owner, in a design discussion; the points listed in open question 54 remain open. Amended the same day by the addendum (transition, resolution of a typed label, storage, rights), settled with the product owner before step 3 of #599, and by the second addendum (sorting the existing tags) and the third addendum (the cut-over) and the fourth addendum (the tag sorting retired, step (d)) and the fifth addendum (curation, step 5: its queue, the merge and the aliases are implemented; the probable duplicates are not) and the sixth addendum (the concepts of a course, steps 7a to 7d and 9a implemented) and the amendment of 2026-10-13 (the model's fill of §7 on the admin's concept sheet, implemented). ADR-114 proposes relations to the admin (#897 step A) and defers §7's suggested broader concept. ADR-115 amends the sixth addendum §7 (2026-10-15): a student may revise a course concept on purpose, outside FSRS and the level. ADR-116 amends the third addendum §6 and the sixth addendum §9 and §10 (2026-10-15): the pool's Concepts tab counts a concept's covered questions and draws the hierarchy. The work is tracked by #599, under a parent issue that groups #557 and #578. Steps (a) and (b) of the transition (addendum §1) are implemented; the cut-over, step (c), is implemented by the cut-over PR of #599 (#648): questions, the pool's filter and Notions tab, the bulk bar, move and copy, polls, the drill, the MCP tools, the teacher assistant and the seed read and write concepts. Step (d), dropping question_tags and pool_tags, is implemented by the fourth addendum. The links to courses (§8), the relations (§4, §5) and the coverage of narrower concepts (§4, 7d) are implemented by the sixth addendum (steps 7a, 7c and 7d); the curation screen and the aliases are (fifth addendum).

Scope: what a question is classified by, who may create and change that classification, how it is stored, and what a course declares. It does not decide how mastery is computed nor what a student sees of it (#578, F-DRILL-05), nor prerequisites (#457).

Relations: replaces the "Tag" entry of the glossary and the tags of F-QST-01, F-POOL-03 and F-STAT-04; the per-tag readings of ADR-041 §10 and F-DRILL-05 read concepts instead. Uses the LLM gateway for the suggestions. The third addendum (the cut-over) amends ADR-017 §5 (a moved question's concepts travel, nothing is taught to the target pool), the tag wording of ADR-013 §1, the interleaving and mastery of ADR-041 §6, §10, §12 and §15, and the pool search of ADR-080's P2b amendment.

Context

A tag today is a free string on a question (question_tags), lowercased, with an optional description per pool (pool_tags, apps/api/src/modules/pool/tags.ts). Each teacher writes their own: another language (pointer / pointeur), another spelling, a plural, an abbreviation, a typo. A pool shared by several teachers, or two pools linked to one course, end up with the same idea under several tags, and a filter on one misses the others.

The same field also carries organisational labels (week3, exam2024, difficile, à-revoir), which are not about what the question teaches.

The platform needs tags to be what they are on Stack Overflow: concepts a question exercises. A course states the concepts its syllabus covers (#578); the drill and, later, the student read their mastery per concept (ADR-041 §10, F-DRILL-05); a question pool filters by them. That requires one shared identity per concept across pools, teachers and languages.

Two extremes were rejected (see Alternatives): classification fully managed by a model, which teachers cannot change, and the free text of today.

Decision

  1. Concepts replace tags. A question is classified by concepts only. Free organisational labels are not kept: what they expressed already has a home: the difficulty (F-QST-01), the usage history of a question in evaluations (ADR-038), the pool's categories, the course's templates (ADR-031). A need that none of these covers is a missing feature to discuss, not a reason to keep a second free vocabulary. The same goes for the kind of task (lecture-de-code, écriture-de-code, vocabulaire, trace, débogage…), which a fifth of today's tag links carry (measured 2026-10-08, #599): it is not a concept, and the product owner chose to drop it rather than keep it as a separate facet.

  2. A concept is a node, not a string, after the SKOS model. It has a stable id; a preferred label in French and in English; aliases; a one-line description in each language; a status (proposed, validated, or merged into another concept). The label shown is the one of the reader's interface language, falling back to the other.

  3. One vocabulary for the whole instance. Not per pool and not per department: Python, C/C++ and logic pools share much of their vocabulary, and the platform has no department entity. The top concepts of the hierarchy (Programming, Electronics, Management, Mathematics…) play that role without one.

  4. Two relations: broader and related. Broader builds the hierarchy; a concept may have several broader concepts (SKOS allows a poly-hierarchy). Related is symmetric and non-hierarchical (pointer — reference). No cycle through broader. Prerequisites are not part of this decision (#457). A concept selected anywhere covers its narrower concepts: a course that lists pointers covers pointer arithmetic.

  5. Homonyms are told apart by a qualifier. Identity is the id, never the label. A label must be unique per language together with its qualifier: adresse (mémoire) and adresse (postale). The qualifier is shown only when the bare label is ambiguous. The concept picker shows each candidate with its broader path and description, and ranks first the concepts already used in the current pool or course.

  6. Aliases are curated; typos never become one. A teacher's input is resolved to a concept: normalization, then exact match on labels and aliases, then a close match by string distance. What is stored is the concept's id; the typed string is discarded. An alias is a legitimate variant only (translation, synonym, accepted abbreviation, plural), worth showing in the picker; only the admin adds one. When the admin merges a proposed concept into another, its label is dropped by default and kept as an alias only on request. The normalization and the close-match function are pure rules in packages/domain (invariant 8), shared by the manual input, the suggestions and the curation screen.

  7. The model proposes, the teacher decides, the admin consolidates.

  8. A teacher picks existing concepts. When nothing matches, they create a concept in the proposed status and use it at once: they are never blocked. The model, through the gateway (ADR-058), fills the other language's label, the descriptions and a suggested broader concept (deferred, ADR-114 §3); the teacher sees and may edit them.
  9. From a question, a Suggest concepts action (#557) proposes existing concepts first, a new one only when none fits; nothing is written without the teacher's confirmation.
  10. The admin curates: validates, renames, qualifies a homonym, merges, edits relations. A merge redirects every question and course from the merged concept to the surviving one, in one transaction, audited (invariant 9). The model may list probable duplicates and homonyms for the admin; it never changes the vocabulary on its own, so the classification does not drift when the model changes.

  11. A course declares its concepts. A course's settings hold a plain list of concepts, built from its syllabus, edited by its owners (ADR-068). No chapter, target level nor weight in this first version; they would be columns of that list, not another model. The pool screen can filter on "the concepts of course X".

  12. A question's concepts are on the question, unweighted. As tags are today, they belong to the question, not to its versions: a mastery computed later reads today's classification of past evidence, a deliberate choice (#578, point 2). No weight per (question, concept) in this version.

  13. Nothing changes for students here. Concepts stay stripped by toStudent (invariant 4). Showing a course's concept labels to its students is decided with #578, by its own ADR.

  14. Migration of the existing tags. A one-off pass sorts every distinct tag into: a concept (an existing one, or a new proposed one), or a tag that is dropped (organisational label, kind of task, noise). The model proposes the sorting; the admin reviews it on the curation screen before anything is written. question_tags and pool_tags are then dropped, and a pool tag's description becomes the concept's description when it has none. The size of this pass is to be measured on production first.

Consequences

  • One identity per concept across pools, teachers and languages: filtering, the drill's mastery and #578's heatmap read the same thing.
  • A new entity to maintain, and a curation load on the admin: the screen of proposed concepts and probable duplicates is part of the feature, not a later improvement. If proposals pile up, the curation role may be widened (open question 54).
  • Contracts change (invariant 7): a question carries concept ids instead of strings; the API and the MCP tools (ADR-022), which take tag strings today, accept labels and resolve them with the rule of §6, creating a proposed concept for an unknown one, so an MCP client keeps working.
  • Code touched: the pool module (tags.ts, questionWrite.ts, questionList.ts, move.ts, memberRoutes.ts), the drill (review.ts, teacher.ts), the web tag input (TagInput.tsx) and the pool's Tags tab, the seed. The concept tables belong to a module of their own; the pool module reads them by join.
  • The UI word "tag" goes away (names in open question 54); the # filter syntax stays.

Alternatives considered

  • Fully automatic classification (the platform tags, the teacher cannot change it). Rejected: not deterministic, it drifts when the model changes, while a syllabus needs a classification stable over years; a teacher loses trust at the first wrong tag they cannot fix; and granularity (C, pointers, pointer arithmetic) is a teaching choice the model cannot infer.
  • Free tags, as today. Rejected: it is the cause of the problem.
  • A vocabulary per pool or per department. Rejected: shared vocabulary between domains is the norm, and a department entity adds a level the platform does not otherwise need; the top concepts of the hierarchy do that job.
  • A concept as an embedding vector, relations inferred by similarity. Rejected as the source of truth: opaque, not editable by a person, changes with the model, and propagating mastery along a cosine similarity yields results nobody can explain. Embeddings remain a possible tool to propose duplicates or relations, if a prompt holding the whole vocabulary no longer suffices; no pgvector until then.
  • Keeping organisational labels beside concepts. Rejected for now (§1): each known use has a better home.
  • A closed "kind of task" facet (read, write, trace, debug code…), the third axis found in the production tags. Rejected by the product owner (2026-10-08): only real concepts are kept.

Addendum 2026-10-08: transition, resolution and storage

Settled with the product owner after the challenge of step 3 of #599. It amends §6, §7 and §11 where named; the rest stands.

  1. Transition in four merges, production coherent after each. (a) The registry alone, connected to nothing: tags stay the source of truth. (b) The sorting of the existing tags, proposed by the model and reviewed by the admin in production, stored keyed by (pool, tag), not as a global map, so a homonym (pile) can go to two concepts. (c) One cut-over: the question links are filled from the reviewed sorting (tags added since are listed, never silently dropped); questions, the MCP tools, the pool filter and Tags tab, move and copy, polls and the drill switch to concepts together; question_tags and pool_tags are frozen. (d) The two tables are dropped one release later. Until (c) no question write creates a concept, so the dropped labels (§1) never reach the vocabulary. This orders §11.
  2. Resolving a typed label (amends §6). An id, or a label written with its qualifier (adresse (mémoire)), resolves directly. One exact match (same key, through a label or an alias of a concept that is not merged) resolves. Several exact matches — homonyms, or an alias shared by two concepts — are refused with the candidates (422 concept_ambiguous). A close match only is refused with the "did you mean" candidates (422 concept_unknown), unless the caller explicitly asks to create a new concept. No match, with creation asked: a proposed concept. The same rule serves the web picker and the MCP tools.
  3. Storage. A concept has a label, a qualifier and a description per language; a proposed concept may have one language only (the creator's interface language; the model fills the other later, never inside a question write). Uniqueness is on the key (conceptKey) of label and qualifier, per language, among concepts that are not merged, enforced by a database index, so two teachers creating pointeur and Pointeurs at the same time cannot both succeed. A stored key is recomputed by a migration when the key rule changes. Aliases are not unique across concepts; a collision falls under the ambiguity rule. A merged concept is kept with merged_into, always pointing at the final concept; an old id follows it.
  4. Ownership. The concept module owns the concepts, their aliases and relations, and the links to questions and courses, since a merge rewrites them in one transaction. The pool module sets a question's concepts by calling the concept service inside its transaction, and reads by join.
  5. Rights (settles open question 54 (c)). Any teacher (and an MCP token acting as one) creates a proposed concept; its creator and the admin edit it while it is proposed; once validated, only the admin edits it. Validate, alias, merge and relations are the admin's. Every teacher reads the whole vocabulary, proposed concepts included, so as not to recreate one; a usage count is computed over the pools the reader can reach (ADR-013), never instance-wide. Creating and editing a concept are audited.
  6. A pool editor no longer documents a tag. The description of a validated concept is the admin's; the product owner accepts losing the per-pool tag description (PATCH /pools/:id/tags/:tag) at the cut-over. ADR-017 §5 ("the vocabulary is taught" to the target pool) becomes obsolete at the same moment.
  7. The MCP tools' descriptions say that concepts are what a question exercises — not organisational labels nor kinds of task — so that an LLM client does not recreate what §1 dropped.

Second addendum 2026-10-08: sorting the existing tags

Settled with the product owner before step (b) of the transition (addendum §1). It amends §11 and addendum §5 where named.

  1. The sorting is stored per (pool, tag) in concept_tag_sortings, owned by the concept module: a decision (concept, with the concept it maps to, or drop, with a reason among organisational, task_kind, noise), the model's raw proposal, a state (proposed, accepted), who decided and when. It reads question_tags and pool_tags by join and never writes them. A pool's rows go with the pool. Re-running the proposal adds the pairs with no row and replaces proposed rows only, never an accepted one. One row maps to one concept at most; a homonym inside one pool is fixed by hand after the cut-over.
  2. Accepting a "new concept" creates it validated (amends §11). The admin has just reviewed it, so a second validation pass would be the same work twice. A validated concept has both labels, French and English; the model proposes the missing one and the admin corrects it. A pool tag's description seeds the concept's description. Before the cut-over the admin may delete a concept nothing refers to; afterwards, a concept is merged, never deleted. A suggested broader concept stays a hint in the proposal; relations and aliases come with the curation screen.
  3. The model pass is a background job started by the admin, under its own LLM purpose (sort), billed to that admin within the daily cap (ADR-058). A deterministic pre-pass first groups the tags of every pool by conceptKey; the model then sees the groups in sequential batches, each with the concepts already in the registry or proposed so far, so later batches map onto earlier ones. It receives, per tag: the tag, its count, the pool's name and the tag's description, and at most two question statements cut to about 300 characters, taken from the version's content (never the internal name). No author, course or classroom name. The product owner accepted this on 2026-10-08 (open question 43). The screen works without a model: a row then has no proposal and the admin decides by hand.
  4. The admin role is enough (exception to ADR-054 §2). The sorting screen shows the tag names, pool names and statement excerpts of every pool, private ones included, to an admin without Super Powers. The product owner chose this exception on 2026-10-08: the sorting is a one-off curation of the instance's vocabulary, not the reading of a colleague's work, and it shows excerpts, never a whole question. It covers the sorting routes only.
  5. Audit. Accepting or changing a decision (concept.sort, with the pairs and decisions), validating a concept (concept.validate) and deleting an unused one (concept.delete) are audited.
  6. UI names (settles open question 54 (a)): "Notions" in French, "Concepts" in English. The admin screen is a "Notions" / "Concepts" tab of the administration; its one primary action is accepting the selected decisions.

Third addendum 2026-10-08: the cut-over

Settled with the product owner after the sorting was completed in production (686 pairs, 219 validated concepts) and before step (c). It amends addendum §2, §1(c) and (d), second addendum §2 and the Consequences where named.

  1. One cut-over PR, prepared by inert ones. A dual write of tags and concepts is not coherent: a typed tag would either create a concept (which §1 forbids before the cut-over) or be lost. So the link table, the write resolution and the picker land first, connected to nothing; then one PR switches every reader and writer together — pool list, filter, question editor, bulk bar, move and copy, polls, the drill, the MCP tools, the teacher assistant (ADR-080), the seed, the user guide.
  2. The fill is a SQL data migration of the cut-over: every live question gets the concepts its (pool, tag) pairs were accepted into (following merged_into). Pairs worn only by deleted questions are not carried over.
  3. Late tags are listed, never lost (amends §1(c), (d)). The Notions tab keeps listing the pairs worn by a question with no accepted decision; accepting one after the cut-over adds its links to the questions still wearing that tag. Rows already accepted become read-only at the cut-over, so re-applying a decision never overwrites a teacher's later edits. question_tags and pool_tags are dropped only once no pair is pending.
  4. Creation is explicit, and dropped labels stay dropped (amends addendum §2 and the Consequences). A label that matches no concept is refused (422 concept_unknown) unless the caller asks to create; the MCP tools take an explicit flag, false by default. A label whose key is the key of a tag the admin dropped in the sorting is refused even when creation is asked (422 concept_dropped), so neither a teacher nor an LLM client recreates c01, lecture-de-code or prog-c. Renaming a concept's label onto such a key is refused too. The Consequences' "creating a proposed concept for an unknown one" no longer holds. Amended the same day by the product owner, on the challenge of the cut-over PR: the list of dropped keys is instance-wide while the sorting was decided by pool, so it held real concept names (c, logique, physique, calcul, trace) dropped in one pool only. Two exemptions follow. A label with a qualifier is never refused: the qualifier tells the concept apart from the dropped tag ("Trace (matrice)"). The admin, curator of the vocabulary (§7), is not bound when creating or editing a concept (POST and PATCH /concepts, and the picker's preview); a question write — the editor, the bulk bar, an MCP tool — stays bound for everyone. An edit is refused only when it changes a side's key and leaves it on the list; a side already there whose key stays (a concept older than the drop) may still be edited. Creating a question with a tags field (a client of before the cut-over) is a 400 validation, through the MCP tool as through the route, rather than a question silently without its tags.
  5. The question editor's picker creates a proposed concept from the cut-over on: a label in the interface language and an optional qualifier; the concepts already used in the pool first (§5). The model's fill of the other language and "Suggest concepts" come later (#557); the fill is on the admin's sheet since the amendment of 2026-10-13, not yet in the picker. The picker shows a concept's qualifier wherever it has one (only homonyms carry one), amending §5's "only when ambiguous" for the picker.
  6. The pool's Tags tab becomes a read-only "Notions" tab: the concepts used in the pool, with their question counts, each opening the filter. No description editing (addendum §6). The bulk bar adds a concept. Amended 2026-10-15 by ADR-116: a concept's count is its covered count, the questions its click opens (its own and its narrower concepts'), and the tab's second reading is a map of the hierarchy, one level per view.
  7. Filter and links. A typed #word resolves to every concept it may designate (both languages, aliases, the qualified form) and filters on any of them; the URL carries concept ids, and an old ?tag= is resolved once like a typed word. A concept used by a question is never deleted, only merged; an unused one may still be deleted by the admin (amends second addendum §2, which forbade any deletion after the cut-over).
  8. The drill interleaves by the question's first concept id (no alphabetical meaning left) and reads mastery per concept, labelled in the reader's language; past reviews are regrouped under today's classification (§9).

Fourth addendum 2026-10-10: the tag sorting is retired

Settled with the product owner (option A of #599, step (d)). It amends addendum §1(d), second addendum §1 to §5 and third addendum §3 where named.

  1. Step (d) is done. question_tags and pool_tags are dropped (migration 0098). Their only readers were the sorting workflow and the late-pair links of third addendum §3.
  2. The sorting workflow is retired (amends second addendum and third addendum §3): the admin's Concepts tab, the routes /admin/concept-sorting (list, accept, propose, run), the model pass concept.sort and its job, and the audit kinds concept.sort and concept.sort_propose are removed. Past audit rows keep their action strings. The late pairs of third addendum §3 are no longer listed nor accepted: a tag nobody sorted before the drop is simply gone.
  3. Only the stop list remains. Migration 0098 deletes every concept_tag_sortings row that is not a drop: the concept and pending rows have no reader left, and their foreign key would make every concept born of a tag undeletable. The drop rows stay, frozen (migration 0102 later drops the columns only the other rows used: decision, concept_id, proposal, decided_by, updated_at): they are the stop list of concept_dropped (third addendum §4). concept_sort_runs is dropped, and so is the LLM purpose sort (no database check nor contract parsed it).
  4. A stray concept is deleted, not migrated. A concept that came out of a kind-of-task tag the 2026-10-08 decision drops (§1, §11 as amended in #600) is deleted through DELETE /admin/concepts/:id once unused; a concept still linked to questions is first unlinked by the PO's reviewed one-off SQL. Merging waits for the curation screen (step 5 of the transition), and no migration deletes data of concepts.

Fifth addendum 2026-10-10: the curation of the vocabulary

Settled with the product owner (comment of 2026-10-10 on #599, step 5) after a review of the spec. It adds to §7 and §11, third addendum §7 and fourth addendum §4 where named. Delivery: the queue (list, validate, edit, delete) is the first pull request; merge, aliases and probable duplicates follow.

  1. The admin's queue. GET /admin/concepts lists the concepts that are not merged, proposed first, with the instance-wide number of live questions using each (a deleted question is not counted, though its link still refuses a deletion: the route also says whether the concept is deletable). An admin without Super Powers reads this number; pool names and statements are not in the payload and need Super Powers (ADR-054, amended).
  2. Merge only into a validated target, which keeps its labels. The links are rewritten in one audited transaction: duplicate links collapse, earlier merged_into chains are re-pointed, soft-deleted questions are included, and copyQuestionConcepts is locked against the merge. The audit keeps the moved question ids. There is no undo button. As built: POST /admin/concepts/:id/merge (concept/merge.ts owns the detail), 422 concept_merge_target_not_validated; earlier chains are re-pointed, setQuestionConcepts and copyQuestionConcepts share-lock in id order against the merge's FOR UPDATE, and the audit concept.merge records the loser's former status and the question ids moved or already linked. The course-concept links join this transaction (sixth addendum §3).
  3. Aliases have no language. An alias whose key equals another live concept's label is refused unless the admin confirms explicitly. As built (PR3): only the admin adds or removes one (POST /admin/concepts/:id/aliases { alias, force? }, DELETE .../aliases/:alias (the text; the server computes the key); audits concept.alias_add with the forced flag and the colliding ids, concept.alias_remove). Table concept_aliases, primary key (concept, conceptKey of the text), the text kept as written; Concept carries aliases: string[] so the picker resolves like the server. Typed input is matched by key against the bare labels AND the aliases of the live concepts as one set (resolveConceptLabel): one hit resolves, several are concept_ambiguous. Hence the guard: an alias equal to another live concept's label or alias is a 409 alias_collision naming those concepts until resent with force (checkAlias, @quiz/domain); equal to the concept's own label it is a 422 alias_redundant, to its own alias a 409 alias_exists. An alias never becomes a label and questions store only the id. The stop list is not consulted: an alias is a curated decision, and concept_dropped only answers an input that designates nothing, so an alias on a dropped tag's key resolves. Rename creates no alias. Merge: the loser's aliases move to the winner always (one the winner answers to is dropped), and with keepAsAlias its labels, qualified when they are, become aliases too. As §6 says, the loser's label is dropped unless asked: keepAsAlias is required in the body and the dialog's checkbox starts off. The rule is mergedAliases (@quiz/domain); concept.merge records aliasesMoved, aliasesAdded and aliasesDropped. A label given to a concept (creation or rename) that another live concept answers to as an alias is refused like a taken label (409 concept_exists, the holder named), the mirror of the alias guard. Deleting an unused concept cascades to its aliases (conceptReferenced is unchanged). The catalogue search (ADR-095 §6) does not read aliases in v1.
  4. Probable duplicates are proposed from labels and qualifiers only, when the admin asks, on the daily cap of the LLM gateway, as an ephemeral result never written automatically, after a deterministic pre-pass; a new row of open question 43 records the data sent. As built (PR4a, the pre-pass alone): probableDuplicates (@quiz/domain) pairs the live concepts, one reason per pair, first that holds: alias (a label's key is the other's alias key), translation (the qualified key of the French label of one is that of the English label of the other), homonym (one bare label in one language under different qualifiers: "check the qualifiers", never offered for a merge) and close (two labels within keyDistance, the resolver's tolerance, not equal). It runs in the browser on the GET /admin/concepts list, which already carries labels, qualifiers and aliases: no route, no table; it compares every pair, so it is for hundreds of concepts, not more. As built (PR4b, the model on demand): POST /admin/concepts/duplicates/ai (admin, no body, LLM_CALLS_PER_MINUTE a minute per admin) is the secondary action "Ask the AI" under the deterministic pairs, purpose concepts of the ADR-058 gateway (the default model, effort: medium, 3000 output tokens at most). It sends one line per live concept, c<n> | fr: label (qualifier) | en: label (qualifier), each field cut to one line without |: no description, no alias, no id, no creator, no question or pool. At most 1000 concepts (about 25 tokens each), the proposed ones first then the newest; past that the response says truncated and the hint says the oldest validated concepts were left out. The model files each pair under a kind: close, translation, homonym or related (distinct but connected concepts, meant for the relations of step 7); homonym and related are never offered for a merge, like the pre-pass's homonyms, and the reason is written in the admin's language. The reply is validated by zod, then strictly: an index not sent, an unknown kind, a pair of one concept, a repeated pair or an empty reason is dropped; at most 50 pairs and 200 characters of reason are kept. The browser also drops a pair the deterministic pass lists. Nothing is stored and no concept is written; the llm_calls row is the only trace, and the failures are the gateway's (llm_not_configured, llm_budget_exhausted, rate_limited, llm_failed). Merge (when a kind allows it) and Edit are the same as on the other pairs.
  5. Out of step 5. Relations (broader, related, cycle check) move to step 7, the course concepts. There is no split of a polysemous concept and no "reject" action in v1: an unwanted proposal is deleted when unused, or merged.

Sixth addendum 2026-10-10: the concepts of a course (steps 7–9)

Settled with the product owner (comment of 2026-10-10 on #599, steps 7–9) after a review of the spec. It makes §8 precise. Delivery, in dependency order: 7a the course's list (this addendum's §1–§4, implemented), 7b the pool filter by course (§8, implemented), 7c relations with the merge-cycle policy (implemented, §5), 7d coverage in the filters (implemented, §10), 8a Suggest concepts (implemented, §6), 9a the teacher's mastery on the course's concepts (implemented, §9), 9b the student's mastery (its own ADR first). §5 to §7 are the decided plan, not yet built, except 8a (§6) and 9a (§9); §8 describes 7b as built.

  1. A plain list. A course declares a set of concepts: no chapter, level, weight or order (they would be columns of the table, not another model). It may list a proposed concept, and its Settings create one with the question editor's own picker, through the ordinary creation path (POST /concepts). The list is shown sorted by label, in the reader's language.
  2. Who, and where. Staff only, never in a student payload (invariants 4 and 6): the course is loaded under staffAccess (404 otherwise); every member reads it as concepts in GET /courses/:id (hence the MCP get_course and the teacher assistant's reads; there is no route of its own), and only an owner replaces the set (PUT /courses/:id/concepts { conceptIds }, which answers the new list; 403 owner_required before the body is read; ADR-068 §3). The web app shows it as a "Concepts" ("Notions") section of the course's Settings, editable by an owner, plain text for an assistant.
  3. As built. Table course_concepts (migration 0104): course_id (cascade with the course), concept_id (RESTRICT), added_by (set null), added_at, primary key (course, concept), index on concept_id. It is owned by the concept module (concept/courseConcepts.ts), like question_concepts: the org module sets it through the concept service and reads it by join; no other module writes it. A write replaces the whole set in one transaction. It share-locks every concept it names or links (lockLinkSet, in id order, shared with setQuestionConcepts) and then reads the links again, so a merge in flight finishes first, a merge that committed meanwhile is seen, and a merged or unknown id is a 422 concept_not_found, as for a question. Two saves racing on a course serialize on its rows (the insert ignores a link the other wrote) and the last to commit wins. A link already there keeps its author and date. It is audited as course.concepts_update (added, removed), and not at all when nothing changed. A concept a course lists cannot be deleted: the foreign key refuses, DELETE /admin/concepts/:id answers 409 concept_in_use, and conceptReferenced (the queue's deletable) mirrors it. A merge rewrites the course links in its transaction (insert-select on conflict do nothing, then delete): a course listing both concepts keeps one, and concept.merge records coursesMoved and coursesAlreadyListed.
  4. Room left. Relations (7c) and Suggest concepts (8a) add to this list without changing it. A course's concept does not reach students: showing them their own mastery of it is 9b, under its own ADR written before the code, with its own student exit and leak test (05 §5.7).
  5. Relations (7c, as built). Broader and related edges between concepts, written by the admin only. A merge drops the edges between the loser and the winner and lists them in its audit; a merge that would close a longer cycle is refused (409 concept_merge_cycle) naming the path; never a silent deletion. As built:
  6. Table concept_relations (migration 0105): concept_id, target_id, kind (broader or related), created_by, created_at; primary key (concept, target, kind), index on target_id; both ids cascade (like aliases, relations are not references: they never block a deletion, conceptReferenced is unchanged). A broader row reads "concept_id has target_id as a broader concept"; a related row is stored once with concept_id < target_id (a check), so a pair has one row whichever side was asked.
  7. Rules, pure, in packages/domain/src/conceptRelations.ts. Broader: no self edge, no duplicate, and no edge whose target is at or below the concept (it would close a cycle; the refusal names the shortest closed path A › B › … › A). Several broader concepts are allowed. Related: no self, no duplicate in either order, and refused when one concept is in the other's broader chain, near or far (a hierarchy says more than "related", the two relations are disjoint as in SKOS); symmetrically a broader edge is refused between two concepts that are related already. Only the direct pair is checked: a related pair that a later broader edge puts in one chain stays, harmlessly. Codes: 422 relation_self, 409 relation_exists, 409 relation_redundant, 409 concept_cycle (with path, labelled concepts), 422 concept_not_found (the target is missing or merged), 409 concept_merged (the concept itself), 404.
  8. Routes, the admin's: POST /admin/concepts/:id/relations { kind, target } and DELETE /admin/concepts/:id/relations/:kind/:target (a related pair from either side), both answering the concept, audited concept.relation_add / concept.relation_remove. Concept gains broader and related (ids of live concepts, sorted; a related pair appears on both sides) in GET /concepts and GET /admin/concepts: teachers read them, no student route carries a Concept (they get ConceptRef through the student views).
  9. Lock. A cycle check reads the whole graph, so every relation write, every merge and every deletion of a concept (whose relations cascade, and which a merge may be inserting a relation to) first takes one transaction-scoped advisory lock (pg_advisory_xact_lock on a fixed key): two opposite edges sent together serialize and the second sees the first, and a deletion never meets a merge's insert (no foreign key violation, no deadlock). The two concepts are then share-locked in id order. The advisory lock always precedes any row lock and no writer of question or course links takes it, so there is no lock cycle with lockLinkSet or the merge's FOR UPDATE.
  10. Merge. In the merge's transaction, mergedRelations rewrites the loser's edges onto the winner: an edge between the two is dropped (it would be a self-loop), an edge the winner has is not written twice, a rewritten related edge keeps the canonical order, and a related edge whose pair becomes a broader pair (the loser was related to x, the winner is below x) is dropped, stored or rewritten, so the two relations stay disjoint after a merge. A rewrite that closes a longer cycle through the winner (the loser is below x and x below the winner) is refused 409 concept_merge_cycle with the path BEFORE anything is written. The audit records relationsOfLoser, relationsMoved and relationsDropped (broader and related between the two, and hierarchical for the related edges dropped as above; { concept, target } edge lists), enough for a reviewed SQL undo. The rewritten edges take the merging admin as author.
  11. Web. The concept sheet has a Relations section (broader chips and a search offering only the concepts the rule would accept; related likewise); the cycle refusal is shown naming the path. The picker shows, under a concept that has broader concepts, its path from the root down (Memory › Heap), following the first broader concept by name; homonyms keep their qualifier. In the Duplicates view a model's related pair gains a secondary "Add as related" action. As built (2026-10-15, #897 step B): the curation queue shows the same path under each row's name, the sheet shows it read-only at its top with a "+n" count of the other broader concepts, and an Unplaced filter lists the concepts with no broader and no narrower concept, whatever their status; the rule is unplaced of @quiz/domain, applied client-side to the loaded vocabulary's broader lists. The queue has a tree reading (2026-10-15, #897): a view switch on All, To validate and Validated draws the same rows indented under the broader concept their path follows, each concept once with its other broader concepts named, the ancestors of a shown concept as path-only rows (conceptTree, client-side). As built (2026-10-16, #897, step D closed as covered by A1, B and C): the sheet's Relations show, after the broader concepts, a read-only Narrower concepts list (the live concepts naming this one in their broader, by name, a proposed one marked, no name a link: the edge belongs to the child and is placed from its sheet); each candidate of either search shows its path; a broader candidate also "Currently holds" its direct narrower concepts (five, then "(+n more)"), which describes its button for a screen reader; both read directNarrowerOf of @quiz/domain, client-side, and the searches' accept rules are unchanged.
  12. Not here. A concept selected anywhere covering its narrower ones (§4 of the Decision) is 7d (§10).
  13. Suggest concepts (8a, built). It sends the draft's statement and choices plus the labels and qualifiers of every live concept (no descriptions, no creators); at most five existing concepts and two new labels come back, and a new label goes through the explicit create form. Hidden when no model is configured. Open question 43 carries the sentence on the data sent. It lives in the editor's AI card (ADR-082 §5 and its amendment of 2026-10-10), not beside the picker. As built:
  14. Route and access. POST /app/api/questions/:id/suggest-concepts { config } (contracts SuggestConceptsRequest, strict), a pool contributor's like the "Generate answers" wand (a reader 403, an outsider 404); the question's type is the server's. Ten calls a minute per teacher (LLM_CALLS_PER_MINUTE), then the gateway's daily cap; errors are llmArms'. A draft whose text is empty is a 400 statement_empty before any model is asked (the wand's own refusal, kept for an excerpt-less draft too).
  15. Purpose and model. A new purpose suggest in LLM_PURPOSES (a text column, no migration), on the default model of the settings: choosing among hundreds of bilingual labels is a judgement the fast model would make worse, and the call is rare and asked for by a person.
  16. Payload. The draft's excerpt, cut at 6 000 characters: the model excerpt of the pool description (questionExcerpt, built for prompts) read from the type's student view, never from the key. It is the top-level prompt (or a cloze's template, blanks shown as ___) and the text or label of the items of the view's top-level lists: a multiple-choice question's choices (without which are correct), a categorize question's columns and cards, a program's template segments. Never the internal name, the explanation, the answer key, a short answer's accepted answers or a hidden case. A draft that does not parse, or has no statement, sends nothing (400 statement_empty). Then one line cN | fr: label (qualifier) | en: label per live concept not already on the question (validated first, then oldest, at most 1 000), with no id, description, alias, count or creator.
  17. Reply, validated against the vocabulary read again after the call. Indexes the call did not send are dropped; a concept merged meanwhile is followed to its winner; one already on the question, a repeat, or an empty reason is dropped; at most five existing. Each "new" label goes through the resolver: one exact match (label or alias) becomes that existing suggestion, a close match only becomes a "did you mean" existing suggestion (asked carries what the model typed), an ambiguous one is dropped, a key on the stop list is discarded, and what is left is a new label (at most two, one per key).
  18. Nothing is stored and no concept is created: the llm_calls row is the only trace. The editor ticks the existing concepts (the question's own concept write, like the picker) and a new label opens the picker's create form filled in. A new label travels as { label, qualifier }, each within its maximum, and so does the asked of a "did you mean".
  19. Mastery (9a built, §9; 9b not built). The teacher's mastery shows the course's concepts with coverage, "no evidence" rows and an "outside the course" bucket, with no student by concept matrix in v1. The student's mastery (F-DRILL-05) shows course concepts only, rolled up, validated concepts only; a level only from five reviewed cards, otherwise "no data yet"; no class comparison or ranking and no "Practise this concept" button (ADR-041 §6 and §11 stand); FSRS fading is accepted. Reserved for its own ADR. Amended by ADR-115 (2026-10-15): practice by concept exists as targeted revision — the course's validated concepts, the student's own cards covering them through coverage, outside FSRS and the level.
  20. The pool's course filter (7b, as built). GET /pools/:id/questions takes course=<courseId> (QuestionSearch.course): the questions that exercise any concept the course covers. The expansion is the one function conceptsCoveredByCourse (concept/courseConcepts.ts); a concept covers itself and its narrower ones (7d, §10), so the course covers more than its list. A course that lists nothing matches nothing. Access: the course must be one the caller staffs (staffAccess, or any course under Super Powers) and that the pool is linked to (course_pools, either mode); otherwise the answer is the 404 of a missing entity, the same body for an unknown id, so a course the caller does not staff is never revealed. The pool's linked courses stay the owner's to see (PoolMembers.courses): the screen offers its own list, PoolDetail.filterCourses (id, name, code: the linked courses the caller staffs, computed server-side by filterCoursesOf), and PoolDetail.courseConceptIds, the concepts those courses cover together (one query, conceptsCoveredByCourse(db, courseIds), the function 7d widens). The poll launcher's search across pools has no such filter (PollPoolSearch omits it). Web: a "Course" ("Cours") block in the filters sheet (one course at a time, shown only when the pool has one on offer), a removable chip, and the search token course:<code or name> (the last one wins; a word naming no course on offer is a dashed chip that filters nothing, as for a concept). The MCP list_questions takes courseId under the same rule. Picker (§5): the question editor's ConceptPicker and the bulk bar's rank the concepts of those courses first, beside the pool's own (first set), so a pool ranks concepts one way.
  21. The teacher's mastery on the course's concepts (9a, as built). GET /classrooms/:id/drill/mastery answers DrillClassroomMastery (packages/contracts/src/drill.ts owns the shape). No migration, no student by concept matrix, nothing for a student.
  22. Coverage in one place. One rule, coverageOf of @quiz/domain, gives, per concept, the concepts it covers (itself, then its narrower ones since 7d, §10); one loader, coverage(db, conceptIds) in concept/courseConcepts.ts, applies it to the stored edges. conceptsCoveredByCourse and the mastery use the loader, 9b follows, and the pool's concept map applies the rule to the edges it reads (ADR-116 §1). Amended 2026-10-15 by ADR-116: the rule moved to @quiz/domain as coverageOf; coverage became its loader.
  23. Counting is the pure drillMastery of @quiz/domain, callable for one student by 9b: a card counts toward every listed concept that covers one of its question's concepts; a listed concept nothing counts toward is a line without evidence (null, never 0 %); cards no listed concept covers, or without concept, go to an outside bucket, line by concept.
  24. Flat fallback. A course listing no concept gets the flat per-concept view, and the screen says to declare them in its Settings.
  25. Suspended cards are excluded, through keyOpen of drill/review.ts, the one loader of "may this card's key reach its student" that also serves the drill. Kept on the teacher side so the teacher and the student (ADR-098 §7) agree, pending the product owner's confirmation.

  26. Coverage of narrower concepts (7d, as built). The §4 rule is in force: a concept selected anywhere covers its narrower ones, near or far, through any number of parents. coverage(db, conceptIds) (concept/courseConcepts.ts) reads the broader edges once and returns, per id, [id, ...narrowerDescendants] (@quiz/domain); coveredConcepts is the union. Only broader edges widen it: a related concept is never covered, and a merged concept's edges were rewritten onto its winner (§5). No migration. Every reader goes through it:

    • The pool's concept filter (QuestionSearch.concept, a typed #word) and the poll launcher's search across pools, which share filterWhere, widen the selected ids at their entry points (withCoveredConcepts, pool/questionList.ts); a question matches when linked to a selected concept or to any narrower one.
    • The MCP list_questions concepts filter calls the same route, so it follows; its description says so.
    • The course filter (7b), PoolDetail.courseConceptIds (the picker's ranking) and the teacher's mastery (9a) use conceptsCoveredByCourse or coverage and follow without another change: a card of a narrower concept counts toward the listed broader one.
    • Not widened, by design: the pool catalogue's text search (a word matched against concept labels, not a concept selected), the concept suggestions and the drill's per-card concept (a question's own concepts), and the question editor's picker (it chooses a link, which stays exact).
    • Web: the mock applies the same domain function; the course filter hint says narrower concepts are included (the pool's Concepts tab counts covered questions and draws the hierarchy, ADR-116). Amended 2026-10-15 by ADR-116: this point said "No other screen changes.", which no longer holds for the pool's Concepts tab.

Amendment 2026-10-13: Fill with AI on the admin's sheet

Settled with the product owner on 2026-10-13 (#817). It carries out the model's fill of §7 ("fills the other language's label, the descriptions…; the teacher sees and may edit them"), deferred by the third addendum §5, on the admin's concept sheet only. The rest stands.

  1. Surface. The concept sheet of the curation screen (/admin/concepts) gets a secondary Fill with AI button; Save stays the primary action. It is shown only when the platform has a model (GET /generate/availability), and enabled when the form holds a label and has an empty label or description. The teacher's picker is out of scope (a later step); the suggested broader concept of §7 is not part of it.
  2. What leaves (open question 43). The sheet's form as it stands, saved or not: this concept's labels, qualifiers and descriptions in both languages, nothing else — no id, alias, relation, question, pool, journal, course or person.
  3. The route. POST /app/api/admin/concepts/:id/fill, the admin's, under the existing purpose concepts (the gateway's cap, the llm_calls ledger, the same per-minute budget as the probable duplicates). Nothing is stored and nothing is audited: the proposal goes back to the sheet. A form with no empty field makes no call; one without any label is a 422 concept_label_missing; a merged concept a 409 concept_merged.
  4. Only empty fields, never an overwrite. The model proposes a label (with the translation of the qualifier when the other language has one) only for an empty label, a description only for an empty one, clamped to CONCEPT_LABEL_MAX, CONCEPT_QUALIFIER_MAX and CONCEPT_DESCRIPTION_MAX. The sheet puts them in the fields still empty when the answer arrives; what the admin typed meanwhile wins.
  5. A proposed label goes through the resolver (resolveConceptLabel, against the vocabulary less this concept), as Suggest concepts does: one that already names another concept comes back as taken and is NOT put in the form — the sheet says "did you mean" that concept, this one being perhaps its duplicate, instead of a 409 concept_exists on Save; one only close to some is filled, and the sheet names them.
  6. The sheet's Save (PATCH /concepts/:id, audited concept.edit) is the only write, unchanged.