Historical snapshot: superseded wording and delivery history are preserved here. The record's text as of 2026-10-03, unchanged except for a reading map and two relative-link fixes added for navigation. For current decisions, read the active ADR. Load this archive only for rationale or a historical section reference.
ADR-014 — Live polls: an evaluation of one question, a code, and participants without a roster¶
Status¶
Accepted (2026-09-21, phase 2). Settles F-LIVE-13, F-LIVE-14 and F-AUTH-05, and lifts
decision D7 of docs/PLAN-MVP.md along one path only (below). Amended by ADR-053
(2026-09-30): the exam and exercise access code is removed, so the session code of
decision 1 is now the only use of evaluations.access_code (CHECK
evaluations_access_code_poll_ck). Amended by ADR-056
(2026-10-01): a poll refuses a parameterized question; the beamer and the phones must
show the same values.
Reading map¶
Read the Status above for cross-record amendments. Within this record, use the sections below before applying the original Decision; earlier wording is historical where these sections change it.
| Topic | Read |
|---|---|
| Audience and access; replaces original classroom/anonymous coupling | Audience |
| Question creation without a pool | Inline questions |
| Retrieval of unsaved questions; qualifies the earlier absence from lists | Recent polls |
| Question sources beyond the personal pool | Accessible pools |
| Expiration without answers | 12-hour expiry |
| Votes and reveal; replaces the earlier three-step progression | Independent switches |
Context¶
A teacher in front of a class wants to ask ONE question, right now, and watch the answers stack up on the beamer: a bar per choice for a multiple choice, the distinct texts for a short answer. The room answers on its phones, by scanning a QR code. Nothing about this is an exam: there is no roster, no schedule, no grade, and half the room may not have an account on the platform at all (F-AUTH-05).
Four facts of the existing codebase shape the design:
- decision D7 says
pollis phase 2: themodecolumn and theEvaluationModeenum accept it, and every code path that meets it answers501 not_implemented. The column, the state machine, the attempts, the answers and the SSE topics therefore already exist and need no migration; - invariant 4: question content only ever reaches a participant through
studentView/toStudent. A public page is content reaching a participant; - invariant 6: access is LOADED, never checked afterwards. A poll is a teacher surface plus a public surface, and the public one has no session to load an identity from;
attempts.user_idwasNOT NULLand every read assumed an account behind every attempt.guest_participantsexisted in the schema, documented as "phase 2, no route writes it".
Decision¶
- A poll IS an evaluation of mode
poll, in a classroom the teacher picks, with exactly ONE item, created AND started in a single call (POST /app/api/polls). Its settings are theexercisepreset plussettings.poll = { anonymous, revealed }, itsaccess_codeis the six-character session code shown on the beamer, and its state isrunningfrom the first instant: there is no draft to review and no lobby to fill.
D7 is lifted along THIS path and no other. createEvaluation still refuses
mode: "poll" (501), because a poll is never authored item by item; the poll module
calls createPollEvaluation, which lives in modules/evaluation/service.ts because
evaluations and evaluation_items are that module's tables and no other module writes
them. enterEvaluation — the student route of an exam — keeps refusing a poll too: a
participant of a poll comes in through /app/api/p/:code, never through the roster.
-
The session code, six characters of
ABCDEFGHJKLMNPQRSTUVWXYZ23456789— noI,O,0or1, because it is read off a beamer and typed on a phone. It is unique among the polls a code still reaches: the running ones, and the ones that ended less than two hours ago. That grace window is what lets a phone keep showing the question and the revealed key while the teacher comments the result; afterwards the code is free again. -
The teacher's personal pool is the home of poll questions (F-POOL-01), created LAZILY by
ensurePersonalPoolon the first poll question — namePolls,private,is_personal— and unique per owner through the partial indexpools_personal_uq. It is an ordinary pool afterwards: it shows on the pools page, it can be renamed and shared.
The launcher never learns its id: POST /app/api/polls/questions ensures the pool and
creates the question in it through the ordinary pool service, and
GET /app/api/polls/questions lists its published mcq/short questions, most recently
USED first. "Used" is read from the poll evaluations that froze a version of the question
(evaluation_items → evaluations.mode = 'poll'); no column was added for it.
- A participant is not a roster seat.
attempts.user_idbecomes nullable,attempts.guest_id → guest_participantsis added, and the schema — not a service — carries the rule:CHECK ((user_id is null) <> (guest_id is null))plus a second unique index(evaluation_id, guest_id)beside the existing(evaluation_id, user_id). The idempotency ofensureAttemptis therefore the same for both: one attempt per (poll, browser) and per (evaluation, account).
Participant becomes { userId, guestId, timeBonusPercent } and everything downstream —
ensureAttempt, beginAttempt, saveAnswer, attemptView — is reused unchanged. The
three places that assumed an account say so now: the grading panel joins users with a
LEFT JOIN and names a guest "Guest n", the dashboard grid iterates the ROSTER and so
simply does not show guests, and dashboard.attempt is not emitted for a guest because
there is no roster row to light up.
- Guest identity is a cookie and a hash.
quiz_guest,HttpOnly,SameSite=Lax,Secureoutside development, path/app/api/p, 12 h, value a random 32-byte token. The database storessha256(token:evaluationId), never the token. Binding the hash to the evaluation is what lets ONE cookie serve a browser across several polls whiletoken_hashstays globally unique, and what stops a hash read out of one poll's table from being replayed as another poll's participant.
The cookie is minted on POST /p/:code/join, only when the poll is anonymous and there
is no session. A signed-in browser joins as its account and gets an ordinary user attempt
— participantOf is bypassed for a poll, which is exactly F-AUTH-05 — and never a guest
row as well: one browser, one identity per poll.
-
The public surface is
/app/api/p/:code, unauthenticated, three routes: read, join, answer. It is safe because of what it can REACH, not because of who is behind it: a caller must hold a code that only exists while a poll is live, the only thing they can write is an answer to that poll's single question, and the guest cookie carries no identity and is worth one vote in one poll. The double-submit CSRF check ofrequireSessioncannot apply (there is no session), so the rule is: when aquiz_csrfcookie IS present — a teacher or a student trying the poll in their own browser — thex-csrf-tokenheader must match it, so a cross-site form cannot spend THEIR vote; a browser with no such cookie has nothing to steal and is let through. -
The reveal is one fact written in two places.
settings.poll.revealedis what the projection and the phones read;feedbackPolicy.showKey/showExplanationmove with it, because a signed-in participant also holdsGET /attempts/:id/feedback, and leaving that behind would publish the key before the teacher revealed anything. A poll is therefore created withimmediatefeedback and the key held back. -
"End" stops at
closed, and does not release. The glossary sends a poll fromrunningto the end in one move, and the end here iscloseEvaluation— the attempts are expired, the state becomesclosed, the deterministic grading pass is enqueued — with the release deliberately NOT taken.releaseResultscomputes the grade table from the classroom's ROSTER, freezes it inreleased_gradesand makes a card appear on every enrolled student's results page: a 1.0 for a poll most of them never saw, plus aresultshint telling them to go and look at it. That is the absurd outcome the spec warns about, and a poll has nothing to release: its result is the tally on the beamer.state: "ended"on the public view is what a phone reads, and it isclosedunderneath. -
The tally is a pure rule.
pollTallyin@quiz/domaintakes the type, the number of canonical choices and the stored payloads:mcqgives one entry per canonical choice, in canonical order, zeroes included;shortfolds the spellings (NFC, whitespace collapse, trim, case) and displays the FIRST one seen, most frequent first, capped atPOLL_SHORT_CAP. A poll never shuffles its choices — the beamer and the phones must show the same screen, and a bar is labelled by canonical index.
It travels as the poll.tally event of docs/spec/05 §5.4: the whole tally, never a
delta, on evaluation:<id>, staff only (the room must not read the distribution
before the teacher shows it), coalesced 500 ms per evaluation by the bus.
-
The login return path.
GET /app/auth/login?next=/p/ABC123andPOST /app/auth/devwith anextfield both come back to the poll. One validator,safeReturnToinauth/returnTo.ts: a same-origin absolute PATH, nothing else — no//host, no scheme, no backslash. For OIDC the path travels in the SIGNED stash cookie beside the PKCE verifier, never in thestatethe IdP echoes back. -
WEB_URL. The QR code encodes${WEB_URL}/p/<code>, which must point at the SPA. It defaults toPUBLIC_URL, which is right in production, where the monolith serves the built SPA; in development the SPA is Vite on :5173 andWEB_URL=http://<ip>:5173is what makes a real phone able to scan the code.
Consequences¶
- A poll appears in its classroom's evaluation list, like any other evaluation. That is the price of reusing the whole machine, and it is also how a teacher finds the poll they ran last Tuesday.
attempts.user_idis nullable everywhere. The check constraint means a bug can no longer produce an attempt owned by nobody or by two, and the 301 tests that existed before this ADR pass unchanged.- Nothing about a guest reaches the grade table:
computeResultswalks the roster, and a guest has no seat in it. A poll's CSV is the classroom's, with no answers in it. - The audit log gains
poll.create,poll.revealandpoll.end; a guest is an actor the log never names, because there is no identity to name. docs/spec/06-questions-ouvertes.mdrows 15 and 16 are settled here.
Rejected alternatives¶
- A
pollstable of its own. It would duplicate items, attempts, answers, the autosave gate, the deadline rule, the grading pass and the SSE topics — and it would need a secondstudentView, which is the one thing invariant 4 says must exist once. - A guest as a
usersrow. It would put a fictional account in the identity table, which every role rule, every roster claim and every notification reads. A guest is not a person the platform knows; it is a browser holding a cookie for twelve hours. - A guest token in the URL (
/p/ABC123?g=…). It survives a copy-paste into a chat, and two students then share one vote. A cookie scoped to/app/api/pdoes not travel. sha256(token)alone. One cookie could then only ever identify one poll, so a second poll in the same lecture would overwrite the browser's identity in the first one.- Releasing the poll at the end (the letter of the glossary's
running → released). See decision 8: it writes a grade for people who were never there. - A
poll.tallyaddressed to everyone. The room would read the distribution as it forms, which changes the answers — and it would publish the majority answer before the reveal.
Addendum (2026-09-23): a question written in the launcher is not saved¶
Decision 3 made the personal Polls pool the only home of a poll question: "Ask a new
question" created a question there and sent the teacher to the full editor, to publish it
and come back. In the middle of a lecture that is four screens for one question. The
launcher's second tab now holds the type's own editor and starts the poll on what is
written, without saving anything a teacher would find again:
POST /app/api/polls/inline(PollInlineCreate:classroomId,anonymous,type,config). The classroom is loaded through the staff predicate, likePOST /polls; a type other thanmcq/shortis the same422 poll_type; the config goes through the type's ownconfigSchemathrough the registry (saveConfig, the publication gate) and a refusal is422 config_invalidwith the zod issues, which the launcher places under the fields. The launch is audited aspoll.createwithinline: true.- The content is an ordinary frozen version of a question that belongs to no pool.
questions.pool_idbecomes nullable (migration0010_poll_unsaved_questions); thepoolservice'screateUnsavedQuestionwrites the question withpool_idnull and ONE published version (number 1, no draft), and the poll's item freezes it exactly as it freezes a saved question's. Everything downstream —studentView/toStudent(invariant 4), the key on reveal, the tally, the grading pass, "Run again" — is the same code on the same tables. Its name, and the poll's title, is the start of its statement: what the room reads anyway. - Nothing reaches it but its polls. Every pool listing filters on
pool_id, andfindAccessibleQuestionjoinspools, so it is in no list, no search, no count and no editor;addItemsrefuses it for any other evaluation. A teacher who wants to keep a question says so after the poll — see 6. - No marks, so no marking settings.
EditorProps.ungradedasks an editor to leave out what only decides a mark: the scoring card ofmcq(policy, cap, shuffle opt-out), the points and the prefilters ofshort. The KEY stays, as something the teacher MAY write — see 5. An unsaved question has no pool to hold an image, so the launcher lends no upload. - The key is optional: an opinion poll (decided 2026-09-23, after the first version of this addendum required a key). "Which lab rhythm suits you?" has no right answer, and forcing the teacher to tick one puts a false "Correct answer" on the wall.
- Validation. The server contract (
@quiz/core,QuestionTypeServer) gains two optional members:keylessConfigSchema, the type'sconfigSchemawith the key made optional and NOTHING else relaxed (anmcqstill needs two choices, asingleone at most one key; ashortmatcher that is there is still checked), andhasKey(config).mcqandshortimplement both.saveConfig(type, config, { keyOptional: true })is the one write that uses it, and onlycreateUnsavedQuestioncalls it. Publication and the draft issues (tryLoadConfig) stay on the strict schema, so a pool question, and therefore every evaluation, still demands a key — tested. The read path (loadConfig) parses with the keyless schema when a type has one: a read accepts what any write gate accepted, and a stored config always passed one. - Grading. The grading pass skips an item whose config
hasKeydenies: no zero per answer, which would mark the whole room wrong. A keyed poll is graded as before. - Reveal.
toSolutiongives an empty key ({ correct: [] },{ expected: [] }). The wall marks nothing (no tick, no faded row) and its switch reads "Results shown" instead of "Answer revealed".PollPublicViewcarries thetallyonce revealed (null before), and a phone whose key is empty shows the distribution with its own answer named — no "correct", no "wrong", no score. The question itself still leaves only throughtoStudent(invariant 4). Amended by the addendum of 2026-09-29: a keyless poll has no reveal; its votes switch is what hands the phones the distribution. - Launcher. The blank question starts WITHOUT a key (no choice ticked, no accepted
answer), one muted line says marking one is optional, and the
shorteditor lets its last accepted answer go whenungraded.
Rejected: relaxing configSchema itself and checking the key at publication only. The
type's schema is what the editor's issues, the canonical import and every test read;
moving the key out of it would weaken all of them to serve one launcher.
6. "Keep this question" (decided 2026-09-23). Since the launcher stopped writing into
the Polls pool, nothing created that pool any more, and "Pick a question" drew from a
pool a new teacher could not get. The poll screen now offers to keep the question:
- Route. POST /app/api/evaluations/:id/poll/keep, no body, beside reveal, end and
again: the poll is loaded through the staff predicate (404 otherwise, 401/403 before
that), so whoever manages the poll may keep its question, and it lands in THEIR
personal pool. pool.keepUnsavedQuestion calls ensurePersonalPool (the pool is still
born on first use, now by the first keep) and ATTACHES the question: pool_id set, no
copy — the question and its published version already exist and the poll keeps
pointing at them. It gains what every pool question has: a name free in the pool (its
statement, then … (2), since questions_pool_name_uq is per pool) and a draft, the
published config stamped with the publication's time so the list shows no pending
change. The UPDATE is guarded by pool_id is null, so two simultaneous keeps attach it
once. Keeping a question already in a pool changes nothing and answers the current
view (idempotent). Audited as poll.keep; the pool's topic and the caller's own topic
get a hint (a pool created a moment ago has no subscriber yet).
- View. PollTeacherView.question gains saved and pool ({ id, name }, only when
the caller reaches that pool through the pool predicate: a colleague on the same staff
learns the question is kept, not where a private pool is). The projection offers the
action in its menu while the room answers — the wall is the room's — and as a secondary
button beside "Run again" once the poll has ended; afterwards the button reads "Kept in
Polls" and opens the question.
- Empty tab. GET /polls/questions still answers [] without a personal pool (a read
never creates one); the launcher's empty state says questions kept after a poll land
there and points to "Ask a new question". The pool is never created in advance: a
teacher who never polls never sees an empty Polls pool.
- No key, no evaluation. A kept question may have no key (5). It runs a poll again from
the pick list, but addItems refuses it with 422 question_keyless — the route passes
the type's hasKey, as it passes defaultPoints — and the evaluation's question picker
shows it disabled, "polls only" (QuestionRow.keyless, the latest published version has
no key).
- Editor rule. Opening a keyless kept question asks for nothing: its draft is the
keyless config, stored as drafts always are (D16), and the "incomplete" alert appears
only once the teacher edits, like any invalid draft. One muted line
(QuestionDetail.keyless) says it runs polls, not evaluations, and that publishing a
new version asks for a key. Publication stays on the strict schema: the only keyless
version a pool can hold is the one the poll wrote. Rejected: a keyless publication
path for the Polls pool — it would make a second kind of pool question that every
evaluation path would have to screen, for a question that already runs as it is.
Rejected: a nullable evaluation_items.question_version_id beside an inline config
column. Every reader of an item — joinedItems, grading, results, the dashboard, the
autosave gate — joins question_versions and questions; a second shape of item would
have to be taught to each of them, and a second path to toStudent is the one thing
invariant 4 forbids.
Addendum (2026-09-27): the votes are hidden until the teacher shows them (#157)¶
The projection drew the distribution as the votes arrived. The room reads the wall, and the last to vote followed the longest bar: the herd effect decision 7 keeps off the phones came back through the beamer.
- Three steps, one progression. What the wall shows is
hidden→votes→answer(PollDisplay, derived bypollDisplayOffromsettings.poll). A reveal implies the votes. An opinion poll has two steps,hidden→answer: its reveal is what hands the phones the results, so a wall-only step would say nothing more. Superseded by the addendum of 2026-09-29: two independent switches. - Server state.
settings.poll.votes(defaultfalse) sits besiderevealedand moves through the same route,POST …/poll/reveal { revealed, votes? };votesomitted leaves it where it was. A reload, or a second screen on the same poll, shows the same step. - Wall only.
votesnever reaches a phone:PollPublicView.tallystays null until the reveal, and thepoll.tallyframes stay staff-only. Hidden, an mcq keeps its choices on the wall without bar or figure; a short-answer poll lists no answer. Amended by the addendum of 2026-09-29:votesreaches the phones too; the frames stay staff-only. - No menu. The projection's
…menu is gone: the way back is an arrow before the context line, "Keep this question" a bookmark icon, "End poll" a named ghost button, still confirmed. The arrow keys and Page Up / Page Down (a presenter clicker) walk the steps.
Addendum (2026-09-27): "Recent polls" lists every question the teacher ran¶
Issue #161. Addendum 2026-09-23, item 3, said an unsaved question "is in no list, no search, no count and no editor", and item 6 made "Keep this question" the only way back to it. In practice a teacher's first wish in the launcher is to re-run a question they asked last week — most of which they wrote inline and never kept — and those were unreachable once the poll ended. Decided by the product owner: the list INCLUDES them.
- The tab is "Recent polls" (« Derniers polls »).
GET /app/api/polls/questionsanswers the questions of the polls the caller LAUNCHED (evaluations.mode = 'poll',created_by= caller), one row per question, most recent run first — kept or not — then the published questions of their personal pool that never ran, most recently edited first.PollQuestionPickgainssaved(the question sits in a pool) andoutcome. "Used" and "use count" now count the caller's own runs. A pool question is listed only whilepoolAccessstill lets the caller reach it, so every row can be relaunched. The launcher opens on this tab when it has rows, on "Ask a new question" otherwise. - Item 3 is amended, not dropped. An unsaved question is still in no POOL list, no
search of the pool screens, no count and no editor, and
addItemsstill refuses it. It is reachable from exactly one more place, the caller's "Recent polls", on exactly one ground: they ran a poll on it.POST /app/api/pollsrelaunches it throughfindOwnUnsavedPollQuestion(guards.ts) —pool_idnull AND a poll created by the caller froze a version of it, or the caller is an admin — tried after the pool predicate; a colleague on the same staff gets the ordinary 404 (they reach the poll, and "Run again" on it, through the staff predicate as before). The relaunch reuses the same question row and its one version; nothing is copied. "Keep this question" is unchanged: it is how a question gets into a pool, not how it stays reachable. - The outcome is a pure rule,
pollOutcomein@quiz/domain, fed per FINISHED run (a running poll is still moving) with{ keyed, answered, correct, roster }: keyedis the type'shasKeyon the FROZEN version;correctcounts the answers whose validated grading has full marks (points >= max_points > 0) — the grading pass is what decides right and wrong, nothing is re-graded for a list; a keyed run with answers and no grading yet is not a result and is skipped;rosteris the classroom's current non-staff enrolments for a poll that asks who answers, and NULL for an anonymous one. It is keyed on "has a roster", not on the classroom (rosterOfRun): an anonymous poll knows nobody who was meant to answer, and a classroom-less poll (#160) has no roster at all;- the kind follows the newest run (keyless → "n answers" per run, on average; keyed →
donut), runs of the other kind are dropped, and the window is the last five runs
left (
POLL_OUTCOME_WINDOW); - abstention is shown only when EVERY run of the window had a roster; one anonymous run folds the donut to correct / incorrect over the answers — an average of "share of the room" and "share of those who answered" means neither;
- the average is the mean of the per-run rates, each over
max(roster, answered)(a signed-in teacher trying their own poll has no seat) or overanswered; a run with no denominator is left out. Whole percentages are distributed by largest remainder, so a tooltip always sums to 100. - Drawing it is
apps/web/DESIGN.md, "Poll outcome donut":success/warning/ grey, fixed clockwise order, 2 px gaps, the correct share in the hole, every share in words on hover and focus.
Rejected: listing only kept questions and nudging the teacher to keep more — the point of
the inline path is that a lecture has no time for filing; a last_polled_at column on
questions — the polls already hold that fact; recomputing correctness from the answer
payloads with the type's grade — a second grading path whose verdict could disagree with
the grading panel's; the roster frozen at poll time — no table holds it, and a roster that
changed since is a rare, visible drift on a five-run average.
Addendum (2026-09-27): the audience of a poll — anonymous in no classroom, or a classroom's by name¶
Decision 1 put every poll "in a classroom the teacher picks", and the launcher asked two questions: a classroom, and whether the poll was anonymous. Of their four answers only two meant anything. "A classroom, anonymously" filed the poll under a class that anybody holding the code could answer; "a classroom, not anonymously" let ANY signed-in account in, roster or not, so the class it was filed under restricted nothing. The product owner settled it (issue #160): the audience is one choice, and "a classroom, anonymously" is dropped.
- Two audiences, one contract.
PollAudiencein@quiz/contractsis{ kind: "anonymous" } | { kind: "classroom", classroomId }, and it replacesclassroomId + anonymousinPollCreateandPollInlineCreate. A body in the old shape is a400, not a silent default. The launcher shows ONE control, "Who answers": "Anyone with the code (anonymous)" and one entry per classroom the teacher is on the staff of; it remembers the last choice as it remembered the classroom, and falls back to "anyone" when nothing is remembered or the classroom is gone — "anyone" needs no classroom, so a teacher with none can poll. The MCPcreate_polltool takes an optionalclassroomIdinstead of a flag. - An anonymous poll belongs to no classroom.
evaluations.classroom_idbecomes nullable (migration0018_poll_audience), and the schema, not a service, says for what:CHECK evaluations_home_ck (classroom_id is not null or (mode = 'poll' and created_by is not null)). An exam or an exercise without a classroom cannot be written, and neither can a classroom-less poll that nobody owns. The owner iscreated_by, which every evaluation already carries — see the rejected alternative below. A partial indexevaluations_owned_poll_idx (created_by, created_at) where classroom_id is nullserves the owner's list. settings.poll.anonymousis derived, never stored. A poll is anonymous exactly when it has no classroom.EvaluationSettings.pollkeepsrevealedonly (an older row'sanonymouskey is dropped by the parse);pollSettingsOfcomputes{ anonymous: classroom_id is null, revealed }, and the views keep carryingsettings.anonymousso the phone and the projection read it where they always did. A running poll created before this addendum as "classroom + anonymous" becomes a classroom's poll: its guests' answers stay counted, and its page asks every browser without a session to sign in. Polls run for minutes; no migration rewrites them.- Access (invariant 6). One predicate is added beside
staffAccess:ownedPollAccess(userId)—classroom_id is null and created_by = userId— and one finder,findManagedEvaluation: the evaluation through a staff seat on its course, or, when it has no classroom, through ownership; an admin reaches both (accessWhere). The poll routes load through it, so a colleague — even one on every staff the owner is on — gets the 404 of a poll that does not exist.findReachableEvaluationstarts with it too, which is what lets the owner's projection open?watch=evaluation:<id>and receivepoll.tally.loadEvaluationkeeps its classroom join on purpose: the generic evaluation routes (settings, items, dashboard, grading, results, CSV) have nothing to offer a classroom-less poll and answer it 404, its owner included. - A classroom's poll admits its roster and its staff, signed in. The public loader
(
publicScope) loads the signed-in viewer of a classroom's poll throughfindReachableEvaluation— a CLAIMED seat on the roster, a staff seat, or an admin — before anything is read, joined or answered. Anybody else signed in gets403 { error: "not_on_roster" }, and the phone says the poll is for another class. It is a 403 and not the usual 404 because the code is on the wall of the room: the poll's existence is no secret, the refusal carries nothing of its content, and its reason is the one thing the reader can act on (another account). A browser with no session is still sent to the login (me.loginRequired,401 login_requiredon join), with the path back to the poll.participantOfstays bypassed: a staff member trying the poll holds no roster seat, and the check that matters has already been made by the loader. - Where a classroom-less poll shows. In no classroom's evaluation list (they list by
classroom_id); in the owner'sGET /app/api/polls, whosePollSummary.classroomIdis now nullable — the "Recent polls" tab of #161.PollTeacherView.evaluation.classroomId,classroomNameandcourseNameare nullable; the projection's context line then reads "Anyone with the code", and its "Back" goes to the launcher. The refresh hints that went toclassroom:<id>go to the owner'steacher:<id>topic instead (homeTopicin the bus), and the grading progress of its end goes to the owner (staffOf). - Audit. No new action.
poll.createrecordsaudienceandclassroomId(null) in its payload, and every event of a classroom-less poll has the owner as its actor, so the poll stays attributable without a classroom to hang it on. - The roster is a condition, never a join on null. The helpers that read the roster of an
evaluation (
participantOf, the dashboard, the results, the staff-attempt queries) go throughseatsOf(evaluation), which isfalsefor a classroom-less poll: it has no roster, andclassroom_id = NULLmust never be read as "every seat whose classroom is null". Paths that reached an evaluation THROUGH its classroom useclassroomIdOf, which throws on the one shape they can never meet. - A running classroom poll is on its roster's home (issue #163). Before it, the student
home listed a poll like any evaluation: a card under "Open now" whose Start button posted to
/evaluations/:id/attempt, which answers a poll501, and once ended a card under "Past" whose View opened feedback that is never released (§8). A poll is now never anEvaluationCard:StudentHome.pollslists the RUNNING polls of the classrooms the student holds a claimed seat in, asStudentPollCard—id,code, classroom and course — and the card's one button, "Answer", opens/p/:code. It carries no title: a poll's title is its question's internal name or its statement, which onlytoStudentmay hand out (invariant 4); the card says "Live poll". An ended poll leaves the home. A classroom-less poll is on no home: the list is drawn from roster seats. The card comes and goes without a reload: the start and the end already hintevaluationsonclassroom:<id>, which every student of the classroom is subscribed to, and the hint refetches the home.
Rejected alternatives:
- A separate
owner_idcolumn. It would always equalcreated_byon a classroom-less poll — nobody else can reach it, so nobody else can "run again" on it — and two columns that must agree are the double source of truth item 3 just removed.created_bygets the one thing it lacked, a constraint that it is present where it matters. - A per-teacher "personal classroom" to hold anonymous polls. It would put a fictional class in every course listing, roster count and SSE topic, and a class that nobody is enrolled in is exactly what an anonymous poll is not.
- Keeping the flag and forbidding the combination in the service. The two stored facts could still disagree in a row written by any other path; the audience as a column that is there or not cannot.
- A 404 for an account off the roster. Invariant 6 hides an entity a caller could not know about. This caller read the code off the wall; a 404 would tell them to check a code that is right.
Addendum (2026-09-27): "From pools" — a poll borrows from any pool the teacher reaches¶
Issue #162. Until now a pool question reached the launcher only once it had run a poll
("Recent polls") or sat in the personal pool. The product owner settled the third way in: a
poll may run a published question of ANY pool the teacher can access, not only the pools linked
to the course of the classroom that answers. A poll is not graded and releases nothing (8), so
the rule that ties an evaluation's questions to its course (addItems) has nothing to protect
here.
- Access is the pool predicate, as it already was.
POST /app/api/pollsloads the question throughfindAccessibleQuestion(poolAccess) and never checked the course; this addendum makes that the stated rule and a tested one (pollPools.db.test.ts): a question of a pool the classroom's course does not use is polled, one of a pool the caller cannot reach is the 404 of a question that does not exist. - One search, not two.
GET /app/api/polls/pool-questions(PollPoolSearch→PollPoolPagein@quiz/contracts) takes the parameters ofGET /pools/:id/questions(QuestionSearchwithout the category and the deleted switch) and runs the same SQL filters (filterWhere, split out ofsearchWherein the pool service) and the same keyset cursor (pageWhere), across every pool the caller reaches — published, livemcq/shortonly; atypeoutside those matches nothing. The launcher tab is the pool screen's own bar (QuestionSearchBar, the grammar ofsearchSyntax.ts), narrowed to the two poll types. - The classroom narrows, it does not gate. With
classroomIdthe search keeps the pools linked to that classroom's course; the classroom is loaded through the staff predicate first (404 otherwise). The launcher offers it as a segmented control, "Classroom pools" (default) | "All pools", shown only when the audience is a classroom: an anonymous poll belongs to no course, so there is nothing to narrow to. - What a row shows is the statement through
toStudent(as every poll payload), the pool's name, the published version a poll would freeze, and the tags. The page carries the scope's tags (the filters left out) for the sheet and thetag:completion, the way a pool's tags feed its own bar.
Rejected: restricting polls to the course's pools — a poll is often the question of a colleague's pool or of last year's course, and nothing is graded; a second, client-side fuzzy search over a prefetched list — the grammar would drift from the pool screen's, and a teacher who reaches a few thousand questions would download them all.
Addendum (2026-09-28): a poll left without answers for 12 hours ends on its own (#190)¶
Nothing ended a poll but its teacher's End, and a poll left running on a closed laptop stayed
running for good: its code stayed taken, it stayed "live" for every screen that lists live
sessions. The product owner accepted an automatic end after 12 hours without answers.
- The rule. A poll ends when it is
runningand neither the poll nor any of its answers moved forPOLL_IDLE_MS(12 h,modules/poll/service.ts):evaluations.updated_atis at mostnow − 12 h, and noanswers.updated_atof its attempts is later than that. The poll'supdated_atis its start (created and started in one call), every reveal step and every edit of the row (a rename throughPATCHincluded), so a poll nobody answered counts from the last thing its teacher did; an answer counts from its server receipt time, a changed vote included. A join without an answer does not count: a QR scanned the next morning keeps nothing alive. Exactly 12 hours ends it; 11 h 59 does not. - Why 12 hours. It is the deploy guard's "left open, nobody is waiting on it"
(
scripts/live-evaluations.sql), for the same reason: a poll lasts minutes, a sitting in a room hours, so half a day of silence is a poll left open by mistake — and a teacher who polls the same room again next morning starts a new one anyway. - The same end. The pass calls
endPoll, the path of the End button: the attempts are expired (closed_by = server), the state becomesclosed, the grading pass is enqueued, the tally and state frames go out, andpoll.endis audited with the system as actor and{ reason: "idle" }as payload. Nothing is released (8). The close is a compare-and-set (tryCloseEvaluation): a pass that loses the race to the teacher's End emits and audits nothing, and neither does an End that loses to the pass. - The ticker closes (invariant 5). A tick task,
poll.end_idle(modules/poll/jobs.ts, part ofCORE_TASKS), runs the pass once a minute: the condition is re-read every time, so a restart catches up and an ended poll no longer matches — a second pass does nothing.
Rejected: counting from the start alone — a long poll still being answered would end under its teacher; counting joins as activity — the one phone that reloads the page keeps a forgotten poll alive; a shorter threshold — a poll paused over lunch while the teacher comments must not vanish.
Addendum (2026-09-29): only End closes the vote; votes and reveal are independent switches¶
Incident of 2026-09-29, poll KUFE5R (classroom PythonGE2-A). The teacher pressed "Reveal
answer" at 08:30:57 on an opinion-like mcq. Eight students joined AFTER that moment: their
phones showed the results and "You did not answer this poll." with no Send button — the
participant page (PollJoin) replaced the question with the key once revealed — and they
read it as "not allowed". The server, meanwhile, still took answers: answerPoll checks
the deadline only. The screen had closed a vote the server had not. Decided by the product
owner the same day:
- Only End closes the vote. While a poll runs, every phone keeps the question and its
Send/Update button, whatever the wall shows.
answerPollis unchanged: it refuses only past the close (410 attempt_closed), as it always did. - Votes: on the wall AND on the phones.
settings.poll.votesstays a switch, off by default. On, the live distribution reaches the phones too:PollPublicView.tallyis non-null exactly while it is shown. This amends the "Wall only" point of the addendum of 2026-09-27 (#157). Thepoll.tallyframes stay staff-only (decision 9): a phone reads the tally through its 3 s refetch. - Reveal: independent and reversible.
settings.poll.revealedshows the key and nothing else: it never closes the vote and no longer impliesvotes. The display has four states — nothing, votes only, key only, both — and a phone receivestallyiff the votes are shown andsolutioniff the key is revealed.POST …/poll/revealtakes{ revealed?, votes? }, a switch omitted staying where it was and a body naming neither refused (400 validation). This supersedes "Three steps, one progression" of the addendum of 2026-09-27:PollDisplayandpollDisplayOfleave the contract, and every view carries the two switches as they are shown. Decision 7 stands:feedbackPolicy.showKey/showExplanationstill move withrevealed. - No reveal without a key. A question with no key (addendum 2026-09-23, 5) has no
reveal switch on the projection, and the server refuses
revealed: truefor it with422 poll_keyless;revealed: falseis always accepted. A row storedrevealed: trueon a keyless poll before this addendum is not migrated. It is normalised in ONE place, on the server at read time:pollSettingsOf(scope)givesvotes || (!keyed && revealed)andrevealed && keyed, every view andsetDisplayread it, so no client knows the legacy shape and the first switch moved on such a poll writes the normalised pair. This amends the "Reveal" bullet of the addendum of 2026-09-23, 5. - Late answers count. An answer given after the reveal counts like any other, in the
tally and in "Recent polls" (
pollOutcome). A keyed poll whose key was on the wall before the vote closed therefore flatters its correct rate. Accepted distortion; nothing is recorded to correct it. - No verdict while the vote is open. While the poll runs and the key is revealed, the phone shows the key UNDER the still-editable question and no personal verdict ("your answer — wrong"): a verdict beside a field the reader may still change is noise. The personal verdict, and "You did not answer this poll.", appear only after End, where the key replaces the question as before.
- The projection. Its primary action while the poll runs is "End poll", with "N joined
have not answered yet" beside it (
tally.joined − tally.answered, no roster count). The two switches are secondary.VandRflip them independently; the arrow keys and Page Down / Page Up (a presentation remote) still walk hidden → votes → votes and key, and a keyless poll hidden → votes. The phase label has two values, Live and Poll ended: a reveal is no longer a phase. - Load. Each phone keeps its 3 s refetch. While the votes are shown, EVERY phone's
refetch computes the tally (
tallyOf), so a room of N phones costs N tally queries every 3 s. No tally cache is added. Follow-up if a large room makes it a concern: cache the tally per evaluation for the refetch period, invalidated by the answer write.
Rejected: closing the vote on reveal (making the screen right and the server wrong) — the product owner wants a latecomer to still answer, and the key on the wall is the teacher's own call; keeping "reveal implies votes" — an opinion-like question then had no way to show the key without the distribution, nor a keyed one the distribution on the phones without the key.