CLAUDE.md; this page holds the full detail.
Organization
Owner of data and subscription (organisasjon). Hasstatus enum (demo, test, single_meeting, standard, pro, active, suspended). demo = internal unlimited (legacy). test = free tier for testing with 1 meeting limit and 10 participant limit. single_meeting = paid one-time purchase (Stripe payment mode, priced per participant and day) with 1 meeting and configurable participant count (single_meeting_participant_count). standard = paid tier (100 participants/meeting). pro = premium tier (500 participants/meeting) with advanced features (sub-orgs, custom fields, branding, terminology, numbering). active = legacy paid tier (unlimited). Advanced features gated via has_feature? (returns true for demo and pro); org settings pages blocked via require_advanced_features guard (aliased as require_non_demo_single for backward compatibility). Per-org overrides via participant_limit and meeting_limit columns. Free signup auto-creates a meeting with the user as chair. Supports unlimited-depth sub-org hierarchy via parent_id (self-referential). Stores custom_field_definitions (JSON) for org-specific participant fields. Field keys are auto-generated from labels via parameterize (no manual key input). Stores terminology_overrides (JSON) for custom UI labels (TERMINOLOGY_KEYS). Stores agenda_numbering_scheme (default "numeric") with NUMBERING_SCHEMES: numeric, numeric_alpha, roman, custom. Custom scheme uses custom_numbering_config (JSON: separator + 5-level levels array from CUSTOM_LEVEL_FORMATS: numeric, alpha, roman_upper, roman_lower). Stores Stripe fields: stripe_checkout_session_id (idempotency key), stripe_customer_id, stripe_subscription_id.
OrganizationMember
A user’s role in an organization (organisasjonsmedlem). Roles:admin, member. Creator is auto-assigned as admin. Parent org admins have cascading access to all descendant orgs.
Meeting
Time-limited event with agenda (møte — årsmøte/generalforsamling). Hasstatus enum (preparing, active, closed; default preparing — upcoming until VOT-432). The stored column is the meeting state; nothing derives it from the dates. meeting_status is a thin alias returning status.to_sym. Uses slug-based URLs (6-character random alphanumeric via to_param); controllers use find_by!(slug:) for lookup. Optional starts_on/ends_on (dates, ends_on >= starts_on validated) and scheduled_at (datetime, creation-only) are information only (VOT-432): shown as «Planlagt: …» via ApplicationHelper#meeting_planned_dates, exported in the protokoll, never read to decide the state. Lifecycle (VOT-382, VOT-432): start! (preparing → active, else :not_preparing), end! (active → closed; refused while a poll is open; its guards are also available on their own as ensure_can_end!. Unfinished agenda items do not block it — the controller first shows a confirmation listing unfinished_agenda_items, VOT-423) and reopen! (closed → active), each raising Meeting::InvalidTransition (with a reason symbol and an i18n message from meetings.errors.*) from the wrong state. Nothing goes back to preparing (VOT-428 Q3): validate :status_never_returns_to_preparing, on: :update refuses it on every write path. drivable? (preparing? || active?, VOT-433) is the single predicate behind Poll#open! (:meeting_closed) and the require_meeting_open_for_mutation guard — «Forberedelse» is the rehearsal, so the chair runs the full flow before «Start møte»; a closed meeting is never driven. Who may vote is a separate predicate, accepts_votes? (VOT-435, preparing? || active? — test votes while preparing, real votes while active, none once closed; the same rule for every role), behind the Vote validation and MeetingAuthorization#require_meeting_accepting_votes. It coincides with drivable? today but answers a different question. start! also refuses while a poll is open (:open_poll_start), so a rehearsal poll cannot stay open into the real meeting, and it ends the rehearsal: it locks the row, runs the guards, MeetingTestDataPurge in activation mode (which also deletes every audit row and protokoll export of the meeting, VOT-434), the state change with activated_at / activated_by, and the meeting.started audit row with the purge Counts in one transaction (a failed purge or update leaves the meeting in preparation), and returns the counts. start!(by: user) names the user; the audit row names their participant row when they hold one. activation_preview returns the same counts without writing (the confirmation page). end!(by: user) sets closed_at / closed_by and, in the same write, ends the pause that start_pause!(by:, minutes:/until_time:) writes (VOT-445; the message set_live_message! writes stays, VOT-457 — below); reopen! clears the closing pair and keeps activated_at (VOT-428 Q17). The four columns are nullable, *_by_id reference users (on_delete: :nullify) because an org admin may start or end a meeting without a role in it. validate :leaves_preparing_only_through_start refuses any other way out of preparing (a raw update to active or closed), and the enum bang setters (active! etc.) are private. ensure_preparing! raises Meeting::NotPreparing — the guard of every rehearsal-only action (bots, simulated votes, «Nullstill»). Transitions are driven by Meetings::MeetingStatusController#update (PATCH /meetings/:id/meeting_status?transition=start|end|reopen), which audit-logs meeting.ended (start! and reopen! write meeting.started / meeting.reopened themselves) and broadcasts a Turbo refresh. A start needs confirm_start=1; without it (a stale form, a crafted request) the controller redirects to GET meeting_status/confirm_start — a server-rendered page listing activation_preview and stating the step cannot be undone, whose button resubmits with the flag (VOT-434). The start buttons (chair preparing banner, secretary Møtestatus card) are links to that page. Optional short_name for compact display (display_name returns short_name.presence || title). chair_can_vote and secretary_can_vote booleans (default true) control whether those roles have voting rights; they are locked while any poll in the meeting is open (VOT-430: Meeting::VOTING_RIGHTS_SETTINGS, validate :voting_rights_unchanged_while_poll_open, on: :update → :base, :voting_rights_locked_open_poll naming the open poll; eligibility_locked? / eligibility_locking_poll — the shared “no eligibility change while a poll is open” guard, in every meeting state — drive the disabled switches on the secretary Møtedetaljer page). Optional welcome_message (ActionText rich text) shown on the participant view, also in the closed meeting’s record. locale (meetings.locale, VOT-446; nullable string, migration 20261011120000 — additive, reversible, no default, no backfill, not written at poll close) is the language of the meeting’s projector screens, «Språk på projektorskjermene»: normalizes strips it and stores a blank as nil, and validates :locale, inclusion: accepts only I18n.available_locales (the locale files present — nb only since VOT-475). projector_locale returns it as a symbol, else I18n.default_locale (:nb) — also when the stored value is no longer available — so every meeting from before VOT-446 renders as before. A new meeting speaks its creator’s language: before_validation :assume_creator_locale, on: :create takes Current.user&.locale, else the request’s I18n.locale, when it is available — one callback for every creation path (organisation form, free signup, the Stripe webhook’s provision_first_meeting, seeds), with no creation controller involved; an explicit locale wins. After that only the secretary’s field changes it (the secretary Møtedetaljer page, settings_panel, card «Møtedetaljer», shown only while more than one locale is available — LocaleHelper#locale_choice_available?, VOT-475 — meeting_settings_params permits :locale, saved by update_settings under :edit_meeting_info in every state, audited in meeting.settings_updated, then the updates refresh, so an open screen switches language without a reload). Only the projector pages and the organiser’s preview read it (see ProjectorScreen): every other page keeps the viewer’s language through ApplicationController#set_locale (the signed-in user’s Current.user.locale, else I18n.default_locale), and the protokoll does not carry it. planned_order_enabled boolean (default false) opts the meeting into planned agenda ordering; when enabled, the “Planlagt” sort option appears in chair, secretary, projector, and shared views. latest_closed_poll (VOT-450) is the projector’s newest closed poll — the poll on display once nothing is open, and «Siste resultat»: ordered by Poll::CLOSED_SORT_SQL (snapshot created_at, updated_at for legacy snapshotless polls, id breaking ties) and loaded through Poll.for_display, in a constant number of queries whatever the meeting’s size; nil when nothing is closed. Since VOT-454 no screen lists closed polls.
Permissions matrix and the closed meeting (VOT-437)
MeetingCapabilities (app/models/concerns/meeting_capabilities.rb, included in Meeting) is the VOT-428 permissions matrix as data: CAPABILITIES maps each capability to the states that allow it, and Meeting#allows?(capability) / Meeting.allows_in?(capability, status) answer it — allows?(:capability) with the row’s symbol, e.g. allows?(:edit_agenda) (an unknown capability raises KeyError). Rows: vote and drive (preparing, active), close_open_poll (every state, VOT-428 Q14), rehearse (preparing), restart_voting, edit_agenda (items incl. text, structure, numbering, planned order, draft polls, options, voting exclusions), manage_participants (add incl. CSV and org-admin self-assign, edit, role, withdraw) and change_voting_rights (preparing, active), delete_participants (preparing only, VOT-436), mark_attendance (preparing, active) and mark_registered_in_error (active only), comment, edit_meeting_info, manage_projector_screens and export_protokoll (every state), live_controls (preparing, active — the projector screens’ pause, VOT-445) and live_message (every state — their message, VOT-457), and the transitions start / end / reopen. drivable?, accepts_votes?, ensure_preparing! and the transition guards read it. refusal_message(capability) is the flash / JSON error; meetings.capabilities.refused.<capability>_<state> wins over <capability> when one capability needs a different wording per state (VOT-436: deleting in active points to «Fraværende» / «Feilregistrert»). State 4 (archived, VOT-439) is a new STATES entry: every row that does not list it — reopen included — refuses it there. A test pins the matrix and its key set (test/models/meeting_capabilities_test.rb).
Controllers ask it through one guard: MeetingAuthorization#require_meeting_capability(capability, alert:, fallback:) (JSON 422 { error: } or a redirect with the flash; require_meeting_open_for_mutation, require_meeting_accepting_votes and require_preparing_meeting delegate to it). In a closed meeting every web and CSV path that changes the agenda, polls, options, exclusions, participants or a result is refused for every role; the voting-rights switches are also locked in the model (validate :voting_rights_unchanged_while_closed → :base, :voting_rights_locked_closed). polls#close carries no state guard (Q14): a poll still open in a closed meeting (legacy data — end! refuses while one is open) can always be closed, which only freezes the tally.
Which row guards what (VOT-433, VOT-436, VOT-437): :drive (require_meeting_open_for_mutation) every chair action that drives the meeting — advance_agenda, set_active_item, set_manual_result, clear_manual_result, mark_complete, mark_incomplete, complete_and_advance, confirm_result, conclude_election, decline_result, withdraw_option, quick_poll (show exempt) — and polls#open/create_round; :restart_voting the secretary’s restart_voting; :edit_agenda AgendaItemsController (all but index), the secretary’s new_item/create_item/edit_item/update_item/toggle_planned_order; :manage_participants the secretary’s participant actions (form, CSV import and sync, role, withdraw), the org admin’s self_assign; :delete_participants destroy_participant; :mark_attendance / :mark_registered_in_error the secretary’s update_participant_attendance / update_participant_registration; :rehearse (require_preparing_meeting) the secretary’s create_bots, simulate_votes and reset_meeting. Comments, meeting info, projector screens and protokoll exports stay open in every state. Once the meeting is closed the chair sees meetings/chair/_state_closed in place of every driving card, the secretary the _closed_strip, participants of every role the read-only record (meetings/{participant,voter}/closed) and every projector screen the record region — all from MeetingRecord (below).
reopen!(by:, reason:) (closed → active, never preparing) locks the row, runs ensure_can_reopen! (allows?(:reopen)), marks every protokoll export of the meeting superseded (ProtokollExport#superseded_at), clears closed_at / closed_by, and writes meeting.reopened with { reason, superseded_protokoll_exports } — all in one transaction. The reason is optional, stripped and cut to REOPEN_REASON_MAX_LENGTH (500). reopen_preview counts the exports it would supersede. A reopen without confirm_reopen=1 redirects to the server-rendered GET meeting_status/confirm_reopen page (closed at / by, what reopening changes, the export count, the reason field).
MeetingRecord (app/models/meeting_record.rb) is the closed meeting’s read-only record, derived once like ChairState: closed_at / closed_by, one Entry per agenda item (status :completed / :unfinished — the rule of Meeting#unfinished_agenda_item?, shared with the end confirmation — / :pending, the decision line, the elected candidates) and the counts. Since VOT-454 it is reveal-safe: an entry’s result is AgendaItem#announced_result — winning_result, except nil while the item’s latest round (AgendaItem#latest_round) awaits the chair’s decision (Poll#awaiting_decision?: closed, no confirmed_outcome, the item not settled — the same predicate behind the projector’s on_confirm reveal, so the hall and the record cannot disagree) — and its elected is AgendaItem#announced_elected_candidates (the candidates the chair confirmed, and the latest round’s winners only once the item is settled). The secretary summary, the chair’s resolved card and the protokoll keep winning_result / all_elected_candidates and their values. It is also preloaded: the items come with their rounds, options and snapshots (includes(polls: [ :poll_options, :poll_result_snapshot ])), and AgendaItem’s outcome readers read fully preloaded rounds without a query (the SQL path otherwise; ties broken by id on both, test/models/agenda_item_announced_outcome_test.rb holds the two paths equal), so a record of 50 items costs the queries of one of 5. MeetingsHelper#closed_record_counts_line / #closed_record_closed_line word the counts and the closing line once. It feeds the chair’s closed card (meetings/chair/_state_closed, which replaces every driving card) and the participant and voter views (closed templates, for every role) through meetings/shared/_closed_summary and _decisions, and the projector’s record region (closed_record, see ProjectorScreen).
Live controls: pause and message (VOT-445)
MeetingLiveControls (app/models/concerns/meeting_live_controls.rb, included in Meeting after MeetingCapabilities) is the meeting-wide state the organisers set from the «Direktekontroller» card and every projector screen that opted in shows. Four nullable columns on meetings, no default, no index: pause_started_at / pause_until (a running pause) and live_message / live_message_set_at (the shown message, «Beskjed»). Writers: start_pause!(by:, minutes: nil, until_time: nil) (1–240 minutes, or «til kl.» "HH:MM" today in Oslo time, later than now, at most 240 minutes ahead; the clock time wins; a new pause replaces a running one), end_pause!(by:), set_live_message!(text, by:) (squished, at most 100 characters, replaces the shown one, stored as typed — views escape it) and clear_live_message!(by:). A refused value raises Meeting::LiveControlError (reason + meetings.live_controls.errors.<reason>: pause_duration_missing, pause_duration_invalid, pause_time_invalid, pause_time_passed, pause_too_long, no_pause (also an expired one), message_blank, message_too_long, no_message, and not_allowed with refusal_message(:live_controls) for a pause once closed). Readers: pause_running?(at = Time.current), current_pause(at) (Meeting::LivePause: started_at, ends_at, total_seconds, minutes, remaining_seconds(at) rounded up, elapsed_seconds(at)) and current_message (Meeting::LiveMessage: text, set_at). A pause expires by time alone: nothing is written, enqueued, broadcast or audited when pause_until passes. Each write is one transaction of a conditional update_all … WHERE id = ? AND status IN (…) over the stored statuses the writer’s matrix row allows — the pause writers (start_pause!, end_pause!) read live_controls (preparing, active: a meeting closed between load and write refuses the pause, not_allowed), the message writers (set_live_message!, clear_live_message!) read live_message (every state, VOT-457) — and the meeting’s validations and callbacks are bypassed on purpose, so a legacy-invalid meeting never blocks a pause — and the audit row meeting.pause_started (minutes, until, replaced_until), meeting.pause_ended (until, remaining_seconds), meeting.message_set (text, replaced) or meeting.message_cleared (text); the actor is by’s participant row, else «System». The activity log shows the four actions; the protokoll does not (AUDIT_LOG_ALLOWLIST unchanged), and the protokoll does not carry the columns. end! clears the two pause columns in its own write (MeetingLiveControls::PAUSE_CLEARED, no extra audit row) and keeps the message; reopen! writes no live column, so a message shown after the close is still shown after a reopen; start! and «Nullstill møte» keep both. Meetings::LiveControlsController drives them and refreshes [@meeting, "projector"] once per success.
MeetingParticipant
A person’s role in a specific meeting (deltaker). Roles:participant, chair, secretary, observer. Observers cannot vote. can_vote? returns false for observers; the eligible_voters scope excludes observers from vote counts.
Attendance and «Feilregistrert» (VOT-436, VOT-428 Q5/Q6) — two flags, separate from the role:
absent_since(datetime, nil = present): «Fraværende».absent?,attendance("present"/"absent"), scopesattending/absent. An absent participant is not stemmeberettiget:voting_ineligibility_reasonreturns:absent,eligible_votersleaves them out, soPoll#eligible_voter_count,#did_not_vote_countand theeligible_votersthreshold denominator of any poll held while they are absent use the reduced count;PollSnapshotServicerecords reasonabsent. Allowed while preparing (known «forfall», kept by the activation purge and by «Nullstill») and active (:mark_attendance).registered_in_error_at(datetime): «Feilregistrert», the active meeting’s replacement for deleting someone added by mistake (:mark_registered_in_error, active only; refused for the last chair).registered_in_error?, scopeslisted(everyone else — every participant list and count reads it: the secretary panel, the chair’s counts, the projector grid, the CSV export,Organization#participant_limit_reached?, which therefore does not count them) andregistered_in_error. Not eligible (reason:registered_in_error); votes already counted in closed polls stay as frozen in their snapshots. Reversible.mark_absent!/mark_present!/mark_registered_in_error!/unmark_registered_in_error!raiseRecordInvalidwhen the state does not allow the change (validations read the matrix) or while any poll in the meeting is open (Meeting#eligibility_locking_poll, the VOT-430 guard; error names the poll). The secretary controller writes the audit rowsparticipant.marked_absent/marked_present/marked_registered_in_error/unmarked_registered_in_error. Web only.- No participant change while a poll is open (owner decision):
validate :participant_list_unlockedruns on every save (create and update, any column — a name-only edit saves the row first) andensure_removablechecks the same before a delete, reusingMeeting#eligibility_locking_poll; the errorparticipants_locked_open_pollnames the poll. It replaces VOT-394’s role-of-a-voter lock and the custom-field lock. Simulated participants are exempt (rehearsal only). Covers the panel, CSV import / sync (also checked up front) and the org-admin self-assign. - «Feilregistrert» loses access (owner decision):
MeetingAuthorization#current_participantreads onlylistedrows, so the person is treated like a deleted participant (no view, no vote, no role); «Angre» restores it; org admin / member access is unaffected.last_chair?and the last-chair role validation count only listed chairs. - CSV sync never deletes once the meeting has started.
CsvParticipantSyncer(the secretary panel’s raw editor) adds new rows, updates changed ones and, while preparing, removes the participants missing from the CSV. Once the meeting has started a participant missing from the CSV iskeptinstead (the preview says why), with an optional «Marker som fraværende» checkbox each:apply!(csv, mark_absent_emails:)(in fullapply!(csv_text, mark_absent_emails: [], allow_chair_changes: true)) marks the ticked ones absent and ignores any address the preview did not keep. «Feilregistrert» participants are not in the raw CSV, so they are never removals; a CSV row naming one is an error until the mark is undone.
before_destroy :ensure_removable refuses unless the meeting allows :delete_participants (preparing only) — on every path (panel, CSV sync, a crafted request); cascades from the meeting/user are exempt. While preparing every vote is a test vote, so the participant’s votes go with them (dependent: :destroy) instead of blocking (the VOT-394 rule this replaces); release_replacement_links drops «vara» links pointing at the row, and initiated protokoll exports are nullified. last_chair? returns true when the participant is the only remaining chair; a model validation prevents role changes away from chair when last_chair? is true. Stores custom_field_values (JSON) for org-defined custom fields. Name (via User.name) and custom field values are editable inline from the secretary participant edit form. Auto-assigned participant_number (unique within meeting). Supports CSV paste import and bidirectional structured/raw editing.
AgendaItem
Item on the agenda (sak på sakslisten). Has rich text body via ActionText. Supports up to 5 nesting levels viaparent_id (self-referential), depth (0–4), and ancestry_path (materialized path for sort, e.g. "0002.0001.0000"). Ordered by ancestry_path for depth-first display. planned_position (always set; auto-assigned on creation) defines planned voting order. When meeting.planned_order_enabled? is true, agenda items can be reordered independently via drag-and-drop in the secretary “Planlagt” view; sub-items dragged away from their parent become standalone items with new sequential numbering. Tracks completed_at (explicit chair action), optional manual_result enum (unanimously_accepted, rejected, withdrawn, no_winner), left_unfinished_at (“ikke ferdigbehandlet”, see below), unfilled_seats (integer, default 0 — seats a multi-seat election was concluded without filling, see “Unfilled seats” below), and secretary_comment. display_number computes hierarchical numbering (e.g. “1.a.i”) based on org’s agenda_numbering_scheme with custom separator support. quick_poll boolean marks bench proposals («Hurtigvotering», raised by the chair on the current item). Quick polls have their own ordering (VOT-448): a quick poll is a child row of the item it was raised on (parent_id; nil when there was no current item), listed at that item’s depth, and numbered «Hurtig N» from the meeting-wide sequence in its position (the n-th quick poll raised in the meeting, 0-based — quick_poll_number; a number is never reused, so deleting one renumbers nothing). Its ancestry_path is <item path>-<nnnn>, which sorts right after the item and before the item’s ordinary sub-items (- < .), or ~<nnnn> with no item (after every ordinary item); in planned order it mirrors the item’s planned_position (follow_item_planned_position / sync_quick_poll_planned_positions) and the quick_poll tie-breaker in ordered_planned lists it after the item, build_planned_tree first among the item’s children. It takes no slot in the ordinary numbering: siblings and sub_items (children minus quick polls) leave quick polls out, so moves, default positions and renumbering (AgendaItemRenumberingService, the reorder actions ignore quick-poll ids) never count them, and items 6, 7, 8 keep their numbers after a quick poll on item 5. A quick poll cannot be moved (MoveBlocked(:quick_poll_fixed)), may be raised on a completed or level-5 item, counts as leaving the item for «ikke ferdigbehandlet», and is deleted with its item (dependent: :destroy; the deletion guards cover it). Migration 20261007090000 re-anchored quick polls created before this ordering. «Neste sak» visits it in running order: from item 5 to Hurtig 1, then to 5.1 or 6. The ordinary scope is the complement of quick_polls. Views support three sort modes: “agenda” (by ancestry_path), “planned” (by planned_position, gated behind meeting.planned_order_enabled?), and “completed” (by completed_at DESC). all_elected_candidates aggregates elected option titles across all rounds (including the latest closed round’s winners unless the chair declined that round, VOT-421; include_pending: false leaves a round awaiting «Bekreft» out — the projector’s rule, VOT-451); winning_result uses this for multi-select elections and appends “N plasser ubesatt” when unfilled_seats is positive. Structure editing via indent!, outdent!, move_up!, move_down! (outdent! adopts the following siblings as children, outliner-style). These raise AgendaItem::MoveBlocked with a reason instead of silently doing nothing when the step is impossible (first item up, last item down, indent without a previous sibling or past 5 levels, top-level outdent), and enforce the completed-parent rule: indent! under a completed sibling and outdent! of a completed item that would adopt following siblings are refused, as are create under a completed parent (:parent_completed) and move_to_parent! onto one (:move_parent_completed). The web reorder_hierarchy action redirects with the reason as an alert. Supports freeform renumbering via AgendaItemRenumberingService: secretary provides new numbering inputs per item, service auto-converts to org scheme, detects conflicts, previews proposed sort order, and applies changes transactionally. Empty input slots inherit from current position chain. New rounds are never built here: every round after the first comes from PollRoundBuilder (see Poll).
Information items (VOT-466). no_vote (boolean, default false) marks an information item («Ingen votering», orienteringssak): it never has a poll. The invariant is one-way — no_vote ⇒ no polls — and held by create_default_poll (skipped), a create-only Poll validation (polls.errors.no_vote_item), PollRoundBuilder#guard! (Poll::InvalidTransition :no_vote_item) and two backstop validations on the item (no no_vote while it has polls, never on a quick poll). A vote item normally has its default round, but that is not validated: tests build poll-less “legacy” items, which keep today’s rendering (and the secretary’s «Start votering på nytt» refuses them instead of failing). The kind changes only through switch_to_no_vote! (deletes the item’s only round — a draft nobody voted on — and sets the flag) and switch_to_vote! (clears it and creates a fresh default Ja/Nei draft titled like the item; earlier settings are not restored). Both lock the row and re-read the rounds and votes in one transaction, and switching to the kind the item already has does nothing. Refusals raise AgendaItem::NoVoteBlocked (an AgendaItem::Error), checked in this order: the agenda is locked (closed meeting, :edit_agenda) no_vote_meeting_closed; a quick poll no_vote_quick_poll; the item is resolved no_vote_resolved; and, to no vote only, voting started (more than one round, a round that is not a draft, or any vote) no_vote_voting_started. Voting exclusions are kept across a switch. set_manual_result! accepts only withdrawn («Trukket») on an information item. winning_result — and through it announced_result, the closed record, the secretary summary, the chair and projector cards and the protokoll — reads «Tatt til orientering» (agenda_items.no_vote.outcome) for a completed information item without a manual result, unless it is a heading: heading? is true for an item with at least one ordinary sub-item (quick polls do not count), and a heading prints no outcome. heading? reads loaded children; AgendaItem.preload_headings(items) loads them for the information items of a list (MeetingRecord, the protokoll aggregation, the secretary’s completed panel) in one query, so nothing is spent per item. The secretary’s «Ny sak» form creates information items through the kind «Ingen votering» (VOT-467: question[kind]=no_vote sets the flag before the save — no round, no settings, no exclusion rules; the strong parameters still never take a bare no_vote). Switching the kind goes through «Rediger» (VOT-468; see the question lock below). The flag survives «Nullstill møte», «Start møte», completion, reopening, moves and renumbering.
The question lock (VOT-468). AgendaItem#current_chain is the item’s rounds from its latest restarted round (else round 1); #question_locked? is true for a vote item whose current chain has more than one round or whose chain start is not a draft — once the first round of the chain has been opened, also with test votes. While locked, the rule, the options and the voting restrictions cannot change: the web refuses a posted question (update_item, V14), PollSettingsApplier / Poll refuse every settings and option write (not_editable, SettingsLocked), and VotingExclusion refuses create / update / destroy for every caller with «… har vært til votering …» (question_locked; poll_open while a round is open). A rule destroyed with its item, meeting or organisation is never refused (destroyed_by_association). The lock is lifted only by «Start votering på nytt» (a new chain) and by the purge («Nullstill møte», «Start møte»). QuestionLock (app/models) reads it for the «Rediger» form and the detail. «Rediger» switches the kind (no_vote_blocker / vote_blocker are the switches’ refusals without raising) and writes one agenda_item.updated audit row naming a kind or rule change.
Poll
A vote/ballot; an agenda item can have multiple polls/rounds (votering/avstemming). Types:binary (UI: «Ja eller nei»), multiple_choice (UI: «Velg ett alternativ» with one seat, «Valg av personer» with one or more). Question kind (VOT-467). question_kind (string, nullable: yes_no / choose_one / election) is the kind chosen in «Ny sak»: written only by Meetings::SecretaryController#create_item and #update_item («Ny sak» and «Rediger», through the QuestionSetup form object, beside PollSettingsApplier) and copied by PollRoundBuilder to follow-up rounds and restarts (a restart from the item’s first round); NULL means derived. Read only through Poll#effective_kind — the stored kind while it fits the columns (yes_no ⇔ binary, choose_one ⇔ multiple choice with one seat, election ⇔ multiple choice), else PollRuleSummary.kind_for: a write outside the form (the settings applier) can change the type or the seats without knowing it, so a stale value never wins and no save refuses it. Not a locked follow-up setting, not in the minutes, never no_vote. The secretary detail’s kind line names the chain start’s effective kind (polls.kinds.*). Binary polls support preset label pairs (Ja/Nei, For/Mot, Godkjenn/Avvis, custom). allow_abstention controls whether abstain option is created. Statuses: draft → open → closed. Draft polls support one-way type conversion via convert_poll_type!: binary→MC is allowed (clears options); MC→binary raises Poll::InvalidTransition (:cannot_convert_to_binary): convert_poll_type! stays one-way. MC → «Ja eller nei» exists only in «Rediger» (Poll#replace_with_yes_no!, web only). Multi-select support: max_selections (default 1) controls how many options a voter can select; MC polls with max_selections > 1 enable multi-winner elections across rounds. original_max_selections returns the first round’s max_selections (after any restart). Threshold settings are decomposed into 3 independent columns: winning_threshold enum (majority, qualified, unanimous), counting_base enum (votes_cast, eligible_voters), and plus_one boolean. plus_one is only valid with qualified threshold (model validation rejects plus_one: true with majority; controller auto-clears it; UI hides the checkbox). Qualified thresholds use configurable fraction via qualified_threshold_numerator / qualified_threshold_denominator (numerator must be less than denominator). Common configurations: simple majority = majority + votes_cast; two-thirds = qualified(2/3) + votes_cast; two-thirds of all eligible voters plus one vote = qualified(2/3) + eligible_voters + plus_one (a qualified fraction must be above 1/2, VOT-403). outcome method returns :adopted or :rejected for closed binary polls (one-directional: the affirmative option at position 0 must clear the threshold, otherwise rejected — ties are not possible); MC polls return :elected or :tied based on winning_options. winning_options (plural, primary method) returns all options exceeding the threshold, respecting max_selections and tie-breaking rules; winning_option is a convenience wrapper returning only the first. round_summary provides contextual text for completed rounds (withdrawn options, decision state, winner/winners for MC polls). eliminated_option_titles returns options eliminated between rounds (present in current round but absent from the next, excluding winners). Multi-round election methods: elected_options_across_rounds returns all PollOptions marked elected since the last restart; remaining_seats returns seats still to fill; election_complete? returns true when all seats are filled; partial_winners? returns true when winners exist but seats remain. percentage_denominator exposes the counting-base-aware denominator for vote percentage calculations. eligible_voter_count returns the count of eligible voters in the meeting; a closed poll reads it from its tally snapshot’s eligible_voter_count when that is stored (VOT-443), else from its participant snapshots, else live. Read from loaded data (VOT-443): on a poll loaded through Poll.for_display, round_number, final_round? and the snapshot path of winning_options answer without a query (from the loaded agenda_item.polls / poll_options; a poll missing from a stale loaded collection falls back to SQL); latest_for_agenda_item? stays a query because it guards writes (ensure_can_spawn_round!, ChairState). Both paths order rounds by [created_at, id]. Rounds (VOT-422): every new round on an item is built by PollRoundBuilder.build!(previous_round, intent, excluded_option_ids:) (app/services/poll_round_builder.rb) and always lands as a draft — nothing opens it; the chair uses “Åpne votering” like round 1. Intents: :revote (same running candidates — “Ny runde” on the no-decision cards and the declined-result banner, both polls#create_round), :eliminate (minus excluded_option_ids, from confirm_result with elimination=true), :partial_elected (this round’s winners elected, the rest run for the seats left, from confirm_result with partial_election=true; round_needed? is false when no seats/candidates remain and the caller completes the item, saving any seat still open as AgendaItem#unfilled_seats) and :restart (the secretary’s “Start votering på nytt”: a fresh round 1 from the FIRST round’s settings and options, restarted: true, clears elected flags, completed_at and manual_result). Follow-up rounds copy follow_up_round_settings (VOT-419) plus type and seats (capped at the seats still open and the candidates left — a per-round cap only: the election keeps its seat total and any shortfall becomes AgendaItem#unfilled_seats when it is concluded, VOT-424; binary always one selection, VOT-420), get a “(runde N)” title, and backfill the previous round’s snapshot eliminated_poll_option_ids (VOT-353). plan! validates and reads only, so confirm_result runs it before its own writes. Guards: the source must be closed and the item’s latest round, no poll open in the meeting, item not resolved (a restart only needs no open poll). follow_up_round? (derived: created after a closed poll on the item and not restarted) marks a follow-up round; its voting settings (LOCKED_FOLLOW_UP_SETTINGS: threshold, counting base, plus one, fraction, abstention, type, seats) and options are locked on every surface — ensure_settings_unlocked! raises Poll::SettingsLocked from PollSettingsApplier, convert_poll_type! and replace_with_yes_no! (so «Rediger» refuses it), with a model validation as backstop. Its title stays editable. The one edit left is withdraw_option!(option) (chair only, see PollOption). ballot_options is the option list minus withdrawn candidates; round_summary names withdrawn candidates after the outcome. confirmed_outcome enum (unconfirmed, confirmed, prefix: true, nullable — nil = not yet reviewed) records whether the chair accepted or declined a closed poll result, separate from the computed outcome. Unconfirmed polls display muted results with “Ikke bekreftet” badge across all views. Chair state derivation skips unconfirmed polls (falls through to :ready instead of :result).
Decision rule (VOT-451; seat cutoff VOT-456). The arithmetic behind decision_result and the multiple-choice winning_options lives in PollDecisionRule (app/models/poll_decision_rule.rb, built by Poll#decision_rule): a pure Data value with ratio, required, clears?, decision and winners, extracted from Poll (same Float operations), so the close-time snapshot, the chair, the protokoll and the projector’s «Avgjort» knockout (PollKnockout) share one rule. Poll still gathers the inputs with the same queries and early returns. The seat cutoff (VOT-456): when more options clear the threshold than there are seats, winners elects the top seats — unless a tie straddles the last seat (the option on the last seat has as many votes as the first option below the cut); then only the options with strictly more votes than the tied group are elected, possibly none, and the chair can take a new round. Equivalently, an option is elected iff at most seats qualifiers, itself included, have at least its votes. Winners are listed most votes first, equal counts in ballot order. Before VOT-456 the option on the last seat was always dropped (2 seats, A 8 / B 7 / C 6 elected only A; A 7 / B 7 / C 6 nobody). A poll closed with a tally snapshot keeps the winners stored at its close (winning_poll_option_ids is never recomputed); a poll closed before v0.34.0, without a snapshot, reads the current rule. Pinned by test/models/poll_decision_rule_test.rb — the decision against a frozen copy of the pre-VOT-451 arithmetic (test/support/legacy_poll_arithmetic.rb), the winners against that copy plus exactly the cutoff’s difference and against an independent rank-form reference — and by database-backed characterization tests (poll_decision_rule_equivalence_test.rb).
The rule in words (VOT-465). PollDecisionRule#minimum_count(count_base) is the smallest count clears? accepts on a counting base: it starts at required(count_base).floor and steps until clears? says yes, so clears? stays the only decider (42 eligible: majority 22, 2/3 29, 2/3 + pluss én 30, 3/4 32, unanimous 42). It may exceed the base and never raises. Limitation, by design: with pluss én a 2/3 rule cannot be met by 1–3 eligible voters and a 3/4 rule by 1–4, and the worked example then shows a count above the number of eligible voters (no special wording; real meetings have more voters). PollRulePresets (app/models/poll_rule_presets.rb) defines the threshold presets once per kind — :yes_no simple_majority / two_thirds / three_quarters / unanimous / other, :choose_one and :election more_than_half / two_thirds / three_quarters / unanimous / other (VOT-468: «Enstemmig» for every kind) — in both directions (preset_for(kind, settings), settings_for(kind, key); labels polls.rule_presets.*); fractions match exactly (4/6 is other), and counting base, pluss én and abstention never change the preset. PollRuleSummary (app/models/poll_rule_summary.rb) is the pure PORO that says a poll’s rule in the user’s language: heading, sentence (sentence_segments keeps the answer word apart for the view to bold — it is data, never inside an html_safe string), example (the caller’s eligible_count: with minimum_count; nil at 0), abstention_line, cutoff_line (elections with more than one seat, the VOT-456 cutoff), plus_one_hint (for the poll form), preset and deviation_line — only the rule keys (winning_threshold, counting_base, plus_one, allow_abstention) of PollSettingsSummary, so an ordinary election is no deviation while the protokoll keeps its own list. The kind comes from the columns (PollRuleSummary.kind_for: binary → :yes_no; multiple choice with one seat → :choose_one, more → :election) unless the caller passes kind:. First reader: the secretary item detail («Voteringsinnstillinger», MeetingsHelper#secretary_rule_summary + meetings/secretary/_rule_summary), which summarises the round that starts the item’s current chain (the last restart, else round 1) and counts the latest round’s stored eligible voters once it has closed, else today’s eligible voters on the item without bots.
Awaiting the chair (VOT-454). awaiting_decision? — closed, no confirmed_outcome, and the item not settled (agenda_item&.resolved?, so a poll without an item fails closed) — is the one rule the projector’s on_confirm reveal (ProjectorContext.reveal_state) and the closed record (AgendaItem#announced_result) share: while it holds, no closed surface names the round’s result. AgendaItem has_many :polls has inverse_of: :agenda_item, so a round read through its item’s preloaded polls reaches the item without a query.
PollOption
Voting option: candidate or proposal (valgalternativ). Has awithdrawn flag (withdraw!), set by the chair’s “Fjern” on a follow-up round draft (ChairController#withdraw_option → Poll#withdraw_option!, VOT-422): only on a multiple-choice follow-up round that is still an editable draft, and never below one candidate or below the round’s seats (withdraw_option_blocker). A withdrawn option stays on the round for the record (struck through as “Trukket” in the secretary view, results and protokoll; hidden on the projector), is off the ballot (Poll#ballot_options, and Vote refuses it), and is never carried into a later round. Options can be eliminated between MC poll rounds when they don’t meet threshold — the chair confirms the suggested lowest candidate(s) (“Bekreft eliminering”) or hand-picks them (“Trekk alternativer”), and the next round opens without them. Position 0 = affirmative, position 1 = negative (for binary polls). elected boolean (default false) marks winners in multi-round elections; managed via elect!/unelect! methods. Scopes: elected, non_elected.
Manual Result
Off-app decision registered by chair (manuelt resultat). Types:unanimously_accepted (enstemmig vedtatt), rejected (avvist), withdrawn (trukket), no_winner (avsluttet uten vinner, VOT-389). Displayed across chair, secretary, and projector views. For multiple-choice elections the chair’s manual-result form additionally ticks the winning candidates (selected_option_ids[]); Meetings::ChairController#set_manual_result persists these by marking the poll’s own options elected (same mechanism as the normal confirm_result election path), so the manual outcome renders identically to a tallied election. Option lookup is scoped to the poll (poll.poll_options.where(id: ...)), rejecting foreign ids. The manual_result value is validated against AgendaItem.manual_results.keys before being applied — an unrecognised value redirects back with an alert instead of raising. An information item (no_vote, VOT-466) accepts only withdrawn: any other result raises AgendaItem::NoVoteBlocked (no_vote_manual_result), which the chair view shows as a flash.
Ikke ferdigbehandlet (VOT-423). An item is left unfinished when it was the meeting’s current item and the meeting moved away from it while it was not completed (completed_at nil — the only criterion; items with and without a poll alike). Detected in one place: Meeting’s after_update hook on current_agenda_item_id calls AgendaItem#mark_left_unfinished!(moving_to:) on the previous item, so every path that changes the current item counts — “Neste sak” (advance_to_next_agenda_item!, including off the last item), set_active_item, opening a poll on another item (Meetings::PollsController#open syncs the current item) and quick_poll. Stepping into one of the item’s own descendants is not leaving it (a heading is not unfinished because the meeting went on to its first sub-item), and only the item that was current is marked — never its parent or children. Leaving again moves left_unfinished_at forward. A before_save clears it whenever completed_at is set (mark_completed!, set_manual_result!, every confirm path), so left_unfinished? (= left_unfinished_at present and not completed) and the AgendaItem.left_unfinished scope only ever report open items. Reopening a completed item (“Gjenåpne”) leaves it plain incomplete; it is marked only if the meeting leaves it again. MeetingResetService clears every mark. Meeting#unfinished_agenda_items (running order) = the marked items plus the current item while it is still open — the list the end-meeting confirmation shows. Navigation is unchanged: “Neste sak” never wraps back. “Fullfør og neste sak” (VOT-441, information items only since VOT-478, ChairController#complete_and_advance) completes the current item before advancing, in the same transaction, so the hook sees a completed item and does not mark it. Exposed read-only as left_unfinished / left_unfinished_at in the protokoll aggregation hash.
Concluding a no-winner election (VOT-369). When a multiple-choice election round ends with no candidate over the threshold, the chair’s result state offers a “Conclude without a winner” action (also surfaced in the unconfirmed banner of the :ready state after “Ikke bekreft”). It reuses the manual-result mechanism, setting manual_result: "no_winner" (VOT-389; previously rejected) via the same set_manual_result action — no selected_option_ids are passed, so no option is marked elected and no new round/poll is created. set_manual_result! also sets completed_at, so the item is resolved and rendered consistently (participant, projector, protokoll all key off manual_result / resolved? / winning_result). This replaces the previous obscure secretary-restart detour for the no-winner case. AgendaItem#winning_result gives candidates elected in earlier rounds precedence for multi-select elections: a no_winner conclusion after a partial election reads “Alice, Bob · Avsluttet uten vinner · 1 plass ubesatt”, and the secretary’s completed summary lists them under the badge. “Avslutt uten vinner” is also offered on a multiple-choice follow-up round that is ready but not opened (ChairState#can_conclude_follow_up?); set_manual_result!("no_winner") then deletes that never-run draft (discard_unopened_follow_up_rounds!) so the last round voted on stays the item’s latest/final round (Poll#final_round? — protokoll, projector, “Endelig resultat”).
Unfilled seats (VOT-424). AgendaItem#unfilled_seats is the number of seats a multi-seat election was concluded without filling — saved explicitly at the moment the election is concluded, never derived on display. The value comes from open_seat_count (the chain’s seats, Poll#original_max_selections, minus all_elected_candidates.size; 0 unless the latest round is multiple-choice with more than one seat). It is written by every concluding path: mark_completed! when a round of the current election has closed (election_voted_on? — so “Bekreft og avslutt valget”, “Bekreft valgte og fortsett” with nobody left to run and “Fullfør” mid-election all record it, while an item nobody voted on records 0), and set_manual_result! for no_winner and unanimously_accepted (after electing the ticked candidates; withdrawn / rejected save 0). It is reset to 0 by mark_incomplete! (“Gjenåpne sak”), clear_manual_result!, a restart (PollRoundBuilder :restart) and MeetingTestDataPurge. «Bekreft og avslutt valget» is ChairController#conclude_election (PATCH /meetings/:id/chair/conclude_election, poll_id): behind require_chair, require_meeting_open_for_mutation and require_confirmable_poll (closed, item’s latest round), plus Poll#ensure_can_conclude_election! (item not yet resolved — a replay does not advance the meeting twice — and partial_election_result?: closed multi-seat MC round with winners and seats_left_after_winners > 0). It confirms the round, elects its winners, completes the item (saving the seats) and advances, building no round. ChairState offers it on the :mc_partial card (can_conclude_election?); when next_round_possible? is false (nobody left to run) it is the only action. ChairState#seats_filled / effective_remaining count an already-elected winner once, so a reopened partial result stays :mc_partial. The per-round seat cap in PollRoundBuilder#follow_up_seats (a round never offers more seats than candidates) is no longer silent: the election keeps its seat total, the ready card warns via ChairState#follow_up_seat_shortfall, and the shortfall ends up in unfilled_seats. Shown by meetings/shared/_elected_candidates (chair resolved card, projector current item, secretary completed summary), the projector’s final-round card, the secretary detail seat counter, the protokoll (unfilled_seats field; markdown/PDF/DOCX line, CSV latest-round summary).
Chair state (VOT-389). The chair card is derived once per request by the ChairState PORO (app/models/chair_state.rb): state is :idle / :voting / :resolved / :result / :ready (first match in that order — a settled item is always :resolved, even with no closed polls, and never offers “Åpne votering”), and result_variant (:binary_adopted, :binary_rejected, :binary_no_votes, :binary_tied, :mc_complete, :mc_partial, :mc_winner, :mc_no_votes, :mc_all_tied, :mc_eliminate) picks the result partial and its primary action. All-tied and no-votes MC rounds re-run via create_round (“Ny runde (omvalg)”) instead of eliminating everyone; binary tie/no-votes cards offer “Neste sak”, which calls mark_complete with advance=1 (complete + advance). can_leave_unfinished? (:ready, meeting running, no closed round on the item — the untouched default draft round 1 does not count) offers “Neste sak” on the ready card; can_complete_and_advance? (VOT-441, VOT-478: the same and an information item) puts “Fullfør og neste sak” (complete_and_advance, which checks the same predicate) beside it. A vote item never gets it: one the meeting does not vote on ends through the manual result, which records how it ended; items with a closed round are settled from the result card / declined banner / manual result. A vote item whose draft round is gone has no primary action in :ready. set_manual_result, clear_manual_result, mark_complete, mark_incomplete and complete_and_advance refuse while any poll in the meeting is open. VOT-453 adds the answers a mirror needs: primary_action (a ChairState::Action — key and text — for the card’s one primary button, built from the same meetings.chair.* keys and data as the partials; nil once the meeting is closed), decline_action («Ikke bekreft» on every result card but the binary no-decision one), manual_result_options (the manual results the card offers, in its order) and the optional current_item: (the projector passes its own loaded item). The chair partials mark that one button with data-primary-action (MeetingsHelper#chair_primary_action_data, exactly one per page), which the Dirigentskjerm’s cue repeats (ProjectorScreen).
Vote
A cast vote (avgitt stemme). Double-voting for the same option is prevented both at the app level (validates :poll_option_id, uniqueness: { scope: [:poll_id, :meeting_participant_id] }) and at the DB level via a composite unique index index_votes_on_poll_participant_option on (poll_id, meeting_participant_id, poll_option_id). This composite is multi-select-safe: multiple_choice polls legitimately allow one row per selected option, so the index only forbids voting for the same option twice — an invariant that holds regardless of poll type. Per-poll selection limits are enforced separately by the within_max_selections validation. Validates participant has voting rights (observers cannot vote). Vote.cast!, Vote.cast_multiple!, and Vote.retract! each acquire poll.lock! as the first statement inside their with_immediate_transaction block (VOT-353); on Postgres this issues SELECT ... FOR UPDATE, under SQLite WAL + BEGIN IMMEDIATE it reissues a fresh SELECT so the poll_must_be_open validation reads the committed status. This closes the stale-tab race window between a chair’s closed! commit and a concurrent vote write. SimulationService#cast_votes routes through Vote.cast! so simulated participants share the same guarantee. Votes follow the meeting state (VOT-435, VOT-428 Q1): validate :meeting_must_accept_votes (:base, :meeting_not_accepting_votes) refuses a vote unless Meeting#accepts_votes? — test votes while preparing, real votes while active, none once closed — and the rule is the same for every role (chair, secretary, org member, participant, bot). Vote.retract! runs the same check through #ballot_box_open? (open poll + state), raising RecordInvalid with the failing rule on the record. All three entry points call the private lock_for_voting!: poll.lock!, then the agenda item with the meeting’s status alongside in one joined read (AgendaItem.joins(:meeting).select("agenda_items.*", "meetings.status AS meeting_status_for_vote")), so the state comes from the committed row inside the transaction at no extra query (a cast is still six statements). Only the status column is read: instantiating the whole meeting with eager_load cost ~8% of the load test’s throughput, the narrow read ~3% (docs/utvikling/lasttest.mdx). The rule for a bare status is Meeting.accepts_votes_in?; a vote built outside these entry points (Vote.create! in seeds and tests) falls back to the meeting association. Under SQLite’s BEGIN IMMEDIATE a state transition racing a vote is therefore serialised. Participant-level reasons (observer, voting rights switched off, item exclusion, «fraværende» and «feilregistrert» — VOT-436, voting_ineligibility_reason) stay in participant_must_have_voting_rights.
PollParticipantSnapshot
Per-participant voter eligibility snapshot captured at poll close (VOT-309). One row per participant per closed poll, withstatus enum (voted, did_not_vote, ineligible) and, for ineligible, ineligibility_reason (observer, chair_no_vote, secretary_no_vote, agenda_item_exclusion, and since VOT-436 absent and registered_in_error). Drives Poll#did_not_vote_count, #eligible_voter_count, #snapshot_eligible_count, and #snapshot_voted_count so post-close roster changes don’t shift the recorded denominator. Idempotent guard via Poll#has_snapshots? (live-query fallback for legacy polls predating VOT-309). Created by PollSnapshotService.call(poll) inside the close transaction. A participant with a counted vote on the poll is always recorded as voted, even if their eligibility has shifted since (VOT-430) — so the snapshot never contradicts the tally (voted ⊆ eligible, snapshot_voted_count == total_votes_cast).
PollOptionTally
Per-option vote-count snapshot captured at poll close (VOT-353). One row per(poll, poll_option) pair on closed polls. vote_count integer column holds the per-option count of Vote rows at the instant of close. Created by PollTallySnapshotService.call(poll) via insert_all! inside the same transaction that transitions the poll to closed. Composite unique index on (poll_id, poll_option_id) enforces idempotency. Backs the vote_count column returned by Poll#results whenever a snapshot exists.
PollResultSnapshot
Per-poll aggregate-outcome snapshot captured at poll close (VOT-353). Exactly one row per closed poll (unique index onpoll_id). Columns: total_votes_cast (distinct non-abstain voter count semantic, not vote-row count), non_abstain_voters_count, abstain_count, threshold_denominator (pre-resolved per counting_base rule), decision_result (nullable string — "ja" / "nei" / nil for non-binary or undecidable), eligible_voter_count (nullable integer, VOT-443 — the eligible voters at close, written in the close transaction; backfilled from the participant snapshots by migration 20261006205000, NULL for polls without them, where reads fall back), and JSON arrays winning_poll_option_ids + eliminated_poll_option_ids (ID references resolved at render time via current PollOption rows; no frozen titles). Drives the snapshot-aware branches in Poll#total_votes_cast, #abstain_count, #decision_result, #winning_options, and the private #threshold_denominator. Created by PollTallySnapshotService.call(poll); the only permitted post-creation mutation is PollTallySnapshotService.update_eliminated_for!(prev_poll, next_poll), which writes ONLY eliminated_poll_option_ids when a new round is created. Legacy polls closed before VOT-353 have no row and trigger live-query fallback via Poll#has_tally_snapshot?.
Platform Admin
Platform-level admin role viaplatform_admin boolean on User. Separate from org admins. Provisioned via admin:bootstrap rake task using ADMIN_EMAIL env var. Has view-only access to /admin dashboard (all orgs, users, meetings).
User preferences (VOT-473)
users.preferences (json, default {}, not null; migration 20261019120000) holds a user’s own view preferences, one top-level key per view. The secretary shell keeps { "hidden_pages" => [page keys], "pane_hidden" => bool } under User::SECRETARY_PREFERENCES_KEY ("secretary"); other keys are never touched. Per user, every meeting, every browser.
- Readers never raise, whatever is stored:
secretary_hidden_pagesreturns the stored keys that are inSecretaryNav::HIDEABLE_KEYS, in catalogue order (anything else dropped);secretary_pane_hidden?is true only for a stored booleantrue. - Writer:
update_secretary_preferences(hidden_pages:, pane_hidden:)replaces only thesecretarykey (a nil argument keeps the stored value) and returns false — nothing stored, the in-memory value restored, the reason inerrors— for a malformed list, an unknown key («Ukjent side: weather.») or a locked page («Møtedetaljer kan ikke skjules.»), or when an unrelated validation on a legacy row refuses the save.reset_secretary_preferences(«Tilbakestill til standard») removes thesecretarykey only, with the same false-on-refusal contract. - Validation runs only when
preferenceschanges (will_save_change_to_preferences?), so junk stored by other means never blocks an unrelated save; on every path (the Tilpass form, a crafted request, the console) a locked page (Saksliste, Deltakere, Møtedetaljer) is never hidden. - Written only by
Meetings::SecretaryPreferencesController(Tilpass and the rail’s pane switch) forCurrent.user; read bySecretaryNavfrom the loaded user (no query). Not exposed anywhere else: not in the profile form’s permitted params, the protokoll or the audit log.
ProtokollExport
Background-generated minutes export record (protokolleksport). Hasstatus enum (pending, generating, ready, failed). formats (JSON array, valid values: "pdf", "json", "markdown", "csv", "docx"). content_options (JSON) stores per-export rendering toggles — structure validated against CONTENT_OPTIONS_DEFAULTS (deep-frozen constant with keys: include_uncompleted_items, include_participants_list, vote_detail, per_item { description, poll_settings, secretary_comment }, per_round { eligible_voter_count, non_voter_names }); #content_options_with_defaults deep-merges stored options over defaults (symbol-keyed, symbolizes string-keyed JSON). has_many_attached :files (ActiveStorage — generated PDF/JSON exports). belongs_to :meeting (cascade on meeting destroy), optional initiated_by participant. generate! convenience method guards pending? status then enqueues ProtokollExportJob.perform_later(id). Driven by ProtokollAggregationService.call(meeting) which produces a versioned, symbol-keyed canonical hash of all meeting data — SCHEMA_VERSION = "2.0" (bumped from 1.0 by VOT-432: meeting.status value upcoming → preparing), additive-only within a major. The aggregation service ALWAYS returns the full hash; renderers filter based on content_options. ProtokollRendererRegistry — frozen dispatch table mapping format strings to renderer classes ("markdown" -> ProtokollMarkdownRenderer, "json" -> ProtokollJsonRenderer, "csv" -> ProtokollCsvRenderer, "pdf" -> ProtokollPdfRenderer, "docx" -> ProtokollDocxRenderer); also provides content_type_for(format) and file_extension_for(format) for all valid formats. ProtokollExportJob — background job orchestrating the export pipeline (pending -> generating -> ready/failed); captures meeting_status_at_export at job time; skips unregistered formats with warnings (ready-with-warnings if at least one file renders, failed if zero); broadcasts status changes via scoped Turbo::StreamsChannel.broadcast_refresh_to(meeting, "protokoll_export") stream. ProtokollExportError < StandardError for permanent failures (discarded, no retry); transient StandardError retried 3x with polynomial backoff. Renderer classes: ProtokollMarkdownRenderer (hash + content_options -> Markdown string), ProtokollJsonRenderer (hash + content_options -> JSON string with string keys, filtering by key omission), ProtokollCsvRenderer (hash + content_options -> ZIP of CSV files), and ProtokollPdfRenderer (hash + content_options -> binary PDF via Prawn with cover page, participants table, agenda items, poll results, and page numbers). All include ProtokollRendererSupport shared module for identical content_options filtering, terminology lookup (term_for), participant resolution, and vote-detail-level logic. Renderers receive the aggregation hash only (no ActiveRecord access). PollSettingsSummary.deviations(poll) is the single source of truth for noteworthy poll settings deviations (shared between aggregation service and future UI). meeting_status_at_export snapshots meeting status at export time (MEETING_STATUS_VALUES = preparing/active/closed, plus the historical upcoming for exports made before VOT-432). failure_reason and failed_at for diagnostics on failed exports. superseded_at (VOT-437) is set by Meeting#reopen! on every export that existed when the meeting was reopened — it describes the meeting as it was before; superseded? drives the «Erstattet» badge in the secretary’s export list.
ProjectorScreen
A configured large-screen display of a meeting (projektorskjerm), reached throughhas_secure_token :token (Meetings::ProjectorController; rotate a leaked link with «Ny lenke», POST /meetings/:slug/projector_screens/:id/regenerate_token — the regenerate_token action calling has_secure_token’s regenerate_token) and, since VOT-449, admitted by its access_level (below). belongs_to :meeting, name required.
The cable identity (VOT-443). An anonymous projector holds a cable connection through ProjectorCableIdentity (app/models/projector_cable_identity.rb, class methods only): the signed, httponly, same_site: :lax cookie projector_screen (one-year expiry, secure outside development/test) with payload { "id", "digest" = SHA256(token) }, set on every rendered public projector page; allowed_for?(screen) (access_level_public? since VOT-449) is read at issuance and at connect; discriminator(payload) is the database-free Rack::Attack key (projector/screen, cable/projector); screen_for(payload) is the connect-time lookup (screen exists, digest matches the current token in constant time). Rotating the token or deleting the screen kills the identity at the next connect. Updating, deleting or rotating a screen broadcasts a bare refresh on [@meeting, "projector"]; a dead link renders the script-free 401 page.
Access level (VOT-449). access_level (string, NOT NULL, default management; migration 20261010150000 gave every existing screen management) is an enum with prefix: true — public («Alle med lenken»: the link alone), participants («Deltakere»: the link and a login as a listed participant of the meeting, observers and absent participants included, or a member of its organisation or an ancestor), management («Møteledelsen»: the link and a login as the meeting’s secretary or chair, or an admin of its organisation or an ancestor). The prefix keeps a bare public from defining ProjectorScreen.public and shadowing Module#public: the predicates are access_level_public? and so on. Every new screen gets the column default management; the automatic «Hovedskjerm» gets its level from ONE line in ProjectorScreen.default_attributes (access_level: "management") — changing it to "public" makes every new meeting’s Hovedskjerm hall-ready, existing screens unaffected. Only the Dirigentskjerm layout is locked: requires_management? is true for the chair_podium preset or a placed podium-only widget (chair_state, next_action, own or fallback), and such a screen must be management (validation management_only on access_level; the chair_podium template sets the level). «Avgjort» (knockout) on a hall screen is not locked — restricted_to_management? (= access_level_management?) is a hint for the editor’s soft notice, nothing more. Who is admitted is decided in one place, ProjectorAdmission.for(screen, user) (app/models/projector_admission.rb): :admitted for public (no query), :login_required for a nil user, else MeetingAccess (app/models/meeting_access.rb — the participant / management test MeetingAuthorization delegates to as well) answers participant_or_org_member? for participants and management? for management → :admitted / :forbidden. It never reads the meeting’s state, the capability matrix, the screen’s content or platform_admin. The refusal pages store the screen’s path for the e-mail login (ProjectorReturnPath: the encrypted session, 30 min, used once, projector paths only). The page, the cookie and the cable per level: Projector access levels.
Layout model (VOT-444, rendered since VOT-450). The NOT NULL columns below describe what the screen shows — and nothing else does (VOT-457 added closed_state):
The four enums follow the VOT-416 pattern (
validate: { message: :invalid_enum_value }): an unknown or blank value is a validation error naming the value — 422 / a re-rendered form — never an ArgumentError. Labels: meetings.projector_screens.{layout_presets,themes,text_scales}.* (used by the VOT-455 setup UI) and meetings.projector_screens.setup.closed_state.* (VOT-457).
ProjectorScreen::PRESETS maps each preset to its ordered slot names and primary slot (Preset = Data.define(:key, :slots, :primary_slot); a test pins layout_presets.keys == PRESETS.keys):
sections. Each entry places one widget in one slot: { "slot", "widget", "options", "when_empty", "fallbacks" } — options a Hash (default {}), when_empty hide | placeholder (default hide), fallbacks an array of widget keys tried in order when the widget has nothing to show (default []). A before_validation (normalize_sections) fills the optional keys, so every stored entry carries all five string keys; widget option defaults are not stored — they are applied on read. The writer sections= accepts an Array or a JSON String (what a form posts: nil / blank → []), never raises, and remembers a String that does not parse (the stored value is left as it was). Validation (SectionsValidator, nested in the model) reports one locale-backed error per problem on :sections: invalid_json, invalid_shape (not an array of Hashes, a key outside the five, options not a Hash, fallbacks not an array), unknown_slot, duplicate_slot, unknown_widget, widget_not_allowed, unknown_option, invalid_option, option_too_long (VOT-452), invalid_when_empty, unknown_fallback, fallback_not_allowed (activerecord.errors.models.projector_screen.attributes.sections.*). placed_sections returns ProjectorScreen::Section values (Data.define) in the preset’s slot order, with effective_options (the widget’s defaults merged under the stored options), hide_when_empty? and chain (the widget, then its fallbacks); it never raises.
Widget catalogue. ProjectorWidgets (app/models/projector_widgets.rb, one class per widget under app/models/projector_widgets/) lists the sixteen widget keys, in picker order — active_poll, last_result, knockout, chair_state, remaining_voters, participant_grid (group votering); current_item, agenda, next_item, next_action, attendance (moetet); clock, organization, welcome_message, qr_join, free_text (informasjon). All have audience :hall except the three chair-support widgets (:chair_support): knockout («Avgjort», VOT-451; allowed in every preset; option show_heading, default true — off, it leaves out the poll heading), and since VOT-453 chair_state («Sakstilstand»; option show_bars, default true) and next_action («Neste handling»; no options), which allowed_in :chair_podium restricts to that preset — the first real use of the restriction: anywhere else a section or a fallback naming them is widget_not_allowed / fallback_not_allowed. Only the chair_podium template places chair-support widgets; no hall template, no «Hovedskjerm» and no implicit chain does (VOT-455 offers them in the picker). Each declares an options schema (option name, :boolean | :enum | :string, default:, values:, max_length:, max_bytes:, format:), the presets it may be placed in (allowed_in; every preset unless it says otherwise), and two predicates: privacy_sensitive?(options) (shows per-participant status or tallies before close) and needs_progress_stream?(options) (changes with every vote). ProjectorWidgets.fetch(key) returns nil for an unknown key. Widget keys and option names are persisted data: renaming one needs a data migration. Since VOT-452 a string option may declare max_length: (a positive Integer, in characters), max_bytes: (a positive Integer, in UTF-8 bytes — the QR address’s limit, because the code encodes bytes: 200 emoji would otherwise make a dense, slow, barely scannable code) and format: :http_url (a key of ProjectorWidgets::Base::STRING_FORMATS: an absolute http(s) URL without whitespace, ", < or >); declaring any of them on a non-string option, a non-positive limit or an unknown format raises when the class loads. All three sit in the schema for the screen editor (VOT-455). Validation checks type, then characters, then bytes — an over-long value is option_too_long, naming slot, key and the limit (in bytes with its own sentence, option_too_long_bytes) — then format (invalid_option); an empty string skips the format check, and a value is never truncated. A string option’s error never echoes the rejected value: a wrong type or format is invalid_option with its own sentence (invalid_option_string), naming slot and key only; enum and boolean values are still quoted.
Registry runtime (VOT-450). The same classes carry what the resolver and the views call (ProjectorWidgets::Base): renders_partial declares the renderer meetings/projector/widgets/_<key> (partial, nil without one); content?(context, options) says whether the widget has something to show; present?(context, options) = has a renderer AND content? — a widget without a renderer is never present; placeholder_key (meetings.projector.placeholders.<key>, what a when_empty: placeholder slot shows); tally? (shows a poll’s per-option tally: active_poll, last_result); reveal_option values: declares the reveal enum with its default derived from audience (:hall → on_confirm, :chair_support → live). ProjectorWidgets.renderable lists the widgets with a renderer — since VOT-452 every catalogue key (ProjectorWidgets.renderable.map(&:key) == ProjectorWidgets::KEYS, all sixteen since VOT-453). The renderer-less rule stays for widgets added later (ProjectorLayout.resolve skips a candidate without a partial before asking for content; the tests hold it with a test widget, test/support/projector_test_widgets.rb). last_result accepts reveal: live too (read like on_close: closed polls only).
Informational widgets (VOT-452). Four widgets show the meeting’s context rather than its flow, all audience :hall, on every subscription tier (no has_feature?, no terminology term):
free_text is the one widget whose options decide presence; as a fallback it gets default options, so it is never present there. The context loaders (ProjectorContext, memoized like the rest): organization (the meeting’s organisation — the same record the layout’s projector_brand_style_tag walks for effective_branding (organization_style_tag before VOT-446), so the chain is loaded once); organization_logo_url — the effective logo_url (the first organisation up the parent chain that sets one; the meeting’s own organisation wins) when it is https://, else nil, so an own http:// value gives the initials even under an https parent; organization_initials (ProjectorContext.initials: the first letter of each of the first three words, upper-cased — «Norges Speiderforbund» → «NS»); welcome_message — the rich_text_welcome_message record when its plain text is present, else nil, in one query (never meeting.welcome_message, which builds an unsaved record); join_url(options) — the section’s own url when it passes the option’s checks, else meeting_link = meeting_url(meeting, **CanonicalUrl.options), and join_display(url) (no scheme, no trailing slash). CanonicalUrl (app/models/canonical_url.rb) gives the scheme / host / port the app’s own mails link to (action_mailer.default_url_options, https under force_ssl), so the code points at the host the magic-link cookie lives on, never the host the projector was opened on. The code itself is QrCodeSvg (app/models/qr_code_svg.rb: rqrcode, level M, a 4-module quiet zone, one black path on a white tile, no style, no prolog; ≈ 6 ms for a meeting link) wrapped by QrCodeHelper#qr_code_svg(data, label:, html_class:) in one inline <svg role="img"> — available to every view (VOT-455’s screen overview reuses it) and generated once per URL per request, with no cache. The logo <img> (alt="", referrerpolicy="no-referrer") sits over its initials tile; projector_logo_controller.js swaps to the tile when the image fails and keeps it across morph refreshes. The meeting’s title reaches a live screen only through organization.
Content templates (VOT-450, VOT-452, VOT-453). Eight content templates: ProjectorScreen::TEMPLATES holds the deep-frozen layouts of TEMPLATE_KEYS (in form order; every entry when_empty: hide, no fallbacks), offered as the screen editor’s «Ferdige oppsett» quick starts on a new screen since VOT-455 (meetings.projector_screens.templates.<key>.{label,hint}):
content_template is a virtual attribute (attr_accessor, cleared after save): a template key replaces layout_preset + sections wholesale in a before_validation (apply_content_template); blank or keep changes nothing (the model still accepts keep; the editor never posts it); any other value is a validation error on content_template (unknown_template, 422 on the web) and changes nothing. template_attributes(key) returns a deep copy; ProjectorScreen.privacy_exposing_templates (live_results, remaining_voters, participant_grid, chair_podium) is derived from the catalogue, and the editor’s quick starts mark exactly those «Personvernfølsom». A template may set more than the layout: chair_podium also sets text_scale: large (on create and update; the other templates leave text_scale alone), and TEMPLATE_DEFAULT_NAMES names a screen created from it without a name «Dirigentskjerm» (projector_screens.chair_podium_name) instead of failing validation.
Create default. A second before_validation on create (apply_default_template, after the template and before normalize_sections) gives a screen created without any layout input — no content_template, no layout_preset / sections change — the standard template. A direct layout_preset / sections write always wins, and an update never derives anything (a name-only edit keeps a hand-set layout). sections: [] with the default preset is indistinguishable from no input and gets the template too.
The Dirigentskjerm (VOT-453). The chair_podium template is a read-only mirror of the chair card for the dirigent at the podium while an operator drives the chair view; no meeting gets one automatically. Its two chair widgets read the chair’s own derivation: ProjectorContext#chair_state = ChairState.for(meeting, current_item: context.current_item) — built lazily, only on a screen that places a chair widget (a hall screen never builds one), at most once per request, with the page’s one record of the current item — and never derive state from polls or items themselves; voting counts come from the context’s per-poll loaders (shared with «Avgjort»), result figures from ChairState’s result helpers and the round’s snapshot. ProjectorContext#rendered_at is the request’s time, read once. chair_state (the hero) renders the item’s number and title, one state pill (and, at a result, the decision pill), then per state: the draft or declined round (_chair_state_ready), the key tally with live bars and the rule (_chair_state_voting), the confirm question «Vil du bekrefte …?» or the chair card’s explanation with the margin in plain numbers (_chair_state_result), the settled decision and elected candidates (_chair_state_resolved); then the manual-result labels the card offers (ChairState#manual_result_options, plain text) and, ready with a draft in a large region, the ballot. A round titled like its item spends no line on its title. next_action (the cue band) says «Neste:» and ChairState#primary_action’s text — the chair view’s one data-primary-action button, word for word (test/integration/chair_podium_contract_test.rb) — then «eller: Ikke bekreft» where the card offers it (decline_action), «Hurtigvotering er sperret mens en votering pågår» while a poll is open, and «Sist oppdatert HH:MM:SS» with the connection dot (data-connected, driven by the canvas’s connection-status controller). While the meeting is preparing the canvas carries an inset amber frame (pj-canvas--rehearsal) besides the «Prøvevotering» band. Derived: privacy_exposing? and shows_vote_progress? true (it follows progress while the meeting is not closed). Once the meeting is closed its hero is the record region with who ended the meeting, its rail only the clock and its cue only «Sist oppdatert» and the dot (below).
Derived predicates. All read the placed chains — layout_sections (sections in a slot of the preset with a known widget, the first per slot, in slot order; never raises) and placed_chain_widgets (each section’s widget with its effective options, then its fallbacks with their defaults; the primary slot’s implicit chain does not count) — never the meeting’s state:
shows_vote_progress?— some placed widget answersneeds_progress_stream?: the page subscribes to[@meeting, "progress"].privacy_exposing?— some placed widget answersprivacy_sensitive?(«Personvernfølsom» in the screen overview, in the screen list and the secretary panel alike). Since VOT-455privacy_exposuressays which kind of exposure comes from which slot (below).
projector_access_lock_controller.js while the Dirigentskjerm card is checked — and on a new screen the «Ferdige oppsett» quick starts) and «Innhold» (one card per slot: the widget, its options, when_empty, the reserve stack, beside a live preview) — and writes a screen only through one form object, ProjectorScreenSetup (app/models/projector_screen_setup.rb), for the step navigation, the preview and the save alike, so the preview shows what the save stores:
- Setup params →
sections. It readsname,layout_preset,text_scale,access_level(VOT-449: a step-1 attribute; when none is posted achair_podiumlayout getsmanagement, any other keeps the edited screen’s level or the column default),closed_state(VOT-457: a step-2 attribute step 1 carries; a blank one counts as not posted — posted, else the edited screen’s, else the column default — and achair_podiumlayout resolvesrecord),content_template(a quick start), the carriedsectionsJSON withsections_preset(the preset it was built for) andregions[<slot>][widget | when_empty | fallbacks[] | options[<widget>][<key>]]. Regions become one canonical section per slot of the posted preset: only the selected widget’s options, only its schema keys, coerced, stored only when they differ from the default; fallbacks blank-free, unique and without the region’s own widget. The content comes from, first match wins: a quick start (through the publicProjectorScreen#apply_template(key), the method thebefore_validationcallback calls too, so a quick start and a directcontent_templatewrite give the same screen) › posted regions › the carried JSON › the stored sections › the preset’s starter. Preset change: carried or stored sections equal to the starter of their preset follow the new preset’s starter; customised sections keep their primary section when the new preset allows its widget (fallbacks filtered the same way), and step 2 says so. Nothing is written before the final save. - Errors per step and region.
layout_valid?(step 1’s attributes only),failed_step(:layoutor:content— the step a refused save re-renders, 422),errors_for(slot)(the slot’ssectionserrors for its card, with the slot, widget and option named by their labels — «… «Tekst» i feltet «Sidefelt 4»» — the model’s own messages unchanged),general_errors,errors_outside(slots).build_unsavedbuildsProjectorScreen.new(meeting_id:, …), nevernew(meeting:)—has_many_inversingwould append the unsaved screen tomeeting.projector_screens. - Starters.
ProjectorScreen::STARTERS/starter_attributes(preset)({ layout_preset:, sections: }, a fresh copy, notext_scale) give each preset its suggested layout: four are templates (single→poll_only,main_sidebar→standard,header_two_columns→welcome,chair_podium→chair_podium), and two are recipes of their own —grid_2x2:current_item,active_pollhall default,remaining_voters, agenda window;three_columns: agenda window,active_pollhall default,next_item. - Privacy (W7). Each widget class answers
privacy_exposures(options), a subset ofProjectorWidgets::Base::EXPOSURE_KINDS—tallies_before_close(tallies or a margin before the close),who_voted,pending_outcome(an outcome the chair has not confirmed).privacy_sensitive?is derived from it (its truth table unchanged), andprivacy_possible?(some enum / boolean combination of the schema exposes something; memoised per class) marks the picker’s «Personvern» badge:active_poll(live_barsorreveal: live),remaining_voters(show_numbers),participant_grid,knockout,chair_state,next_action.ProjectorScreen#privacy_exposuresmaps each kind to the slots whose placed chain gives it — the editor’s «Personvern» card names those slots.ProjectorScreen#restricted_to_management?=access_level_management?(VOT-449, read from the level carried from step 1); only the step-2 soft notice reads it: on apublicorparticipantsscreen the notice shows for every live tally or chair-support placement («Avgjort» included), on amanagementscreen nothing. Never a validation. current_item’sshow_description(boolean, defaulttrue, stored insections— no migration): off, the item’s description never renders on that screen, whatever the region’s size. A screen saved with it off fails validation under pre-VOT-455 code (rollback note in the release notes).- The preview.
ProjectorScreensController#previewrenders the screen the form object builds withProjectorPreview::Context(app/models/projector_preview/): aProjectorContextsubclass with the meeting’s real identity — organisation, logo, name, planned dates, welcome message, join link, the agenda’s titles in running order — and a fictional flow: sample polls, options, votes, participants, attendance and decisions, with the realChairState(given rounds) andMeetingRecordderivations over sample objects. Six scenarios:preparing,voting(default; the thumbnails),awaiting,revealed,closedandpaused(VOT-445: therevealedflow with a 30-minute pause started 12 minutes before the render and the sample message «Kaffe og frukt står i foajeen»).ProjectorPreview::Context::INHERITED_READERSpins the readers that come from the real context —pauseandmessageare not among them: the sample answers them (nil but inpaused), andProjectorPreview::MeetingViewanswers the meeting’s live readers from the sample too, so the real pause or message never reaches a preview;test/integration/projector_preview_contract_test.rbrenders every widget × option × scenario and fails on any query against the flow tables.
Meetings::ProjectorController#show builds one ProjectorContext and calls ProjectorLayout.resolve(screen, context) — in every meeting state since VOT-454 (one page; see «The closed screen» below):
ProjectorContext(app/models/projector_context.rb) is everything a projector page reads about its meeting, loaded lazily and memoized per request (nil answers too), so a clock-only screen runs no meeting-data query. Since VOT-451:current_item(with its polls, for the poll line);agenda_items— the running order (Meeting#agenda_items_in_running_order: planned depth-first when enabled, else agenda order), loaded once, from whichnext_item(the rule ofMeeting#upcoming_agenda_items.first) andlast_item?(«Siste sak»: nothing follows and the current item is still open) are derived;open_poll;latest_closed_poll(Meeting#latest_closed_poll: newest by snapshot time,updated_atfor legacy polls), which is alsolast_result_poll(«Siste resultat»);vote_roll— the open poll’s item’sMeetingParticipant.vote_roll(below), real participants only, in number order — withvoted_participant_idsandremaining_numbers;attendance({ total:, present: }in one aggregate query, the chair card’s numbers); per pollvotes_cast/eligible_count/outstanding/results/percentage_denominator,decision_outcome(:partialfor «Delvis valgt» perPoll#partial_election_result?, elsePoll#outcome),elimination_history(the candidates eliminated after each round of the election chain since the last restart, from the rounds’ snapshots —Eliminated(option_id, title, round)) andknockout; per itemelected_candidates— the reveal rule for names: only the candidates the chair confirmed (PollOption#elected), and the latest round’s winners only once the item is settled (completed, manual result or unfilled seats), viaAgendaItem#announced_elected_candidates(VOT-454: the closed record’s rule too) — so «Gjeldende sak» never names a winner while the round awaits «Bekreft». The poll on display (poll_on_display) is the open poll, else the meeting’s newest closed poll whichever item is current — a result stays on the hall screen until the next poll opens; once the meeting is closed it is always the newest closed poll, never a poll left open (VOT-454).ProjectorContext.reveal_state(poll, reveal)is pure: open →liveiffreveal: live, elsecollecting; closed →revealedforon_close/live, and foron_confirmonce the chair decided (confirmed_outcomeset) or the item is settled (AgendaItem#resolved?),awaiting_revealuntil then — exactly whilePoll#awaiting_decision?(VOT-454); nil for a draft or no poll. A declined result (muted?) and a provisional one (provisional?: revealed underon_close/livebefore the chair confirmed, declined or settled it) are shown muted with «Ikke bekreftet» and no decision pill.pause/message(VOT-445) are the meeting’s running pause atrendered_at(Meeting::LivePause) and its shown message (Meeting::LiveMessage), read from the loaded row (no query);pauseis nil once closed,messageis read in every state (VOT-457). VOT-454 addsclosed?,record(the meeting’sMeetingRecord, built only when a closed screen renders its record region, once per request) andsimulated_participant_count(the band’s bot count, one query, only while preparing).ProjectorLayout(app/models/projector_layout.rb,ProjectorLayout::Grid) holds one base grid per preset (GEOMETRY: area matrix,fr/autotracks, stacks). Per slot, the first present widget of the section’s chain fills it (fallbacks with their default options); the primary slot then triescurrent_item→welcome_messageand otherwise is the idle region («Venter på neste sak…»), ignoringwhen_empty— since VOT-452welcome_messagerenders, so every screen whose primary has nothing to show displays the welcome message when one is set: before the meeting starts, and on the «Hovedskjerm» right after «Start møte» (which clears the current item) until the first poll opens; another empty slot is a placeholder region withwhen_empty: placeholder, else it is removed andGrid.collapsehands its space to a neighbour so every surviving area stays a rectangle. The result (Resolved) carries the regions (area, widget, options, size hintband/compact/medium/large/full, placeholder, andsystem—:idleor, since VOT-454,:closed_recordfor a region no catalogue widget fills), the inline grid style and the bands ([:preparing]while preparing). VOT-445: whilecontext.pauseruns and the screen hasshow_pause, the primary slot of every preset butchair_podiumis the pause region (system: :pause,meetings/projector/widgets/_pause: «Pause», the countdown, «Møtet fortsetter kl. …», the elapsedprogresstrack; its own chain and the implicit chain are not asked); the bands are, in order,:preparing(before close),:message(show_message, every preset, also once closed — VOT-457) and:pause(show_pause,chair_podiumonly, so the Dirigentskjerm’s hero keeps the chair’s state; never once closed) —meetings/projector/bands/_<band>. The closed branch wins over the pause.
<progress class="pj-track">), one rule line (Poll#threshold_display_text plus the leading option’s margin, e.g. «For: 29 av 39 avgitte stemmer (74,4 %)» — the percentage through ProjectorHelper#projector_percent in the meeting’s language since VOT-446) and one shared result (widgets/_poll_result) that active_poll and last_result render identically: the decision pill in the words of MeetingsHelper#poll_decision_label (the helper poll_decision_badge reads, so the projector and the admin views cannot disagree — Vedtatt / Avslått / Uavgjort / Valgt / Delvis valgt), «Endelig resultat», the rule line, bars with the winners in ochre with a check and «Valgt» (not on a muted result), «Eliminert etter runde N» for this round’s and the earlier rounds’ eliminated candidates, the item block (manual-result pill, elected candidates, unfilled seats) once, and the turnout line «N av M stemmeberettigede har stemt» with «Avholdt seg: A». Every badge is a projector pill (ProjectorHelper#projector_pill, each tone reading its --pj-pill-<tone>-{bg,fg,border} tokens, at least 2.5rem tall — 40 px at 1920×1080, VOT-446); no widget partial renders the admin badge partials. What a widget shows depends on its region’s size hint: the item’s introduction (its ActionText body, «Beskrivelse») only at large / full — so the description reaches the hall screen there — and the rule, restriction and turnout lines and the earlier-eliminated list not at compact / band. agenda lists the running order with a check on completed rows, a filled current row, indentation by depth classes, dense rows, and two columns in a region at least 75rem wide (container query); next_item says «Siste sak» on the last open item; remaining_voters and participant_grid count the vote roll; attendance («Oppmøte») shows «N av M til stede» from the participant list; clock with show_date flips its date at Oslo midnight in the browser; knockout («Avgjort») shows whether the open poll’s outcome is already settled («Utfallet er avgjort» / «Kan fortsatt snu» / «Ingen stemmer ennå»), the projected outcome, the margin and the votes still to come.
Vote roll, decision rule and knockout (VOT-451). MeetingParticipant.vote_roll(agenda_item:) is everyone who may vote on the item disregarding attendance — listed, not observers, a chair or secretary only when the meeting lets them vote, not excluded from the item — and eligible_voters is vote_roll.attending, one definition of stemmeberettiget. PollDecisionRule (see Poll) decides; PollKnockout (app/models/poll_knockout.rb, pure) evaluates an open poll with the same rule: the projected outcome on the votes so far, and whether any way the outstanding voters (M − N, bots included while preparing) could still vote would change it. It is sound for every poll (never «decided» while some completion gives another outcome), exact for binary and one-seat polls (closed forms), and conservative for multi-seat polls (rank bounds; may say «Kan fortsatt snu» when the seats are in fact fixed); for multiple choice the outcome is the set of winners. Votes already cast count as standing; a retraction shows up in the next evaluation. It follows the VOT-456 seat cutoff: its rank bounds call an option surely elected only when, even if every rival gained every outstanding vote, at most seats qualifiers could have at least its votes, and surely not elected when at least seats rivals already have its votes plus every outstanding vote. Soundness and exactness are proven by test/models/poll_knockout_exhaustive_test.rb over small electorates, and soundness on the cutoff branch, which those electorates cannot reach, by test/models/poll_knockout_cutoff_sweep_test.rb.
The closed screen and the rehearsal band (VOT-454). A closed meeting renders through the screen’s own layout — there is no closed page. ProjectorLayout.resolve makes the preset’s primary slot the record region — unless the screen keeps its own content (VOT-457, keeps_own_content_once_closed?: its own chain under the closed presence rules below, then the implicit chain — effectively welcome_message, as current_item is never present once closed — and the record region only when nothing is present, never the idle region): a system region (Region#system == :closed_record, like the idle region — not a catalogue widget, so the 16 keys, the picker and the predicates are unchanged), whose own chain is not asked, rendered by meetings/projector/widgets/_closed_record from ProjectorContext#record only. It shows the «Avsluttet» pill, «Møtet er avsluttet», the closing line — with who ended the meeting only on chair_podium (the region’s show_actor option, set by the resolver per preset) — the counts, and each entry’s outcome in the participant record’s words and order (manual-result pill, decision line or «Behandlet», «Ikke ferdigbehandlet», «Ikke behandlet», the confirmed candidates and unfilled seats): no tally, no vote count, no meeting title. At compact / band the head alone; from a 75rem region the rows flow into two columns; rows that do not fit give way whole from the end (VOT-446; at medium the head is compact — no pill, the title in the heading tier). The other slots keep their sections under the closed-state rules (ProjectorWidgets::Base#present? asks closed_content? once context.closed?): current_item, next_item, remaining_voters, participant_grid, knockout and chair_state are hidden_once_closed (not present, without a query); active_poll and last_result show the newest closed poll only when it is revealed under their own reveal, else they are not present — a closed screen never says «Resultatet kunngjøres av dirigenten»; agenda has no current row and marks «Ikke ferdigbehandlet» by Meeting#unfinished_agenda_item?; next_action renders only «Sist oppdatert» and the dot (widgets/_cue_meta) and builds no ChairState; attendance and the informational widgets are unchanged. On «Velkomst» the primary is left, so the record replaces the welcome message there once closed, unless the screen keeps its own content. No screen lists closed polls. Once closed the only band is the message (VOT-457): a message shown before or after «Avslutt møte» stays above the grid of every screen with show_message, the pause never shows. The page subscribes to updates and projector in every state, to progress only while the meeting is not closed; «Start møte», «Avslutt møte» and «Gjenåpne møte» reach an open screen through the updates refresh as a morph of the same page. While the meeting is preparing every screen shows the projector-native band (meetings/projector/bands/_preparing: the warning-tone «Prøvevotering» pill, the rehearsal sentence and, with bots, «N simulerte deltakere»), its own canvas row above the grid; only the Dirigentskjerm adds the frame.
Legibility, theme and language (VOT-446). What the page looks like and which language it speaks, read from the screen and the loaded meeting row (no query added):
- Canvas unit. The projector layout’s
<html class="pj-root">sets the root font size tomax(8px, min(100vw / 120, 100vh / 67.5))— 16 px at 1920×1080, 12 px at 1440×900, never below 8 px — and every length inprojector.cssis in canvas units (rem), so a screen shows the 1920×1080 composition at any resolution, scaled. Browser zoom changes nothing;text_scale(--pj-scale1 / 1.15 / 1.3) is the one way to enlarge the type. The type tiers and their floors are in the Design System. - Whole blocks. Every widget root is a
.pj-fit(or a.pj-stack) whose DOM order is its priority order — prose and lists inside it are a.pj-lines(a one-column multicol that shows whole lines or rows and keeps its own size) or a.pj-fill(the same, also taking the space its widget has left in a sized region): a region that cannot show everything drops whole blocks — lines, rows, bar sets, images — from the end, never cuts one and never scrolls; CSS only (see Controllers and Partials).ProjectorLayout::Region#sized?marks a region that spans anfrrow (pj-region--sized, where fillers take the free space). Once the meeting is closed and the primary slot shows the record region (VOT-457: not on a screen that shows its own content),ProjectorLayout::Grid#weight_primary_stackgives the primary region two thirds of the column it shares with a stack partner — on «Velkomst» the record over the QR code, 2 : 1. - Theme.
themepicks the--pj-*token set through the body’sdata-theme— on the page, in the preview and in the overview’s thumbnails. The QR code keeps its white tile and dark modules in both themes, the organisation logo sits on a light plate, and the refusal pages stay light whatever the screen’s theme. The organisation’s primary colour reaches the accent tokens only through the contrast guardProjectorTheme.brand_accent(see the Design System). - Language.
Meetings::ProjectorControllerskipsApplicationController#set_localeand wraps every page of a meeting whose slug resolves —show, the dead-link page, the two refusal pages — inI18n.with_locale(@meeting.projector_locale), so a public screen resumes no session and every viewer sees the meeting’s language. The preview builds its sample and renders its canvas in the meeting’s language; its organiser chrome (the truncation note, the «Personvern» card) stays in the organiser’s own.<html lang>isProjectorHelper#projector_html_lang; the page’s<title>isprojector_page_title(«LM26 – tilvotering»), a refusal page’s its own title + « – tilvotering» with no meeting data — the body names the meeting only in «Organisasjon». A quick poll’s number on the projector isProjectorHelper#projector_item_number(«Hurtig 3», fromAgendaItem#quick_poll_sequence=position + 1, in the meeting’s language);display_number/quick_poll_numberstay «Hurtig N» on the admin views and in the protokoll. Percentages go throughprojector_percent(«71,4 %» with a no-break space, from the locale); a bar’s width stays the machine value. The Dirigentskjerm’s margin leaves out «, 0 avholdende».
20261008100000): its down re-adds them and writes every row through a vote-safe reverse mapping (legacy_columns_for) that never restores the old live bar charts for a screen showing no live tally. Rollback order: run that down with the VOT-450 code still deployed, then deploy the revert at once (the VOT-444 model declares enums on the three columns, so the code cannot be reverted first). Screens created in between — a new meeting’s «Hovedskjerm», the secretary form — are not covered by the reverse mapping: the VOT-450 code never writes the old columns, so they get the column default vote_display = live_results (live bar charts under the VOT-444 views). After the revert, check the screens created since the down and set them vote-safe (update_all(vote_display: "results_after_close")). ProjectorScreenLegacyMapping (app/models/projector_screen_legacy_mapping.rb) is now only the frozen helper of the backfill migration 20261007100000 and of bin/rails "migration_rehearsal:projector_screens[<ref>]", which rehearses the whole chain — backfill, the delay-column drop (20261007100100) and this drop — against a deployed schema. Nothing in the model derives from or writes the old columns.
Default «Hovedskjerm». Meeting after_create saves ProjectorScreen.default_for(meeting) in the meeting’s own transaction, so every creation path (free signup, Stripe webhook, org-admin form, seeds, load test) yields one screen named «Hovedskjerm» (projector_screens.default_name): the standard template (default_attributes), theme: light, text_scale: normal, show_pause and show_message true, access_level: management (VOT-449; the one line to flip to public). A screen that cannot be saved fails the meeting’s creation (the webhook answers 500 and Stripe retries). MeetingTestDataPurge («Nullstill møte», «Start møte») keeps projector screens. Every screen, including a meeting’s only one, can be deleted.
SpeakerQueue
Speaker list for debate (taleliste — innlegg/replikk) — not yet implemented.Test Mode (the preparing state)
There is no separate test mode (VOT-433; themeetings.test_mode column was dropped). The preparing state («Forberedelse») is the rehearsal: the meeting is drivable?, every vote cast is a test vote, and the rehearsal-only actions — creating bots, simulating votes, «Nullstill møte» — are available. In active and closed they are refused for every role (chair, secretary, org admin) on three layers: MeetingAuthorization#require_preparing_meeting on SecretaryController#create_bots/simulate_votes/reset_meeting (flash or JSON 422), Meeting#ensure_preparing! in SimulationService and MeetingTestDataPurge#call (Meeting::NotPreparing, rescued by the controller), and a MeetingParticipant validation that makes an is_simulated participant invalid unless the meeting is preparing. Nothing returns to preparing (VOT-428 Q3), so none of this is reachable once the meeting has started. The tools live on the secretary view’s «Prøvekjøring» page (preparation_panel; the rail draws its «Test» entry only while preparing, VOT-473); there is no chair console. Test chrome (meetings/shared/_preparing_banner) follows the state on the chair, secretary, participant, voter and projector views. Historical meeting.test_mode_toggled audit rows still render.
Reset («Nullstill møte», VOT-429). MeetingResetService#call runs from the «Prøvekjøring» page (SecretaryController#reset_meeting; secretary, chair and org admin). It refuses unless the meeting is preparing and delegates to MeetingTestDataPurge (app/services/meeting_test_data_purge.rb), the reusable purge «Start møte» also runs: Meeting#start! calls it in the same transaction, before it moves the meeting to active (call itself refuses a meeting that is no longer preparing). preview returns Counts (votes, rounds, quick_poll_items, simulated_participants, completed_items — items that lose their completion or manual result, a deleted quick poll counted only once) without writing; call purges in one transaction and returns the same counts, stored as the meeting.reset / meeting.started audit metadata (shown in the activity log).
Activation (MeetingTestDataPurge.new(meeting, activation: true), «Start møte» only, VOT-434). Owner decision (VOT-428 round 2): nothing from before the start survives in the record. On top of the purge below, the activation deletes every audit row of the meeting and every protokoll export with its attached files (attachments in the transaction; the blobs are purged by ActiveStorage after the commit, so a rolled-back start deletes no file; a still-pending ProtokollExportJob is discarded on RecordNotFound). A meeting never returns to preparing, so all of them were made while preparing. Counts then also carries audit_log_entries and protokoll_exports (nil — left out of to_h — for a reset). Meeting#start! writes meeting.started after the purge, so it is the first audit row that remains. A manual «Nullstill» deliberately keeps both: the activity log of the rehearsals stays readable until the start.
The purge:
- deletes every vote and every participant snapshot, option tally and result snapshot (a leftover snapshot would make a re-opened poll serve the rehearsal’s frozen result);
- deletes every round after an item’s first poll (
created_at, thenid) — follow-up rounds and restarts. OnlyAgendaItem#create_default_pollandPollRoundBuildercreate polls, and the builder only builds from a closed round or a settled item, so a later round exists only because a vote was run. Left behind as a draft it was the item’s latest round, which hid the real round-1 result card and refused confirm / «Ny runde» (Poll#latest_for_agenda_item?); - deletes every quick-poll item (
quick_poll: true, no ordinary sub-items; a quick poll raised on another quick poll goes with it), throughAgendaItem#destroy_from_agenda!. Nothing is shifted: a quick poll takes no slot in either order (VOT-448), so the ordinary items keep their positions and numbers; - puts each first round back to
draftwithoutconfirmed_outcome,restarted,electedorwithdrawn; clearscompleted_at, the manual result,left_unfinished_atandunfilled_seatson every item and the meeting’s current item; removes bots and their users.
left_unfinished_at runs after it, so no item is flagged once the purge is done.
Every quick poll is a rehearsal item: a purge only runs while the meeting is preparing, and a meeting that has started never returns to preparation, so no quick poll in a preparing meeting can belong to a real run (VOT-433 dropped the old meeting.test_mode_toggled boundary). Rounds are always pruned to the first one, because a later round without its votes cannot be continued and would break the item. Known limits: settings edited on a rehearsal restart round are discarded (round 1 is editable again after the reset); a quick-poll item with sub-items is kept.
Simulated Participant
Bot participant used to rehearse a meeting (simulert deltaker): aMeetingParticipant with is_simulated: true and a bot-…@simulation.local user, created by SimulationService#create_participants and voted by SimulationService#cast_votes (through Vote.cast!; ineligible bots are skipped). Only while the meeting is preparing (VOT-433): a validation (:simulated_only_while_preparing) refuses creating one — or flagging an existing participant — in an active or closed meeting, and both service methods raise Meeting::NotPreparing. Excluded from tier participant limits, the CSV export and the projector participant grid (MeetingParticipant.real); removed with their users by «Nullstill» and by «Start møte».