> ## Documentation Index
> Fetch the complete documentation index at: https://skaum.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Domain Model Reference

> Full reference for domain terms used in code — Norwegian context in parentheses

All domain terms used in code. Norwegian context in parentheses. High-level one-line glosses are in `CLAUDE.md`; this page holds the full detail.

## Organization

Owner of data and subscription (organisasjon). Has `status` 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). Has `status` 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](#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](#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"`), scopes `attending` / `absent`. An absent participant is not stemmeberettiget: `voting_ineligibility_reason` returns `:absent`, `eligible_voters` leaves them out, so `Poll#eligible_voter_count`, `#did_not_vote_count` and the `eligible_voters` threshold denominator of any poll held while they are absent use the reduced count; `PollSnapshotService` records reason `absent`. 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?`, scopes `listed` (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) and `registered_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!` raise `RecordInvalid` when 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 rows `participant.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_unlocked` runs on every save (create and update, any column — a name-only edit saves the row first) and `ensure_removable` checks the same before a delete, reusing `Meeting#eligibility_locking_poll`; the error `participants_locked_open_poll` names 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_participant` reads only `listed` rows, 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 is `kept` instead (the preview says why), with an optional «Marker som fraværende» checkbox each: `apply!(csv, mark_absent_emails:)` (in full `apply!(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.

**Deleting (VOT-436, VOT-428 Q6)**: `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 via `parent_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](#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 a `withdrawn` 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](#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, with `status` 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 on `poll_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 via `platform_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_pages` returns the stored keys that are in `SecretaryNav::HIDEABLE_KEYS`, in catalogue order (anything else dropped); `secretary_pane_hidden?` is true only for a stored boolean `true`.
* Writer: `update_secretary_preferences(hidden_pages:, pane_hidden:)` replaces only the `secretary` key (a nil argument keeps the stored value) and returns false — nothing stored, the in-memory value restored, the reason in `errors` — 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 the `secretary` key only, with the same false-on-refusal contract.
* Validation runs only when `preferences` changes (`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) for `Current.user`; read by `SecretaryNav` from 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). Has `status` 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 through `has_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](/utvikling/arkitektur#access-levels-vot-449).

**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`):

| Column | Type / values | Default |
| - | - | - |
| `layout_preset` | enum `single`, `main_sidebar`, `grid_2x2`, `header_two_columns`, `three_columns`, `chair_podium` | `main_sidebar` |
| `sections` | JSON array of placed widgets (below) | `[]` |
| `theme` | enum `light`, `dark` — effective since VOT-446: the body's `data-theme` picks the light or the dark `--pj-*` token set (below); step 1's «Tema», overview badge «Mørkt tema» | `light` |
| `text_scale` | enum `normal`, `large`, `xlarge` | `normal` |
| `show_pause` | boolean — the screen shows the meeting's running pause (the pause region on a hall preset, a band on `chair_podium`); step 1's «Vis pause», overview badge «Uten pause» when off (VOT-445) | `true` |
| `show_message` | boolean — the screen shows the meeting's message band; step 1's «Vis beskjed», badge «Uten beskjed» when off (VOT-445) | `true` |
| `closed_state` | enum `record`, `own_content` (`prefix: true`: `closed_state_record?` / `closed_state_own_content?`) — what the primary slot shows once the meeting is closed: the record region, or the screen's own content (VOT-457, migration `20261012120000`, no backfill); step 2's «Når møtet er avsluttet» on the «Største felt» card («Vis vedtakene» / «Behold innholdet»), overview badge «Eget innhold etter møtet»; read through `keeps_own_content_once_closed?` — false on `chair_podium`, which every save stores as `record` (`ProjectorScreen.closed_state_choice?` / `closed_state_for`, a `before_validation`; the form object resolves the same) | `record` |

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`):

| Preset | Slots | Primary |
| - | - | - |
| `single` | `main` | `main` |
| `main_sidebar` | `main side1 side2 side3 side4` | `main` |
| `grid_2x2` | `a b c d` | `a` |
| `header_two_columns` | `header left right left2 right2` | `left` |
| `three_columns` | `left center right` | `center` |
| `chair_podium` | `hero rail1 rail2 rail3 cue` (VOT-453: the hero beside a rail of three, and `cue`, a full-width band below both) | `hero` |

**`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):

| Key | nb name | Shows | Options | Present when |
| - | - | - | - | - |
| `organization` | Organisasjon | the logo (or an initials tile), the organisation's name, the meeting's `display_name`, and «Planlagt …» (`meeting_planned_dates`) while the meeting is preparing and has dates | `show_planned_dates` (boolean, `true`) | always |
| `welcome_message` | Velkomstmelding | «Velkommen» and the meeting's welcome message (ActionText, sanitised; images capped at 20rem, and in a sized region at the space the message has left — an image that does not fit gives way whole, VOT-446 — captions hidden) | — | the message's plain text is not blank |
| `qr_join` | QR-kode for innlogging | the QR code of the join target, its address without the scheme in 3rem type (48 px at 1920×1080, × the text scale) at every size hint, and a hint sentence at `medium` / `large` / `full` | `url` (string, `""`, `max_bytes: 200`, `format: :http_url`) | always |
| `free_text` | Fritekst | its text as plain text (escaped, line breaks kept, no links) | `text` (string, `""`, `max_length: 500`) | the text has a non-whitespace character |

`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}`):

| Key | nb label | Preset | Sections |
| - | - | - | - |
| `standard` | Standard (Hovedskjerm) | `main_sidebar` | `main` → `active_poll` hall default (`display: hidden_until_reveal`, `reveal: on_confirm`, `show_votes_cast: true`); `side1` → `agenda { mode: window }`; `side2` → `next_item`; `side3` → `clock` |
| `poll_only` | Kun votering | `single` | `main` → `active_poll` hall default |
| `count_only` | Antall stemmer | `main_sidebar` | `main` → `active_poll { display: count_only, reveal: on_confirm }`; `side1` → agenda window |
| `live_results` | Løpende resultat (dirigentstøtte) | `main_sidebar` | `main` → `active_poll { display: live_bars, reveal: live }`; `side1` → agenda window |
| `remaining_voters` | Gjenstående stemmer | `main_sidebar` | `main` → `remaining_voters { show_numbers: true }`; `side1` → agenda window |
| `participant_grid` | Deltakergrid | `main_sidebar` | `main` → `participant_grid`; `side1` → agenda window |
| `welcome` | Velkomst | `header_two_columns` | `header` → `organization`; `left` → `welcome_message`; `left2` → `qr_join`; `right` → `agenda { mode: dense }` (VOT-452: the lobby for doors-open and breaks) |
| `chair_podium` | Dirigentskjerm | `chair_podium` | `hero` → `chair_state`; `rail1` → `knockout { show_heading: false }`; `rail2` → `clock`; `rail3` → `next_item`; `cue` → `next_action` (VOT-453: the chair's own screen at the podium; also sets `text_scale: large`, and names a new screen created without a name «Dirigentskjerm»; never created automatically) |

`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 answers `needs_progress_stream?`: the page subscribes to `[@meeting, "progress"]`.
* `privacy_exposing?` — some placed widget answers `privacy_sensitive?` («Personvernfølsom» in the screen overview, in the screen list and the secretary panel alike). Since VOT-455 `privacy_exposures` says which kind of exposure comes from which slot (below).

**The organiser UI (VOT-455).** The screen editor has two steps in one Turbo frame — «Oppsett» (name, preset, text size, the access level — «Tilgangsnivå», VOT-449, locked on «Møteledelsen» by `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 reads `name`, `layout_preset`, `text_scale`, `access_level` (VOT-449: a step-1 attribute; when none is posted a `chair_podium` layout gets `management`, 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 a `chair_podium` layout resolves `record`), `content_template` (a quick start), the carried `sections` JSON with `sections_preset` (the preset it was built for) and `regions[<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 public `ProjectorScreen#apply_template(key)`, the method the `before_validation` callback calls too, so a quick start and a direct `content_template` write 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` (`:layout` or `:content` — the step a refused save re-renders, 422), `errors_for(slot)` (the slot's `sections` errors 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_unsaved` builds `ProjectorScreen.new(meeting_id:, …)`, never `new(meeting:)` — `has_many_inversing` would append the unsaved screen to `meeting.projector_screens`.
* **Starters.** `ProjectorScreen::STARTERS` / `starter_attributes(preset)` (`{ layout_preset:, sections: }`, a fresh copy, no `text_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_poll` hall default, `remaining_voters`, agenda window; `three_columns`: agenda window, `active_poll` hall default, `next_item`.
* **Privacy (W7).** Each widget class answers `privacy_exposures(options)`, a subset of `ProjectorWidgets::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), and `privacy_possible?` (some enum / boolean combination of the schema exposes something; memoised per class) marks the picker's «Personvern» badge: `active_poll` (`live_bars` or `reveal: live`), `remaining_voters` (`show_numbers`), `participant_grid`, `knockout`, `chair_state`, `next_action`. `ProjectorScreen#privacy_exposures` maps 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 a `public` or `participants` screen the notice shows for every live tally or chair-support placement («Avgjort» included), on a `management` screen nothing. Never a validation.
* **`current_item`'s `show_description`** (boolean, default `true`, stored in `sections` — 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#preview` renders the screen the form object builds with `ProjectorPreview::Context` (`app/models/projector_preview/`): a `ProjectorContext` subclass 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 real `ChairState` (given rounds) and `MeetingRecord` derivations over sample objects. Six scenarios: `preparing`, `voting` (default; the thumbnails), `awaiting`, `revealed`, `closed` and `paused` (VOT-445: the `revealed` flow 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_READERS` pins the readers that come from the real context — `pause` and `message` are not among them: the sample answers them (nil but in `paused`), and `ProjectorPreview::MeetingView` answers 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.rb` renders every widget × option × scenario and fails on any query against the flow tables.

**Rendering (VOT-450).** `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 which `next_item` (the rule of `Meeting#upcoming_agenda_items.first`) and `last_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_at` for legacy polls), which is also `last_result_poll` («Siste resultat»); `vote_roll` — the open poll's item's `MeetingParticipant.vote_roll` (below), real participants only, in number order — with `voted_participant_ids` and `remaining_numbers`; `attendance` (`{ total:, present: }` in one aggregate query, the chair card's numbers); per poll `votes_cast` / `eligible_count` / `outstanding` / `results` / `percentage_denominator`, `decision_outcome` (`:partial` for «Delvis valgt» per `Poll#partial_election_result?`, else `Poll#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)`) and `knockout`; per item `elected_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), via `AgendaItem#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 → `live` iff `reveal: live`, else `collecting`; closed → `revealed` for `on_close` / `live`, and for `on_confirm` once the chair decided (`confirmed_outcome` set) or the item is settled (`AgendaItem#resolved?`), `awaiting_reveal` until then — exactly while `Poll#awaiting_decision?` (VOT-454); nil for a draft or no poll. A declined result (`muted?`) and a **provisional** one (`provisional?`: revealed under `on_close` / `live` before 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 at `rendered_at` (`Meeting::LivePause`) and its shown message (`Meeting::LiveMessage`), read from the loaded row (no query); `pause` is nil once closed, `message` is read in every state (VOT-457). VOT-454 adds `closed?`, `record` (the meeting's `MeetingRecord`, built only when a closed screen renders its record region, once per request) and `simulated_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` / `auto` tracks, stacks). Per slot, the first present widget of the section's chain fills it (fallbacks with their default options); the primary slot then tries `current_item` → `welcome_message` and otherwise is the idle region («Venter på neste sak…»), ignoring `when_empty` — since VOT-452 `welcome_message` renders, 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 with `when_empty: placeholder`, else it is removed and `Grid.collapse` hands its space to a neighbour so every surviving area stays a rectangle. The result (`Resolved`) carries the regions (area, widget, options, size hint `band` / `compact` / `medium` / `large` / `full`, placeholder, and `system` — `:idle` or, since VOT-454, `:closed_record` for a region no catalogue widget fills), the inline grid style and the bands (`[:preparing]` while preparing). VOT-445: while `context.pause` runs and the screen has `show_pause`, the primary slot of every preset but `chair_podium` is the **pause region** (`system: :pause`, `meetings/projector/widgets/_pause`: «Pause», the countdown, «Møtet fortsetter kl. …», the elapsed `progress` track; 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_podium` only, so the Dirigentskjerm's hero keeps the chair's state; never once closed) — `meetings/projector/bands/_<band>`. The closed branch wins over the pause.

**Meeting-flow widgets (VOT-451).** The meeting-flow renderers share their pieces: one poll heading (item, poll title, «Åpen» / «Avsluttet» pill, «Runde N», the voting-restriction line), one count line («N av M har stemt» or the key tally «N av M», with a `<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](#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 to `max(8px, min(100vw / 120, 100vh / 67.5))` — 16 px at 1920×1080, 12 px at 1440×900, never below 8 px — and every length in `projector.css` is in canvas units (rem), so a screen shows the 1920×1080 composition at any resolution, scaled. Browser zoom changes nothing; `text_scale` (`--pj-scale` 1 / 1.15 / 1.3) is the one way to enlarge the type. The type tiers and their floors are in the [Design System](/utvikling/design-system#projector-tokens-and-the-canvas-unit).
* **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](/utvikling/controllers-og-partials)). `ProjectorLayout::Region#sized?` marks a region that spans an `fr` row (`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_stack` gives 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.** `theme` picks the `--pj-*` token set through the body's `data-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 guard `ProjectorTheme.brand_accent` (see the Design System).
* **Language.** `Meetings::ProjectorController` skips `ApplicationController#set_locale` and wraps every page of a meeting whose slug resolves — `show`, the dead-link page, the two refusal pages — in `I18n.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>` is `ProjectorHelper#projector_html_lang`; the page's `<title>` is `projector_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 is `ProjectorHelper#projector_item_number` («Hurtig 3», from `AgendaItem#quick_poll_sequence` = `position + 1`, in the meeting's language); `display_number` / `quick_poll_number` stay «Hurtig N» on the admin views and in the protokoll. Percentages go through `projector_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».

**Legacy columns (dropped).** VOT-444 kept the three old display columns beside the layout model while the old views still read them. VOT-450 dropped them (migration `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; the `meetings.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`, then `id`) — follow-up rounds and restarts. Only `AgendaItem#create_default_poll` and `PollRoundBuilder` create 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), through `AgendaItem#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 `draft` without `confirmed_outcome`, `restarted`, `elected` or `withdrawn`; clears `completed_at`, the manual result, `left_unfinished_at` and `unfilled_seats` on every item and the meeting's current item; removes bots and their users.

It keeps ordinary agenda items (also ones added during the rehearsal), their texts and **secretary comments** (VOT-428 Q11), voting exclusions, real participants, projector screens, and — on a reset, not on the activation — protokoll exports and the audit log. Clearing the current item marks the item the meeting was on as "ikke ferdigbehandlet" (VOT-423 hook); the reset of `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): a `MeetingParticipant` 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».


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.