Skip to content

ADR-014 — Live polls

Status

Accepted (2026-09-21), consolidated from the amendments through 2026-10-01. ADR-053 removes non-poll access codes; ADR-056 excludes parameterized questions. ADR-071 adds the brainstorm type and a moderation switch. This consolidation changes no decision. The historical record preserves incidents, alternatives, migration details and original section numbering. Amended 2026-10-04: the launcher's defaults are safe for a projected screen (Launcher); they supersede "opens on Recent polls when it has rows" and "remembers the last choice" of the 2026-09-27 addenda. Amended 2026-10-12: an mcq written in the launcher may allow several answers without a key, a several-answers mcq has no donut, and a row of "Recent polls" opens the launcher's editor prefilled (Several answers and reopening a recent poll); this narrows "Recent polls can retrieve and relaunch" of Questions and retrieval to an unchanged question. Amended 2026-10-15 (product owner, #873): a poll's guests are deleted 30 days after it closed, and its results are read from a frozen copy from then on (Retention of the guests); this scopes "There is no tally cache" of Tally, history and visibility to a poll whose guests are still there.

Context

A teacher needs to ask one question immediately, including to people without accounts, without creating an exam, roster obligation or released grade. Reusing an evaluation preserves one answer, versioning, deadline and student-content path.

Decision

Evaluation and audience

A poll is an evaluation of mode poll, with exactly one frozen question version, created and started together through the poll routes. Generic evaluation creation still refuses mode: "poll" (501) and student entry refuses a poll: participants come in through the public /app/api/p/:code routes (read, join, answer) only. The session code is six characters from ABCDEFGHJKLMNPQRSTUVWXYZ23456789, unique among running polls and those ended less than two hours ago. The grace window permits continued reading after End. The QR targets ${WEB_URL}/p/<code>; WEB_URL defaults to PUBLIC_URL.

PollAudience has two shapes: anonymous, without a classroom; or a classroom. Anonymous status is derived from classroom_id, never an independent stored flag. A classroom-less poll must have created_by; its manager is the owner under the access predicate. Classroom polls are managed by course staff. Administrative access follows ADR-054. Generic classroom evaluation routes cannot reach a classroom-less poll.

A classroom poll admits signed-in claimed roster seats, staff and authorized admins. A signed-in outsider receives 403 not_on_roster without content: the public code already reveals the poll's existence. A browser without a session must sign in (401 login_required), and receives nothing of the poll. An anonymous poll admits anyone with its code: a signed-in browser votes as its account, otherwise as a guest. There is no second identity for the same browser. The database requires exactly one of attempts.user_id and guest_id, and a unique index attempts_evaluation_guest_uq (evaluation_id, guest_id): one attempt per poll and browser (an account's side is the partial attempts_evaluation_user_open_uq, one unfinished attempt per evaluation and account). Grading names a guest "Guest n"; the dashboard grid iterates the roster, so it shows no guest and emits no dashboard.attempt for one; no guest reaches the grade table or the CSV.

Guest identity is a random 32-byte quiz_guest cookie, HttpOnly, SameSite=Lax, Secure outside development, path /app/api/p, 12 hours. Only sha256(token:evaluationId) is stored. Public writes require the matching CSRF header when the CSRF cookie exists, and are refused when the session cookie comes without it (audit L8, 2026-10-11; __Host-quiz_csrf over https, quiz_csrf in development, N-SEC-01). Login return paths are same-origin paths validated by safeReturnTo, carried in the signed OIDC stash, never trusted URLs.

Questions and retrieval

Polls use published, live mcq or short questions; any other type is 422 poll_type, and an inline config the type's schema refuses is 422 config_invalid. Parameterized questions are refused so the projection and phones see the same values. Choices never shuffle. A pool question may come from any pool the teacher can access, independently of links to the answering classroom's course. The pool search can narrow to the course's linked pools but that is a filter, not an authorization gate.

An inline question creates an ordinary published version with no pool, no draft, and optional key. The keyless schema relaxes only the key; normal publication still requires it. Inline editing omits grading settings and asset upload. Unsaved questions are absent from pool lists/editors and cannot enter exams or exercises. The caller's Recent polls can retrieve and relaunch their own unsaved questions; it reuses their identity/version rather than copying. Saved questions are listed only while pool access remains. Recent polls also includes never-run published questions from the caller's personal pool.

Keep attaches an unsaved question to the caller's lazily created personal Polls pool and creates its draft; it does not copy or change the frozen version. A guarded update makes concurrent keeps idempotent. Pool visibility controls whether a colleague learns the destination, even when they can manage the poll. A kept keyless question remains poll-only (question_keyless on evaluation entry); its next ordinary publication requires a key. Reads never create the personal pool.

Launcher

The launcher is often opened on a projected screen, so it opens on what reveals nothing: the "Ask a new question" tab, blank, first of the three tabs (then "Recent polls", then "From pools"), and the audience "anyone with the code". A classroom audience is an explicit choice for each poll; the launcher does not remember one, so a poll is never filed under, nor restricted to, a class by a leftover from the previous one. Decided by the product owner (2026-10-04).

Several answers and reopening a recent poll

Decided by the product owner (2026-10-12).

Several answers. The mcq editor in its ungraded (launcher) use has one checkbox, "Allow several answers". With no correct choice marked it decides mode (single or multiple), for an opinion poll such as "which of these notions have you seen?". With exactly one correct choice the mode is single and the checkbox is off and disabled; with two or more it is multiple, on and disabled: the key decides, as in the full editor, which is unchanged and never shows the checkbox. The tally already counts each respondent's ticks, and shares stay of the respondents, so they may add up past 100 %.

No donut for several answers. The projection's donut is not offered for an mcq in multiple mode; its bars stay. A ring shows the parts of one whole, which shares over 100 % are not.

Recent polls opens the editor. Pressing a row of "Recent polls" opens the "Ask a new question" tab prefilled with that question's type and config, answer key shown (the owner chose not to hide it). It replaces the editor's working copy of that type, on purpose: the teacher asked for that question. "Recent polls" has no start action of its own; "Start the poll" in the editor remains the one primary action:

  • not edited since it was loaded: POST /app/api/polls with its questionId — the same question, keeping its identity, history and use count;
  • edited: POST /app/api/polls/inline — a new unsaved question; the original and its results are untouched. A saved pool question edited there likewise becomes an unsaved copy: the launcher never writes a pool.

The content comes from GET /app/api/polls/questions/:id (staff), loaded through the predicate a relaunch uses (reachableQuestion: a pool question the caller reaches, or an unsaved question of a poll they launched) — what they may run, they may open — and otherwise, or for a question a poll cannot run, the 404 of a missing question. It returns the config of the latest published version, the one a relaunch freezes.

Display, answers and ending

votes and revealed are independent, reversible switches, initially off. Votes expose the distribution on both projection and phones; reveal exposes the key. Neither closes voting. While running, a phone retains its editable answer and Send/Update action, with the key below it if revealed and no personal verdict. After End, the final answer/key view may show that verdict or absence of an answer. Late answers after reveal count normally, including in outcome statistics; the resulting optimistic success rate is an accepted distortion.

A keyless poll has no reveal control; revealed: true is 422 poll_keyless. Legacy keyless rows are normalized centrally: votes becomes votes || revealed (an old reveal shows the votes, never hides them), and reveal becomes false. feedbackPolicy.showKey and showExplanation move with reveal so an authenticated feedback endpoint cannot leak the key early. Every question payload still passes through toStudent; revealing uses the solution path.

End closes the evaluation, expires attempts, enqueues grading and emits the ordinary frames; it never releases results or creates roster grades. Keyless items are skipped by grading rather than marked wrong. Compare-and-set ensures only the winning close emits and audits. A one-minute ticker uses that same path when neither the poll row nor an answer has changed for 12 hours. Joins do not extend this idle period; teacher edits and answer updates do. Idle closure is audited as poll.end with the system actor and reason: idle.

Tally, history and visibility

pollTally is a pure rule: MCQ canonical choice order with zeroes; short answers normalized for Unicode/whitespace/case, first spelling retained, frequency order, capped by POLL_SHORT_CAP. Full tally frames are staff-only on the evaluation topic, coalesced at 500 ms. Phones refetch every three seconds and receive tally only with votes shown, solution only with key revealed. There is no tally cache: visible votes cost one tally computation per phone per refresh.

Recent outcomes read existing validated full-mark gradings of finished keyed runs, never independently regrade answers. Ungraded keyed runs with answers are skipped. Use up to the last five finished runs of the newest run's keyed/keyless kind; keyless outcomes report mean answers. Keyed rates use each run's current non-staff roster, bounded below by its answer count, or answers alone without a roster. Abstention is included only when every run in the window has a roster; never mix room percentages with respondent percentages. Empty denominators are ignored; rounding distributes whole percentages to total 100. Roster drift is accepted.

A running classroom poll appears on students' home as an Answer link to /p/:code, without a question title; it disappears at End. It is not an exam card or results card. Anonymous polls appear in the owner's poll history, not a classroom list. Ownership/classroom topics carry appropriate refresh hints. Poll creation, display, keep and end are audited; guest votes have no invented named actor.

Retention of the guests

Amendment of 2026-10-15 (product owner, #873; N-DATA-03). Thirty days after a poll closed, by the server's clock, the scheduled task poll.purge_guests (daily) deletes its guest_participants; their attempts, answers, attempt journal and gradings go by the foreign keys' cascade. The delete is the live module's (deleteGuests), which owns those tables. The accounts' answers stay; a running poll is never touched.

The teacher keeps the results. Before the delete, in the same transaction, the poll module writes a frozen copy of what the results are computed from (poll_frozen_results): the answers' payloads, how many joined, and how many the grading pass gave full marks. From then on the tally, the teacher's and the phones' views, the brainstorm board, the history's answer count and the launcher's outcome read that copy instead of the stored answers. For a poll answered by guests and accounts, the copy is the whole poll's results at the purge, so no result moves after it. This is the one tally cache: a frozen result, never a cache of a live poll. The copy links to no person, but the typed texts stay in it: short answers, brainstorm ideas and the marks keyed by them (poll_idea_marks).

A purged brainstorm's moderation is read-only: an idea action, or the moderation or AI switch, is refused with 409 poll_frozen, and the board and the projection disable those controls (PollTeacherView.frozenAt). The display switches still move.

Consequences and alternatives

Nullable question pools, evaluation classrooms and attempt users are constrained by the database and handled explicitly by readers; a null classroom means no roster, never all null seats. Reusing evaluations avoids a second content/grading pipeline. Fictional guest accounts, personal classrooms and tokens in URLs were rejected. Nothing about a poll justifies releasing grades to absent roster members.

Implementation references

  • apps/api/src/modules/poll/service.ts: audience, codes, display normalization, tally, idle closure, recent outcomes and pool search.
  • apps/api/src/modules/poll/routes.ts: managed/public loaders and CSRF. Teacher routes /app/api/polls (GET list, POST create-and-start, questions, questions/:id, pool-questions, inline) and /app/api/evaluations/:id/poll (GET, reveal, end, again, keep); public routes GET /app/api/p/:code, POST …/join, POST …/answer.
  • apps/api/src/modules/poll/service.ts (purgePollGuests) and apps/api/src/modules/live/attempt.ts (deleteGuests): the guests' retention and the frozen results.
  • packages/contracts/src/poll.ts, packages/domain/src/pollTally.ts and packages/domain/src/pollOutcome.ts: contracts and pure rules.
  • apps/api/src/modules/guards.ts, pool/evaluation services and the poll database tests: ownership, retention and content boundaries.

Historical section references

Original numbered decisions and addenda are historical; apply the consolidated decision above. Their full text is the historical record. A citation in code or another record ("ADR-014 §8", "ADR-014, addendum 2026-09-27") lives here now:

Old reference Current section Full text
§1 a poll is an evaluation; §2 session code; §4 participants; §5 guest cookie; §6 public routes; §10 login return; §11 WEB_URL; audience addendum (2026-09-27) Evaluation and audience §1–§11, audience
§3 personal pool; inline (2026-09-23), Recent polls (§1–2) and From pools addenda Questions and retrieval inline, Recent polls, From pools
§7 reveal; §8 End does not release; votes-hidden (2026-09-27), 12-hour expiry (2026-09-28) and independent switches (2026-09-29) addenda Display, answers and ending votes hidden, 12 hours, switches
§9 tally; Recent polls §3 (outcomes) Tally, history and visibility §9, Recent polls §3

Amended 2026-10-09: the list of compatibility anchors into the historical record was reduced to this table (the references that code and records cite) and bare compatibility anchors, so old links still land here; the full text stays in history/ADR-014.