> ## 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.

# Architecture

> Technical overview of the application architecture

## Majestic Monolith

tilvotering.no is a standard Rails 8.1 monolith (the repository, container and volume names still say `voteringsapp`). All functionality lives in one deployable unit, using SQLite in all environments with the Solid trifecta for infrastructure:

| Concern | Solution |
| - | - |
| Database | SQLite3 (WAL mode, 5s busy timeout) |
| Background jobs | Solid Queue (database-backed) |
| Caching | Solid Cache (database-backed) |
| WebSockets | Solid Cable (database-backed Action Cable) |
| Asset pipeline | Propshaft + importmap-rails |
| CSS | Tailwind CSS v4 (custom design system) |
| Interactivity | Hotwire (Turbo + Stimulus) |
| Payments | Stripe (Checkout Sessions + webhooks) |
| Font | Inter, served locally (variable 100–900, Latin + Latin Extended subset; `app/assets/fonts`) |

## Domain Model

```
Organization (status: test | single_meeting | standard | pro | demo | active | suspended)
  ├── parent_id → Organization (self-referential, unlimited depth)
  ├── custom_field_definitions (JSON)
  └── Meeting (preparing → active ⇄ closed; never back to preparing)
        ├── MeetingParticipant (role: participant | chair | secretary | observer)
        │     └── custom_field_values (JSON)
        ├── AgendaItem (ordered by position, has rich text body)
        │     └── Poll (binary | multiple_choice; draft → open → closed)
        │           ├── PollOption
        │           └── Vote (one row per chosen option)
        └── current_agenda_item → AgendaItem (nullable FK)
```

Key relationships:

* An **Organization** can have a parent **Organization** and many child **Organizations** (unlimited nesting). Tree traversal: `ancestors`, `descendants`, `root`, `root?`, `depth`, and `effective_setting(key)` for a setting a child inherits from the nearest ancestor that sets it
* A **Meeting** belongs to an **Organization** and has many **AgendaItems** and **MeetingParticipants**. The state is the stored `status` column (`preparing` → `active` → `closed`, reopen `closed` → `active`), changed only by explicit transitions; optional `starts_on`/`ends_on`/`scheduled_at` are information only (VOT-432). `chair_can_vote` and `secretary_can_vote` booleans control voting eligibility for those roles
* A **Poll** belongs to an **AgendaItem** and has many **PollOptions** and **Votes**
* A **Vote** belongs to a **Poll**, a **PollOption**, and a **MeetingParticipant**
* Vote uniqueness is enforced at the database level by the composite unique index `(poll_id, meeting_participant_id, poll_option_id)`: a participant cannot choose the same option twice, and a multi-select poll holds one row per chosen option. The poll's `max_selections` caps the rows per participant (`Vote#within_max_selections`); see [Domain model](/utvikling/domenemodell) «Vote»

## Authentication

The app uses magic link authentication (passwordless). Users receive an email with a one-time login link. Sessions are stored in the `sessions` table with expiration and tracking metadata.

A session expires after 72 hours of inactivity (the `Session.active` scope); `last_active_at` is touched at most every 5 minutes. The session cookie is `httponly`, `same_site: :lax` and `secure: !Rails.env.local?`, so it travels over HTTPS only in deployed environments (VOT-184).

`Current.user` provides the authenticated user throughout the request lifecycle.

**Where a login lands.** `SessionsController#show` (the magic link) first honours a stored projector return path — `ProjectorReturnPath`: the screen a login-required or no-access page sent the viewer from, kept in the encrypted session for 30 minutes, used once, projector paths only (VOT-449); `#new` honours it too for a visitor who is already signed in. Otherwise `SessionsController#after_login_path` sends a user with exactly one open meeting — preparing or active, `allows?(:vote)` (VOT-437; closed meetings and «Feilregistrert» rows do not count) — to `meeting_path`, which redirects to their role's view; several open meetings → `meetings_path`. With none open, the chair of exactly one meeting still lands on it (the closed summary card, for instance); anyone else gets `meetings_path`.

## Role-Based Views

Each meeting has 5 views, served by namespaced controllers under `Meetings::`:

| Controller | Path | Access |
| - | - | - |
| `ParticipantController` | `/meetings/:slug/participant` | All participants + org members while preparing/active; once closed the read-only record (decisions, no vote buttons) for everyone (VOT-437; only decided outcomes and confirmed candidates, VOT-454) |
| `VoterController` | `/meetings/:slug/voter` | All participants + org members while preparing/active; once closed the read-only record (decisions, no vote buttons) for everyone (VOT-437; only decided outcomes and confirmed candidates, VOT-454) |
| `ChairController` | `/meetings/:slug/chair` | Chair only |
| `SecretaryController` | `/meetings/:slug/secretary` | Secretary + Chair; org admins without a role. Own layout since VOT-473: `layouts/secretary` (top bar, a rail of pages from `SecretaryNav`, a pane per page, the stage), see [Controllers and Partials](/utvikling/controllers-og-partials#the-secretary-shell-vot-473) |
| `ProjectorController` | `/meetings/:slug/projector` | Token; plus a login per the screen's `access_level` (VOT-449) |

The **Voter** view is a minimal, mobile-first view focused exclusively on casting votes. It uses a dedicated layout (`layout "voter"`) without the application navigation bar and without `_meeting_header`; its own minimal top bar links back to the participant view.

**Switching views.** A view-switcher dropdown sits in the top nav bar (`content_for(:view_switcher)`). Its list comes from `MeetingsHelper#meeting_view_switch`; `_meeting_header.html.erb` and the secretary top bar render it. Every participant, observers and org admins included, can reach the voter view through it (`meeting_voter_path`, label `meetings.nav.voter_view` → «Stem», VOT-370), so a plain participant sees two entries: participant and voter. The same header fills `content_for(:nav_title)` (meeting name and status badge: none while active, «Forberedelse» while preparing, «Avsluttet» once closed); without it the application layout shows the app's branding. The secretary view draws its name and badge on its own podium instead (VOT-473).

Visiting `GET /meetings/:slug` auto-redirects to the user's role view: participants and org members land on the voter view; chairs, secretaries and org admins on their management views. Meeting slugs are 6-character random alphanumeric identifiers (e.g., `a3kf9x`).

### Authorization

**Meeting authorization** is handled by the `MeetingAuthorization` concern (`app/controllers/concerns/meeting_authorization.rb`), which provides:

* `set_meeting` — finds the meeting by slug from params (`find_by!(slug:)`)
* `current_participant` — resolves the authenticated user's participant record (exposed as a helper method)
* `require_meeting_participant` — redirects if user is not a participant
* `require_chair` — restricts to the chair role
* `AgendaItemsController` uses both (VOT-184): `index` needs only `require_meeting_participant` (any participant or org member), every other action `require_secretary_or_chair` (secretary, chair or org admin)
* `require_secretary_or_chair` — restricts to secretary or chair
* `org_admin_for_meeting?` / `org_member_for_meeting?` — implicit access through the organisation: an org admin passes `require_secretary_or_chair`, an org member passes `require_meeting_participant`; `require_chair` stays explicit-only. `current_participant`, `current_meeting_role` and `org_admin_for_meeting?` are declared with `helper_method`, so views can call them. `current_participant` is nil for an org admin or member without a role and for a «Feilregistrert» participant (it reads only listed rows), so views use safe navigation
* `MeetingAccess` (`app/models/meeting_access.rb`, VOT-449) — the one participant / management test behind `current_participant`, `org_admin_for_meeting?` and `org_member_for_meeting?`, shared with the projector's `ProjectorAdmission` and `ProjectorStreamsChannel`
* `require_meeting_capability(capability, alert:, fallback:, status:)` — the one meeting-STATE guard (VOT-437; `status: :see_other` lands a refusal back inside a Turbo frame — the secretary's projector panel, VOT-449): refuses unless `@meeting.allows?(capability)` (the `MeetingCapabilities` matrix), for every role, with JSON 422 or a redirect + flash. `require_meeting_open_for_mutation` (`:drive`), `require_meeting_accepting_votes` (`:vote`) and `require_preparing_meeting` (`:rehearse`) delegate to it. Roles say *who* may act, the matrix says *when* — see [Domain model](/utvikling/domenemodell#permissions-matrix-and-the-closed-meeting-vot-437)
* `meeting_inactive_message` — the refusal text of `:vote` and `:drive` (`require_meeting_accepting_votes`, `require_meeting_open_for_mutation`), read from the stored state: `meetings.already_closed` once the meeting is closed, else `meetings.not_yet_open`. A closed meeting refuses a vote or a retraction with JSON 422 or this flash; the voter and participant views render the closed record instead (VOT-437), and `Vote` enforces the same rule in the model

**Organization authorization** is handled by the `OrganizationAuthorization` concern (`app/controllers/concerns/organization_authorization.rb`), which provides:

* `set_organization` — finds the organization from slug params
* `current_org_member` — resolves the authenticated user's membership record
* `org_admin?` — checks if user is admin of the org or any ancestor org (cascading access)
* `require_org_member` — redirects if user is not a member (also checks ancestor membership)
* `require_org_admin` — redirects if user is not an admin (also checks ancestor admin status)
* `require_advanced_features` — blocks the organisation settings pages for tiers without advanced features (only `demo` and `pro` have them); `require_non_demo_single` is kept as a backward-compatible alias
* `current_organization`, `current_org_member` and `org_admin?` are helper methods, so views can call them

**Chair-role escalation guard** (VOT-184). In `ParticipantManagement` (the secretary's Deltakere page, `create_participant` / `update_participant`) only a chair or an org admin may grant or revoke the `chair` role (`reject_unauthorized_chair_assignment`); a secretary manages the participant, observer and secretary roles. Since the VOT-436 security review the same holds on every path that takes the role away or gives it back: deleting a chair, withdrawing their voting right, «Feilregistrert» / «Angre» on a chair (`reject_unauthorized_chair_change`), a CSV import with a chair row and a CSV sync that adds, changes or removes a chair. The `last_chair?` guard is separate.

**Platform admin.** `Admin::BaseController` requires `platform_admin?` (a boolean on `User`; 403 otherwise) for the read-only `/admin` dashboard. Provision an admin with `ADMIN_EMAIL=x bin/rails admin:bootstrap`.

### Shared Partials

All role views share partials in `app/views/meetings/shared/`:

| Partial | Purpose |
| - | - |
| `_meeting_header` | Breadcrumb trail, title with status badge, uppercase tab navigation |
| `_voting_controls` | Vote buttons or "already voted" confirmation |
| `_agenda_list` | Numbered agenda items with optional current-item highlighting |
| `_poll_status` | Active poll indicator |
| `_poll_results` | Closed poll results with bar chart visualization |

Flash messages render app-wide from `shared/_flash.html.erb` as floating toasts that the `flash` Stimulus controller dismisses on its own. Vote privacy state that JavaScript manages sits under `data-turbo-permanent`, so a Turbo morph does not reset it.

## Design System

The UI uses an "Architectural Curator" design language — warm, structured, and confident — configured via a Tailwind v4 `@theme` block in `app/assets/tailwind/application.css`.

Key design tokens:

| Token | Value | Purpose |
| - | - | - |
| `primary` | `#2f6770` (teal) | Buttons, active states, accents |
| `secondary` | `#895213` (ochre) | Secondary actions, warm highlights |
| `surface-bright` | `#fef9f1` (warm cream) | Page backgrounds |
| `surface` | `#f5ead8` | Card backgrounds, elevated surfaces |
| `surface-dim` | `#e8dcc8` | Recessed areas, secondary surfaces |
| `on-surface` | `#1c1a15` (warm black) | Text, headings |
| `outline-variant` | `rgba(53,50,42,0.15)` | Borders and dividers |

Visual conventions:

* **Pill shapes for actions** — `radius-full` is `9999px`; buttons (`.btn-primary`, `.btn-ghost`) and badges use pill radii
* **Uppercase labels** — small, bold, wide-tracked text for headers, badges, and navigation
* **Warm palette** — cream surfaces, ochre accents, teal primary throughout
* **Layout themes** — `surface-bright` for the app layouts; the projector paints from its own `--pj-*` token layer, light or dark per screen (VOT-446, see [Design System](/utvikling/design-system#projector-tokens-and-the-canvas-unit))
* **Cards** — borderless `.s-card` with subtle elevation, used for all content sections
* **Progress bars** — teal fill (`.poll-bar-fill`) on warm gray track for poll results
* **Component classes** — `.btn-primary`, `.btn-ghost`, `.s-card`, `.s-chip` provide reusable styled components

Custom CSS classes (`custom-scrollbar`, `active-sidebar-item`) provide thin warm-toned scrollbars and the highlighted row of the Saksliste pane's tree in the secretary view. The secretary shell itself is styled by `.sv-*` rules in `app/assets/tailwind/secretary_shell.css` and the fixed podium tokens (VOT-473, see [Design System](/utvikling/design-system#secretary-shell-tokens)).

## Payments

**Tiers** (the organisation's `status`). `test` is free with 1 meeting and at most 10 participants; `single_meeting` is one meeting with a configurable participant count; `standard` is paid (100 participants per meeting); `pro` is premium (500 participants per meeting and the advanced features). `demo` (internal, unlimited) and `active` (legacy paid, unlimited) are legacy. Starting a meeting is one-way on every tier (VOT-428 Q16): a started `test` or `single_meeting` meeting can never be reset or reused, which the pricing page (`pages.pricing.single_use_*`) and the user guides say.

**Free signup.** The `/signup` form posts to `POST /signup/create_free`, which creates a `test` organisation, a first meeting with the user as chair, and sends a magic link. Simulated bots do not count against the participant limit.

**Paid signup** via Stripe Checkout:

1. The user fills in `/signup/paid?tier=X&billing=Y` (organisation name, contact name, email), which posts to `create_paid`
2. The app creates a Stripe Checkout Session with the form data in `metadata` and redirects to Stripe: subscription mode for `standard` / `pro`, a one-time payment for `single_meeting`
3. The Stripe webhook (`checkout.session.completed`) provisions the organization with the tier-specific status, creates or reuses the user, assigns admin role, auto-creates a first meeting with the buyer as chair (all paid tiers — `single_meeting`, `standard`, `pro` — via the shared `provision_first_meeting` helper in `StripeWebhooksController`, mirroring the free flow), and sends a magic link email. The meeting starts in `preparing`, and `after_login_path` lands the buyer (the chair of exactly one meeting) on it, where the chair view offers «Start møte» (VOT-382)

No local state is created until payment is confirmed — all signup data travels in the Stripe Checkout Session metadata. Idempotency is enforced via a unique index on `stripe_checkout_session_id`; a webhook replay creates neither a duplicate org nor a duplicate meeting.

**Webhook failures fail loudly** (VOT-368). If any provisioning step raises, `StripeWebhooksController#create` logs the error (`Rails.logger.error` with session id, tier and email), reports it with `Sentry.capture_exception`, and returns HTTP 500 so Stripe's automatic retry (about three days) kicks in — it never returns 200 on a partial provision. A bad signature or JSON returns 400; a missing `STRIPE_WEBHOOK_SECRET` raises (`ENV.fetch`, 500) rather than masking a bug. The `/signup/success` page carries a support recovery affordance (a `support@tilvotering.no` contact link and guidance) in case provisioning fails and no magic link arrives.

**Upgrade funnel** (VOT-365/366). Every upgrade CTA routes to the public pricing page (`pricing_path` → `pages#pricing`, `/priser`), which links into the paid signup flow; there is no `Organizations::UpgradesController`. Tier limits surface the path everywhere: the nav «Kjøp full tilgang» banner for test-tier users, the participant-limit state on the secretary's Deltakere page, the meeting-limit state on the organisation page, and the locked advanced-feature cards (wrapped in a pricing link by `OrganizationsHelper#locked_feature_card_wrapper`). The «Organisasjon» nav link and `organizations#index` include test-tier organisations, so free admins reach their organisation page.

**Billing portal and subscription lifecycle.** Org admins with a Stripe subscription reach the Stripe Customer Portal through `POST /organizations/:slug/billing_portal` (payment methods, invoices, cancellation). The `customer.subscription.updated` webhook syncs the tier from the Stripe price ID; `customer.subscription.deleted` downgrades the organisation to `test` and clears `stripe_subscription_id`.

## Time Zone

The app zone is Europe/Oslo (`config.time_zone`, VOT-425). Every time the app shows is Norwegian local time (CET in winter, CEST in summer): views (`l`, `strftime`, `to_fs` on Active Record times), «Planlagt …», the activity log, the protokoll covers and the date in protokoll file names. The database stores UTC, as before.

* Use `Time.current` / `Date.current`, not `Time.now` / `Date.today`, so "today" is the Oslo date.
* Machine-readable timestamps (protokoll hash and JSON export) are ISO 8601 with the Oslo offset for their date (`+02:00` / `+01:00`); before VOT-425 they ended in `Z`. See [Protokoll JSON schema](/utvikling/protokoll-json-schema#timestamps).
* The protokoll renderers format the hash's ISO strings with `ProtokollRendererSupport#display_time` and the `protokoll.time_format` locale key, so a hash with UTC strings shows the same local time.
* Recurring jobs (`config/recurring.yml`) must name their zone. Fugit reads a zone-less schedule in `ENV["TZ"]` or the app zone, so it would move with DST. Both current jobs say UTC.
* `scheduled_at` is the only time a user types (`datetime-local`), read in the app zone. Values typed before VOT-425 were read as UTC; a data migration re-read them as Oslo wall-clock time.

Per-organization time zones are out of scope.

## Vote Integrity

`Vote.cast!` uses `with_immediate_transaction` to prevent race conditions on concurrent vote submissions. Combined with the composite unique index on `(poll_id, meeting_participant_id, poll_option_id)` and the `max_selections` validation, this ensures a participant never picks the same option twice and never exceeds the poll's selection limit (one vote on a single-choice poll).

`Vote.retract!` allows a participant to retract their vote while a poll is still open, enabling re-voting. This also uses `with_immediate_transaction`.

## Rate Limiting

Rack::Attack (`config/initializers/rack_attack.rb`) throttles every request except the `/up` health check. The buckets are keyed so that a venue whose delegates share one NAT address is not locked out: signed-in traffic is counted **per session**, anonymous traffic **per IP**, a `public` projector screen **per screen**, and every request also counts against a per-IP backstop.

| Throttle | Keyed on | Limit | Counts |
| - | - | - | - |
| `req/ip/all` | IP | 100 000 / 5 min | every request, signed in or not, `/cable` included — the backstop |
| `req/ip` | IP | 5000 / 5 min | anonymous requests: no verified session cookie, not `/cable`, not a projector page refetch counted per screen |
| `req/session` | session | 1200 / 5 min | signed-in requests, not `/cable` |
| `votes/session` | session | 60 / min | casting and retracting a vote (a non-GET routed to `meetings/votes#…`) |
| `cable/session` | session | 120 / 5 min | `/cable` upgrades with a verified session cookie |
| `cable/ip` | IP | 1000 / 5 min | other `/cable` upgrades (neither a session nor a projector cookie) |
| `projector/screen` | projector cookie | 1200 / 5 min | GETs routed to `meetings/projector#show` with a verified `projector_screen` cookie and no session cookie (VOT-443) |
| `cable/projector` | projector cookie | 120 / 5 min | `/cable` upgrades with that cookie (VOT-443) |
| `magic_link/email` | normalised e-mail | 5 / 15 min | magic-link requests (a non-GET routed to `sessions#create`) |
| `magic_link/ip` | IP | 1000 / 15 min | the same requests |

* **Signed in = a verified signature.** A request counts as signed-in only when the `session_token` cookie's signature verifies — `Rack::Attack::Request#session_discriminator`: a signature check only, no database query, and the bucket key is a SHA-256 digest of the token, so the raw token never reaches the cache store. A forged or random cookie stays in the IP bucket. A signed cookie of a logged-out or expired session still verifies and still gets a session bucket, and one account can mint a session per magic link — which is why `req/ip/all` caps everything an address sends, whatever cookies it carries.
* **Projector screens.** Only when no session cookie verifies, `Rack::Attack::Request#projector_discriminator` keys a digest of the signature-verified `projector_screen` cookie payload (`ProjectorCableIdentity.discriminator`; never the URL token, never the raw cookie, which changes on every re-issue). `Rack::Attack::Request#projector_page_discriminator` (only for a request routed to the projector page) and `Rack::Attack::Request#projector_cable_discriminator` (only for `/cable`) are also the `req/ip` / `cable/ip` gates: a request leaves the IP bucket exactly when it is counted per screen, so a forged, expired or missing cookie stays in the IP bucket and only the projector route leaves it. A screen's first GET (no cookie yet) counts in `req/ip`; `req/ip/all` still counts everything. A restricted screen (`participants` / `management`, VOT-449) gets no projector cookie, so its page and cable traffic stay in the viewer's session buckets and its refusal pages in the IP buckets — see [Access levels](#access-levels-vot-449).
* **Routes, not paths.** Throttles match on `Rack::Attack::Request#routed_to` — `controller#action` as the router resolves the request — so `/session.json`, a trailing slash or another format suffix cannot dodge a throttle the app would still serve.
* **Magic links.** The same two limits are enforced in Rack::Attack (`magic_link/email`, `magic_link/ip`) and in `SessionsController` (`rate_limit` twice, keyed on the address `create` looks up), so the layers back each other up. The address is read the way Rails reads `params[:email]` (the query string wins over the body, JSON bodies are parsed) and normalised like `User#email`; only a plain string is ever looked up or keyed.
* **The throttled answer.** `ThrottledResponse` (in the initializer, the `throttled_responder`) gives a throttled HTML navigation — a GET or HEAD accepting `text/html`, not `/cable` — a small self-reloading page: a meta refresh after `Retry-After` plus 1–5 s of jitter, no script, in the default locale (no user is looked up this early; VOT-475: Norwegian only), locale keys `throttled.*`. A page Turbo loads itself, such as a refetch, thus recovers unattended. Everything else (JSON, `/cable`, form posts) keeps the plain-text 429. Both answers carry `Retry-After` (the seconds left in the wall-clock-aligned window) and `Cache-Control: no-store`.

Signup has its own `rate_limit` in `SignupsController`, unchanged by the buckets above.

## Content Security Policy

The policy is enabled and **enforcing** (VOT-363, `config/initializers/content_security_policy.rb`):

* `default-src 'self'`, `object-src 'none'`, `base-uri 'self'`, `connect-src 'self'`; fonts and images from `'self'`, `https:` and `data:`.
* `script-src 'self'` plus a per-request nonce. The importmap tags get the nonce automatically, and `script-src` is the **only** nonce directive.
* `style-src 'self' https: 'unsafe-inline'` with **no** nonce. About 17 views emit dynamic inline `style="..."` attributes (bar-chart widths, agenda indentation), which fall under `style-src-attr`, and a nonce does not cover attributes. A nonce next to `'unsafe-inline'` on the same directive makes the browser ignore `'unsafe-inline'`, so `style-src` stays out of the nonce directives.
* `frame-ancestors 'self'` (VOT-455: the organiser's preview iframes and thumbnails) — the same rule as Rails' `X-Frame-Options: SAMEORIGIN`, which stays. `frame-src 'self'`.
* `https://challenges.cloudflare.com` is allowed in `script-src` and `frame-src` for Turnstile (below); it is always present and harmless while Turnstile is off.

To switch to report-only, uncomment `content_security_policy_report_only` in the initializer.

## Error Monitoring

Sentry (`sentry-ruby` + `sentry-rails`, VOT-373), configured in `config/initializers/sentry.rb`. It is inert unless `SENTRY_DSN` is set, and `enabled_environments` is limited to `production` and `staging`, so development, test and un-provisioned deploys are unaffected. It covers unhandled web exceptions, ActiveJob / Solid Queue job failures (auto-instrumented) and the rescued Stripe webhook provisioning failures (an explicit `Sentry.capture_exception` in `StripeWebhooksController#create`). `send_default_pii = false`, and a `before_send` hook (`SentryScrubbing`) filters request params, cookies and extras whose keys match the same sensitive substrings as `filter_parameter_logging.rb`. `traces_sample_rate` is 0.1. `SENTRY_DSN` comes from Doppler (`prd` / `stg`) through Kamal's `env.secret`.

## Signup Bot Protection

Cloudflare Turnstile guards the free and the paid signup form (VOT-375). It is active only when **both** `TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET_KEY` are set; without them there is no widget and no verification. `app/models/turnstile.rb` renders and verifies; `SignupsController` (`before_action :verify_turnstile`) checks `cf-turnstile-response` against Cloudflare's siteverify endpoint (Net::HTTP, 5 s timeouts) and re-renders the form with a 422 on failure.

Failure policy: fail **closed** on a missing or rejected token, fail **open** on a Cloudflare outage or timeout (logged, and `Sentry.capture_message`), so an outage cannot block every signup. The signup `rate_limit` stays as defence in depth.

## Projector Real-Time Updates

The projector view subscribes to the meeting's Action Cable stream —
`projector_stream_from @projector_screen, @meeting, "updates"` in `meetings/projector/show.html.erb`,
through `ProjectorStreamsChannel` (VOT-449, see «Access levels» below) —
so every poll, agenda, and result change is pushed to the screen as it happens.
Screens with a placed widget that changes with every vote
(`ProjectorScreen#shows_vote_progress?`, derived from the widgets'
`needs_progress_stream?` — e.g. the votes-cast line of the «Hovedskjerm»,
`remaining_voters`, `participant_grid`) additionally subscribe to
`[@meeting, "progress"]`, the coalesced vote-progress stream (see below).
«Oppmøte» («N av M til stede») changes with the participant list, which is
broadcast on `updates`, so it needs no stream of its own. One page serves
every meeting state (VOT-454): once the meeting is closed it no longer
subscribes to `progress` (nothing can be voted).
Every projector page also subscribes to `[@meeting, "projector"]`, the stream
a screen change and a live control are sent on (see below).
The `projector` layout sets `turbo-refresh-method: morph` and
`turbo-refresh-scroll: preserve` so a refresh does not flash or lose scroll
position. There is no `<meta http-equiv="refresh">` polling.

Each projector screen is reached through a `has_secure_token :token` URL; its
`access_level` (VOT-449) decides whether the URL alone is enough. `ProjectorScreen#privacy_exposing?` is true when a placed
widget answers `privacy_sensitive?` — it reveals per-participant status or live
tallies before a poll closes (the `live_results`, `remaining_voters`,
`participant_grid` and `chair_podium` content templates); the screen overview
(the screen list and the secretary panel) badges such screens
«Personvernfølsom», the editor's quick starts mark those templates, the
picker badges every widget that can expose something «Personvern», the
editor's «Personvern» card names the exposing slots (VOT-455), and
`POST /meetings/:slug/projector_screens/:id/regenerate_token` («Ny lenke», in the
overview of both places) rotates a leaked link.

### Rendering pipeline (VOT-450)

The projector page renders only from the screen's `layout_preset` +
`sections` (the widget registry and the layout resolver; see
[ProjectorScreen](/utvikling/domenemodell#projectorscreen)) — in every meeting
state since VOT-454, which removed the separate closed page:

1. **Context.** `Meetings::ProjectorController#show` builds one
   `ProjectorContext` per request: the meeting data a widget may read, loaded
   lazily and memoized (nil answers too), so each piece costs at most one query
   and only when a placed widget asks for it — a clock-only screen loads no
   meeting data.
2. **Resolve.** `ProjectorLayout.resolve(screen, context)` walks the preset's
   slots in order. A slot is filled by the first widget of its section's chain
   (the widget, then its fallbacks with their default options) that has a
   renderer and is `present?`. The primary slot then tries `current_item` →
   `welcome_message` and otherwise becomes the idle region; it never collapses.
   Another empty slot is a placeholder region when its section says
   `when_empty: placeholder`, else it is removed. Once the meeting is closed
   the primary slot is the record region (`closed_record`, below) without
   asking its own chain — unless the screen keeps its own content (VOT-457:
   its own chain, then the welcome message, the record as the fallback);
   while a pause runs on a screen with `show_pause`,
   the primary slot of every preset but `chair_podium` is the pause region
   (`system: :pause`, VOT-445 — below) in the same way. The closed branch
   wins over the pause.
3. **Collapse.** `ProjectorLayout::Grid.collapse` removes the empty slots from
   the preset's base grid (`ProjectorLayout::GEOMETRY`) so every surviving area
   stays one rectangle and `grid-template-areas` stays valid: tracks made only
   of removed slots are dropped; each remaining removed slot hands its cells to
   a neighbour sharing its whole edge — its stack neighbour first
   (`main_sidebar` side1–4, `header_two_columns` left/left2 and right/right2,
   `chair_podium` rail1–3), then its row neighbour, then its column neighbour;
   identical adjacent tracks merge. All 61 empty-slot combinations of the six
   presets (sixteen of them `chair_podium`'s, with its `cue` band) are pinned
   by `test/models/projector_layout_grid_test.rb`. Each
   region gets a size hint (`band` / `compact` / `medium` / `large` / `full`)
   from its share of the `fr` tracks, which drives density (below).
4. **Render.** `meetings/projector/show.html.erb` adds the stream sources
   around the shared canvas partial `meetings/projector/_canvas` (VOT-455; the
   organiser's preview renders the same partial), which renders the bands
   (`meetings/projector/bands/_<band>`: `_preparing` while preparing — the
   projector-native «Prøvevotering» band since VOT-454 — then `_message` and,
   on `chair_podium`, `_pause`, VOT-445), then one
   `main.pj-grid` with the grid template inline (allowed by
   `style-src 'unsafe-inline'`) and one `section` per region carrying
   `data-area`, `data-widget` and `data-size-hint`, filled by the widget's
   partial `meetings/projector/widgets/_<key>` (or `_placeholder`). The canvas
   is styled by `app/assets/tailwind/projector.css` (`.pj-*` only, inside
   `@layer components`): exactly the viewport, no page scroll, cursor hidden,
   text scaled by `data-text-scale`. The clock ticks in the browser
   (`projector_clock_controller.js`, Europe/Oslo); nothing polls.

**Reveal states.** The tally widget (`active_poll`) shows the **poll on
display** — the open poll, else the meeting's newest closed poll, whatever item
is current, so a result stays until the next poll opens — in one of four states
(`ProjectorContext.reveal_state(poll, reveal)`, `data-reveal-state`):
`collecting` (open; «Votering pågår», the rule line, «Velg inntil N» for a
multi-seat election, optionally the count line «N av M har stemt» with a
progress track or `count_only`'s key tally «N av M» — never a per-option
count), `live` (open with `reveal: live`; bars as the votes come in),
`awaiting_reveal` (closed with `reveal: on_confirm` before the chair confirmed
or declined and before the item is settled; «Resultatet kunngjøres av
dirigenten») and `revealed` (the result; a declined one, or under `on_close` /
`live` one the chair has not acted on yet — `provisional?` — muted with «Ikke
bekreftet»). `last_result` («Siste resultat») shows the meeting's newest closed
poll by the same rule, also while another poll is open. No projector page
lists earlier closed polls (VOT-454 removed the closed page's list), and the
meeting's title reaches a screen only through the Organisasjon widget
(VOT-452, below), also once the meeting is closed.

### Meeting-flow widgets (VOT-451)

* **One decision rule.** `PollDecisionRule` holds the threshold and winner
  arithmetic `Poll#decision_result` / `#winning_options` used inline (extracted
  verbatim; `Poll` gathers the inputs as before). The projector's decision
  words come from `MeetingsHelper#poll_decision_label`, the same helper
  `poll_decision_badge` uses.
* **«Avgjort».** `PollKnockout` evaluates the open poll with that rule — the
  projected outcome and whether the outstanding votes could still change it:
  sound for every poll, exact for binary and one-seat polls, conservative for
  multi-seat polls, pure (no query; it reads the counts the live bars already
  loaded). The `knockout` widget is for the chair (`:chair_support`); the
  only template that places it is the Dirigentskjerm (below).
* **Context loaders.** `ProjectorContext` adds the agenda's running order
  (`next_item` / `last_item?` derived from it — one agenda load), the vote roll
  (`MeetingParticipant.vote_roll` of the open poll's item — one participant
  load for the grid and the remaining numbers), attendance (one aggregate
  query), the last result poll, and per poll the decision outcome, the
  election's elimination history (from the rounds' snapshots) and the
  knockout; per item the elected candidates — only those the chair confirmed,
  plus the latest round's winners once the item is settled, so no hall widget
  names a winner while the round awaits «Bekreft». All memoized.
* **Shared result.** `active_poll` (revealed) and `last_result` render one
  partial: decision pill, rule line with the leading option's margin, bars,
  «Valgt» / «Eliminert etter runde N» (no «Valgt» on a muted result), the
  item block once, the turnout line.
  Pills are projector markup (`projector_pill`), never the admin badges.
* **Density by size hint.** Server-side, the item introduction renders only at
  `large` / `full` and the rule, restriction and turnout lines and the
  earlier-eliminated list not at `compact` / `band`. Since VOT-446 every
  length is in canvas units: the projector layout's `html.pj-root` sets the
  root font size from the window (16 px at 1920×1080, 12 px at 1440×900), so
  the composition scales as a whole and the type tiers have floors; within a
  region, whole blocks give way in the partial's DOM order (`pj-fit` /
  `pj-fill` / `pj-lines` / `pj-stack`, CSS only) — nothing is cut mid-line.
  Each region is an inline-size container (`container: pj-region`), so the
  agenda flows into two columns in a region at least 75rem wide.
  `test/system/projector_layout_test.rb` measures the «Hovedskjerm» at
  1440×900 and 1920×1080 (the current agenda row and three more in view, no
  scrolling, pills ≥ 40 px × the canvas factor), and
  `test/system/projector_legibility_test.rb` renders ten surfaces at both
  sizes in both themes through the cut detector, the floors and the
  rendered-contrast check.

### Informational widgets (VOT-452)

Organisasjon (`organization`), Velkomstmelding (`welcome_message`), QR-kode for
innlogging (`qr_join`) and Fritekst (`free_text`) complete the catalogue: every
widget now has a renderer. Their data, all through `ProjectorContext`:

* **Organisation chain shared with the branding.** `organization` is the
  meeting's organisation — the record the projector layout's
  `projector_brand_style_tag` walks for `effective_branding` (VOT-446; it was
  `organization_style_tag` before) — so the logo lookup
  (`organization_logo_url`: the effective `logo_url`, https only, the meeting's
  own organisation first) reuses the loaded parents and Organisasjon costs no
  query beyond the layout's own (pinned against a clock in
  `test/integration/n1_hot_paths_test.rb`).
* **One rich-text load.** `welcome_message` reads `rich_text_welcome_message`
  once and keeps it only when its plain text is present; it never builds a
  record. Attachments render through ActionText's sanitiser and the app's
  `active_storage/blobs/_blob` partial, so an uploaded image is served by the
  app. An image embedded by its address (a Trix remote image) renders as an
  `<img>` pointing at its own host and is loaded from there (allowed by
  `img-src https:`) — the same holds for the item description (VOT-451).
* **Join target on the canonical host.** `join_url` is the section's own
  http(s) `url`, else the meeting link built with `CanonicalUrl.options` — the
  mailers' host (`https` under `force_ssl`), never the request's, so a phone
  that scans the code lands where its magic-link session lives.
* **QR generated in-process per request.** `QrCodeSvg` (`rqrcode`, level M,
  4-module quiet zone; ≈ 6 ms for a meeting link) and the view helper
  `QrCodeHelper#qr_code_svg` produce one inline `<svg>` with presentation
  attributes only — no `style`, no external reference, no CSP change — reused
  within the request (one per URL) and never cached across requests.
* **Logo fallback.** The logo `<img>` (`referrerpolicy="no-referrer"`: the
  projector URL carries the screen token) sits over an initials tile;
  `projector_logo_controller.js` switches to the tile when the image fails and
  cancels the morph's `data-logo-state` change, so a failed logo stays replaced
  after every refresh.
* **The implicit chain reaches the welcome message.** A primary slot with
  nothing to show tries `current_item`, then `welcome_message`, then becomes
  the idle region — so the «Hovedskjerm» shows the welcome message after
  «Start møte» until the first poll opens.

The «Velkomst» content template (`welcome`) composes the four on
`header_two_columns` (Organisasjon in the header band, the welcome message,
the QR code, the agenda `dense`); it subscribes to `updates` and `projector`
only. `test/system/projector_lobby_test.rb` measures it at 1440×900 and
1920×1080 (a square QR tile ≥ 160 px inside its region and a 48 px address —
both at the 1920×1080 reference canvas, × k at 1440×900 — the initials after a
failed logo and a refresh, the welcome image capped to its region).

### The Dirigentskjerm (VOT-453)

The `chair_podium` content template («Dirigentskjerm») mirrors the chair card
for the dirigent at the podium while an operator drives the chair view. It is
read-only, follows the same streams as any screen (`updates`, `projector`, and
`progress` because its widgets change with every vote) and is
never created automatically.

* **The chair mirror.** The two chair widgets, `chair_state` («Sakstilstand»,
  the hero) and `next_action` («Neste handling», the cue band), read the
  chair's own derivation: `ProjectorContext#chair_state` builds
  `ChairState.for(meeting, current_item: context.current_item)` lazily, at
  most once per request, and only when a placed widget asks — a hall screen
  never builds one (`n1_hot_paths_test.rb` counts `ChairState.new`: zero on
  the «Hovedskjerm», a knockout-only and a «Løpende resultat» screen, one on
  the Dirigentskjerm, also with «Sakstilstand» in two regions), and a closed
  meeting's Dirigentskjerm none (VOT-454). The widgets
  never derive state from polls or items; voting counts come from the
  context's per-poll loaders shared with «Avgjort», result figures from
  `ChairState`'s result helpers. `ProjectorContext#rendered_at` is the
  request's time, read once («Sist oppdatert»).
* **The cue contract.** `ChairState#primary_action` builds the label of the
  chair card's one primary button from the same `meetings.chair.*` keys and
  data as the chair partials; the chair view marks that button with
  `data-primary-action` (`MeetingsHelper#chair_primary_action_data`, exactly
  one per page) and the cue repeats it after «Neste:», with «eller: Ikke
  bekreft» where `decline_action` says the card offers it.
  `test/integration/chair_podium_contract_test.rb` renders both pages in
  twenty chair states and holds the key, the words, the decline line and the
  manual-result labels equal; `test/system/projector_chair_podium_test.rb`
  follows a live flow in two browser sessions.
* **The connection dot.** On the live projector page the
  `connection-status` controller sits on the `.pj-canvas` itself (one per
  page, as before), so the cue's dot is its `dot` target; the controller
  mirrors the dot's state into `data-connected`, which the projector
  stylesheet colours (only `pj-*` classes are allowed there) — the one
  colour source for that dot: the controller's `bg-green-500` /
  `bg-red-500` toggles stay for the voter view's dot and skip
  `.pj-connection-dot`. A closed meeting's screen is the same page with the
  same controller (VOT-454); the dead-link page has none.
* **Layout.** `chair_podium` is the hero (1.8 fr) beside a rail of three
  (`rail1`–`rail3`, 1 fr), above the `cue`: a full-width `auto` row with the
  `band` hint. An empty rail slot hands its rows to its stack neighbour; with
  the whole rail empty the grid is `"hero" "cue"` and the hero `full`. The
  template's large text (`text_scale: large`) fits 1440×900: a round titled
  like its item spends no line on its title, the result's decision pill sits
  in the heading, the rule sentence under the bars is whole or left out (the
  hero is a `.pj-fit` in priority order since VOT-446, which dropped
  `.pj-chair-fit`), and the cue keeps «Sist oppdatert» beside it. While the
  meeting is preparing the canvas gets an inset amber frame
  (`pj-canvas--rehearsal`, a `box-shadow` inside the canvas padding, so the
  grid does not move).
* **Query cost.** Measured on the N+1 guard's rich meeting, against the same
  screen with clocks in place of the two chair widgets: about 4 more queries
  while a round-1 poll is open («Avgjort» already loads the open round), 9 in
  the ready state and at an election's result, 11 at a binary result. The
  count does not grow with the meeting (3 against 30 closed polls on other
  items and 45 more agenda items give the same count while voting and at a
  result) and is paid only by Dirigentskjerm screens, at most once per
  refresh.

### The closed screen (VOT-454)

A closed meeting renders through the screen's own layout, in the same page,
body and canvas as while it runs; `Meetings::ProjectorController#show` has
one render path.

* **The record region.** `ProjectorLayout.resolve` makes the preset's primary
  slot the record region once `context.closed?` — unless the screen keeps
  its own content (VOT-457, `ProjectorScreen#keeps_own_content_once_closed?`:
  its own chain, then the welcome message, the record as the fallback; the
  two-thirds weighting only with the record) — a system region
  (`Region#system == :closed_record`, like the idle one, not a catalogue
  widget) whose own chain is not asked, so no tally or poll is loaded for it.
  `meetings/projector/widgets/_closed_record` renders it from
  `ProjectorContext#record` (the meeting's `MeetingRecord`, built once per
  request and only when a closed meeting is rendered): «Avsluttet», «Møtet er avsluttet», the
  closing line (with who ended the meeting only on `chair_podium`, the
  resolver's `show_actor` option), the counts and each item's outcome in the
  participant record's words and order — never a tally, a vote count or the
  meeting's title. On the Dirigentskjerm the hero is the record, the rail
  keeps the clock and the cue shows only «Sist oppdatert» and the dot; no
  `ChairState` is built.
* **Closed-state rules.** The other slots keep their sections:
  `ProjectorWidgets::Base#present?` asks `closed_content?` once the meeting is
  closed. The meeting-flow widgets that describe what happens now
  (`current_item`, `next_item`, `remaining_voters`, `participant_grid`,
  `knockout`, `chair_state`) are `hidden_once_closed` and answer without a
  query; `active_poll` / `last_result` show the newest closed poll only when it
  is revealed under their own `reveal` (`poll_on_display` ignores a poll left
  open); `agenda` drops the current row; `next_action` renders only its meta
  (`widgets/_cue_meta`).
* **Reveal-safe record.** `MeetingRecord`'s entries read
  `AgendaItem#announced_result` / `#announced_elected_candidates`: an item
  whose latest round awaits the chair (`Poll#awaiting_decision?`, the
  predicate of the `on_confirm` reveal) shows no decision, and only confirmed
  candidates are named — on the participant and voter records as on the
  projector. The secretary summary and the protokoll keep `winning_result`.
* **Streams and the reopen morph.** `progress` is not subscribed once closed;
  `updates` and `projector` are. «Start
  møte», «Avslutt møte» and «Gjenåpne møte» reach an open screen through the
  `updates` refresh: the refetched page morphs in place (same body classes and
  data attributes, the record region in or out, the `progress` source removed
  or added) — `test/system/projector_meeting_states_test.rb` drives the cycle
  on two anonymous screens without a reload.
* **Preloaded rounds.** The record loads every item with its rounds, options
  and snapshots, and `AgendaItem`'s outcome readers read fully preloaded
  rounds without a query (ties broken by `id` on both paths), so the closed
  screens' query count does not grow with the agenda (below).

### Cable identity without a login (VOT-443)

Action Cable connections were session-only, so an anonymous projector's stream
sources never connected. `Meetings::ProjectorController#show` now sets a signed,
`httponly`, `same_site: :lax` cookie `projector_screen` (`secure` outside
development/test, one-year expiry renewed on every render) whose payload is
`{ "id" => screen.id, "digest" => SHA256(token) }` — bound to the screen and its
CURRENT token without carrying the token. `ProjectorCableIdentity`
(`app/models/projector_cable_identity.rb`) owns the payload, the cookie options,
`allowed_for?` (read at issuance and at connect; since VOT-449 true exactly for
`public` screens), the database-free Rack::Attack discriminator, the
connect-time lookup and the signed screen reference of the projector stream
channel (`stream_reference` / `screen_for_reference`).

`ApplicationCable::Connection` is `identified_by :current_user, :projector_screen`:

* **An active session first.** An active session → `current_user`. Since
  VOT-449 a dead one (expired, idle > 72 h, logged out elsewhere) falls through
  to the projector cookie, so a logged-in projector on a `public` screen keeps
  updating when its session dies. A restricted screen has no cookie identity,
  so its page connects as its admitted user or not at all.
* **Otherwise the projector cookie.** A valid one (screen exists, digest matches
  the current token in constant time, `allowed_for?`) → `projector_screen`, with
  `current_user` nil. Rotation and deletion are revocation **at the next
  connect**; a live socket is not cut, it only ever receives bare refreshes and
  its next refetch lands on the dead-link page.
* **Only `ProjectorStreamsChannel`.** The channel every projector page uses
  (a `Turbo::StreamsChannel` subclass that also re-checks the screen — below).
  Plain `Turbo::StreamsChannel` refuses a connection without `current_user`
  (`config/initializers/turbo_streams_channel.rb`, VOT-449), so a signed stream
  name that once reached a projector page cannot be replayed past the screen
  check. Any future channel that needs a user must check `current_user`
  itself.

Rack::Attack gives the projector its own buckets, keyed on a digest of the
signature-verified payload (never the URL token): page refetches in
`projector/screen` (1200 / 5 min) and `/cable` upgrades in `cable/projector`
(120 / 5 min), both out of the venue's `req/ip` / `cable/ip`. The bucket key is
also the IP-bucket gate, so a request without a verified cookie stays in the IP
bucket; a screen's first GET (no cookie yet) counts there once. `req/ip/all`
still counts everything.

### Dead links

A missing, unknown, rotated or other-meeting token, or a deleted screen, gets an
explanatory `401` page (`meetings/projector/unavailable`,
`meetings.projector.unavailable.*`) in its own layout `layouts/projector_static`,
which carries **no script**: no importmap, no CSP nonce meta, no Stimulus
controller, no stream source. An open projector reaches the page through Turbo's
error renderer (a broadcast refresh, or `page_refetch.js` handing over the 401),
which re-activates every script of the new page under the new page's nonce — one
the loaded document's CSP header refuses. With no script there is nothing to
refuse. The page shows no meeting data, is HTML whatever the `Accept` header, and
neither sets nor deletes the projector cookie (another tab may still need it).
The organisation's colour may still arrive as a nonced `<style>`
(`organization_style_tag`), harmless under `style-src 'unsafe-inline'`, where
no nonce applies.

### Access levels (VOT-449)

Each screen has an `access_level` — `public`, `participants` or `management`
([ProjectorScreen](/utvikling/domenemodell#projectorscreen); every screen starts
at `management`, the Dirigentskjerm layout can be nothing else).
`Meetings::ProjectorController#show` checks, in order:

1. **The token.** A dead link gets `meetings/projector/unavailable` (401) first,
   whatever the level and the viewer — it never reveals a level.
2. **The level.** `public` renders for anyone without resuming a session and
   issues the `projector_screen` cookie. `participants` / `management` resume
   the session and ask `ProjectorAdmission.for(screen, user)`: an admitted
   viewer gets the same page (no cookie), an anonymous one
   `meetings/projector/login_required` (401), a signed-in one without access
   `meetings/projector/forbidden` (403).

The two refusal pages share `meetings/projector/_refusal_card`, live in the
script-free `layouts/projector_static` like the dead-link page, name the
screen's audience (`projector_screens.access_levels.<level>.audience`) — never
the meeting, the screen, its token or the user — reload themselves every 30–35 s
(`ProjectorAdmission.refusal_refresh_seconds`, a meta refresh), and store the
screen's path in the encrypted session (`ProjectorReturnPath`: 30 min, used
once, projector paths only), so the magic link opened in the same browser lands
on the screen (`SessionsController#show`, and `#new` for a visitor already
signed in). «Logg inn» is a plain GET to the login page; «Logg inn som en
annen bruker» posts the sign-out form, which keeps the stored path.

| | `public` screen | `participants` / `management` screen |
| - | - | - |
| Page | renders for anyone; no session resumed; cookie issued | session resumed; admitted → the page, no cookie; anonymous → `login_required` 401; without access → `forbidden` 403 |
| Cable connect | active session → `current_user`; no or dead session → the cookie → `projector_screen` | active session → `current_user`; its cookie (if one is left from a `public` past) is refused by `allowed_for?` |
| Each (re)subscribe | `ProjectorStreamsChannel`: any accepted connection | `ProjectorStreamsChannel`: only a `current_user` the level admits |

**`ProjectorStreamsChannel`** (`app/channels/projector_streams_channel.rb`, a
`Turbo::StreamsChannel` subclass) carries every projector page's stream
subscriptions, on every level, rendered by `ProjectorHelper#projector_stream_from`
with a signed screen reference (`data-screen`, `ProjectorCableIdentity.stream_reference`).
It confirms only when the signed stream name verifies, the reference names a
screen that still has its token, the stream belongs to that screen's meeting
and `ProjectorAdmission` admits the connection. It re-checks at every
(re)subscribe, so a rotated, deleted or tightened screen, or a viewer whose role
or session is gone, is refused at the next subscribe; a live subscription is
never cut. Payloads stay bare refreshes; no other view uses the channel.

**Refused cables (R1).** A tightening broadcast reaches an open projector as a
refresh, whose refetch lands on the refusal page. A projector that missed the
change only learns it when its cable reconnects and the server refuses the
connection or a subscription. The live canvas's `connection-status` controller
carries `recoverRefused` (`data-connection-status-recover-refused-value`, the
projector canvas only): on a refused connection or subscription it refetches
the page once per refusal episode (2 s settle), so the projector lands on the
dead-link, login-required or no-access page — or, when the refetch answers 200
and re-issues the cookie, recovers on the morph. `page_refetch.js` never
retries 401 / 403 and hands the refusal page to Turbo once; the pages carry no
script, so Turbo's error renderer is safe.

**Rack::Attack** is unchanged: a restricted screen gets no projector cookie, so
its page and cable traffic count in the viewer's session buckets (or, for the
refusal pages, the IP buckets). A laptop running the chair view and the
Dirigentskjerm stays within ≤ 600 of `req/session`'s 1200 per 5 min.

### Rendering limits

* **One closed poll per tally widget; no list.** A tally widget shows at most
  one closed poll — the poll on display or the last result — and reads its
  round number, final badge and eligible-voter count from loaded associations
  and the tally snapshot (`poll_result_snapshots.eligible_voter_count`), so 30
  closed polls cost the queries of 10, while the meeting runs and once it is
  closed (`test/integration/n1_hot_paths_test.rb`). Since VOT-454 no screen
  lists closed polls.
* **The closed screen does not grow with the agenda.** The record region and
  the participant and voter records read the preloaded `MeetingRecord`, so the
  «Hovedskjerm», an `on_close` poll screen, the Dirigentskjerm and «Velkomst»
  of a closed meeting with 50 agenda items cost the queries of one with 5
  (measured: 8 / 8 / 9 / 8 queries; the participant and voter pages 15 / 10).
* **Query count independent of the layout.** Every widget reads through the
  request's `ProjectorContext`, so placing a widget four times costs no more
  queries than placing it once; the Bullet guard renders every renderable
  widget in every preset with one poll open, with none and — VOT-454 — with
  the meeting closed, plus the option variants (VOT-451), and pins that `next_item` beside the agenda and
  `knockout` beside live bars add no query. Since VOT-452 the rich meeting
  also carries a welcome message with an image and an inherited https logo,
  and the informational widgets load no poll, agenda, participant, vote or
  rich-text data.
* **Any browser.** The projector is exempt from `allow_browser versions: :modern`
  (`allow_any_browser?`); its CSS needs Safari 16.4 / Chrome 111 / Firefox 128,
  and an older smart-TV browser renders unstyled rather than getting a 406.
* **No third-party requests but images.** Inter is served from the app's own
  assets, so a venue without internet access beyond the app still gets the
  design's type; the QR code is generated in the app. The only requests a
  screen may make elsewhere are for the organisation's https logo
  (Organisasjon; a logo that cannot load falls back to the initials) and for
  any image embedded by address in rich text (the welcome message, the item
  description). Browsers send only the origin as referrer
  (`Referrer-Policy: strict-origin-when-cross-origin`, the Rails default; the
  logo `<img>` sends none), so the projector token never leaks
  (`test/integration/content_security_policy_test.rb`).

### Organiser UI (VOT-455)

The screen editor and the screen overview reuse the projector's rendering
without its live parts.

* **Preview pipeline.** Setup params (the editor's state, or nothing for a
  saved screen) → `ProjectorScreenSetup` (the one translator to `sections`,
  the same object the save uses) → an unsaved screen (`build_unsaved`,
  `ProjectorScreen.new(meeting_id:)`) → `ProjectorPreview::Context` (the
  meeting's real identity, a fictional flow, one of six scenarios — the
  sixth, `paused`, shows a sample pause and message, never the meeting's
  real ones) →
  `ProjectorLayout.resolve` → the shared canvas partial
  `meetings/projector/_canvas` with `live: false`: no stream source, no
  connection-status controller, no `projector_screen` cookie, nothing written
  or broadcast, `Cache-Control: no-store`. `GET .../projector_screens/preview`
  (new screen) and `GET .../projector_screens/:id/preview` (saved screen)
  answer 200 whatever the params hold. The projector page renders the same
  canvas with `live: true` beside its stream sources (`updates` and
  `projector` always, `progress` when the screen needs it).
* **Editor transport.** Both steps live in one Turbo frame
  (`projector_screen_setup`): step navigation posts to `step` and renders
  200 (422 for an invalid step 1) in the frame, carrying the other step's
  values as hidden fields; the final save leaves the frame (`_top`).
  `projector_setup_controller.js` debounces form changes (600 ms) and
  replaces the preview iframe's document with a GET of the preview URL
  through `contentWindow.location.replace` — no session-history entry, so
  Back leaves the editor; the scenario select refreshes at once. Puma
  refuses a query string over 10 KB before Rails sees it, so the URL leaves
  out the hidden option fieldsets and carries each text option trimmed to
  `ProjectorPreview::TEXT_BYTES` (300) bytes of UTF-8 on a character
  boundary, plus `preview_truncated=1`, which shows «Forhåndsvisningen viser
  forkortet tekst» over the canvas; the server-built first `src`
  (`projector_preview_url`) applies the same rule, and the save (a POST)
  keeps every text whole. The «Personvern» card is read from a `template`
  in the preview response, so one request per change updates both. `projector_region_controller.js`
  shows the chosen widget's server-rendered option fieldset (the others
  disabled, so they are not posted) and edits the reserve stack.
* **Thumbnails.** Each row of the overview carries a lazy
  (`loading="lazy"`), CSS-scaled iframe of the screen's member preview in the
  default `voting` scenario — sample data, never the meeting's votes — out of
  the tab order and the accessibility tree. The overview itself costs the
  same for 2 and for 6 screens (badges, summary and QR code are computed
  in-process).
* **Framing.** The CSP sends `frame-ancestors 'self'` (the same rule as
  Rails' `X-Frame-Options: SAMEORIGIN`, which stays) beside the unchanged
  `frame-src 'self' https://challenges.cloudflare.com`, so only the app frames
  its previews. No inline script: both controllers come from the importmap.

## Meeting Broadcast Streams

All real-time updates are bare Turbo `refresh` broadcasts: the client refetches
its own page under its own authorisation, so no tallies or per-user content ever
travel over a shared stream.

| Stream | Sent on | Subscribers |
| - | - | - |
| `[@meeting, "updates"]` | Structural changes: poll opened/closed/new round, agenda, manual results, participants, meeting status (`broadcast_meeting_update`) | Every meeting view |
| `[@meeting, "progress"]` | Votes cast/retracted, simulated votes (`broadcast_vote_progress`) | Chair view; projector screens when `shows_vote_progress?` (a placed widget changes with every vote) and the meeting is not closed |
| `[@meeting, "projector"]` | A projector screen updated, deleted or given a new link (`broadcast_projector_update` in `ProjectorScreensController`); a pause started or ended, a message shown or cleared (`Meetings::LiveControlsController`, VOT-445; the message also after the close, VOT-457) | Projector pages, in every meeting state; and the organiser overview — the screen list and the secretary's projector panel — through the plain `Turbo::StreamsChannel` (VOT-445: the «Direktekontroller» card follows another organiser) |

Screen changes are never sent on `updates`: tweaking a projector must not
refetch every delegate's phone.

**Live controls (VOT-445).** A pause and a message are four columns on the
meeting (`MeetingLiveControls`); starting, ending, showing or clearing one
sends one bare refresh on `projector`, and each screen refetches and renders
it under its own access level (the pause region in a hall screen's primary
slot, the bands above the grid). The pause only while the meeting is
preparing or active; a message can also be shown or cleared after the close
(VOT-457) and reaches the closed screens through the same refresh — «Avslutt
møte» ends the pause and keeps the message. Nothing happens on the server when a pause
runs out — no job, no write, no broadcast. The countdown
(`pause_countdown_controller.js`) keeps its server-rendered text current
against the server's clock: the page carries the render time (`now`) and the
end, the controller takes the offset between `now` and the browser's clock
(so a projector PC whose clock is wrong still reaches 00:00 at the server's
end), and at 00:00 it asks `page_refetch` for ONE refetch of the page, 1–2 s
later (random, so a hall's screens do not hit the server together). The
server then renders the screen without the pause. A new render re-arms it;
inside the organiser's preview it stays static. The organiser overview's card
uses the same controller and refetches the screen list (or the secretary page)
the same way.

The organiser overview follows the `projector` stream too, so the card shows
another organiser's change without a reload. The secretary page's
`secretary_detail` frame carries `refresh="morph"`: a page refresh reloads the
open panel with morphing, and the card's input fields are
`data-turbo-permanent`, so what an organiser is typing survives; the
permanent-element clean-up in `application.js` leaves such a frame's content
to the frame's own render.

Turbo 8 treats a refresh as a page refresh — and morphs — only when its URL is
the one the page was last rendered at. A `secretary_detail` navigation that
advances the URL (an item, «Legg til sak», «Legg til undersak»; since VOT-473 a rail entry is a page visit)
renders only the frame, so Turbo still took the page as rendered at the old
URL, and the next refresh of the new URL — a broadcast or the visibility
refetch — replaced the whole body instead of morphing it (VOT-467 found it: it
emptied a half-filled «Ny sak» form). `application.js` therefore records the
window's URL as rendered (`Turbo.session.view.lastRenderedLocation`) on every
`turbo:frame-load` of `secretary_detail` and in the `replace_url` stream
action, so every stage-frame navigation now refreshes by morphing. This
writes a Turbo internal (as `page_refetch.js` reads `Turbo.navigator`); the
broadcast-mid-fill test in `test/system/secretary_question_form_refresh_test.rb`
(QF15.2) fails if a Turbo upgrade changes it. The «Ny sak» form adds three more
pieces: the custom stream action `replace_url` (same origin only, through
Turbo's own history) moves the URL to the new item after «Opprett sak», so the
next refresh reloads that item rather than an empty form; the form's
disclosures — the description and «Flere innstillinger», which holds
«Begrens» — are `data-turbo-permanent`, so a refresh never touches them and
they stay open without `data-keep-open`; and its nested `qf-rule-words` frame handles
«Content missing» itself (`question_form_controller#wordsMissing`), because the
shared `turbo:frame-missing` listener stays limited to `secretary_detail`.

Votes are deliberately **not** sent on `updates`: with 500 voters, a refresh per
vote made every client re-fetch its page on every vote. Vote progress (a vote
cast or retracted, `simulate_votes`) goes through
`MeetingBroadcasts#broadcast_vote_progress` →
`MeetingLiveBroadcast.schedule(meeting, :progress)`
(`app/services/meeting_live_broadcast.rb`), which coalesces across processes:
the first event in a window takes a `Rails.cache` flag (`unless_exist`; the key
names the meeting and the stream, and the cache is shared by the Puma workers)
and enqueues `MeetingLiveBroadcastJob` one second out; the job clears the flag
and then broadcasts. That yields at most about one progress refresh per second
per meeting, and the state after the last vote is always delivered — at most
about 1.6 s after it, with the Solid Queue dispatcher polling every 0.5 s
(`config/queue.yml`). The flag expires on its own after `delay + FLAG_GRACE`
(5 s for progress), so a lost job only delays progress. The
job retries a failed broadcast (4 attempts, 1 s apart; the flag is cleared
before the broadcast, so a job failing for good cannot wedge it), and
`schedule` enqueues anyway when its flag write is refused but no flag is
visible (Solid Cache swallows a failed write) — a refresh too many is harmless,
a missing one is not. The voter view re-fetches itself after its own vote or
retraction (`voter_controller.js#refreshPage`).

### Self-healing clients

Because a broadcast is only a signal, a client that was offline or asleep when
it went out would stay stale. Clients therefore recover missed broadcasts
themselves; no page polls.

`connection_status_controller.js` is mounted on every meeting view — voter,
participant, chair, secretary, projector and the closed record views; on the
live projector page it sits on the `.pj-canvas` itself (VOT-453), where it
mirrors its dot into `data-connected`. It re-fetches the page (a Turbo morph
refresh; `data-turbo-permanent` regions survive):

* once the cable connection is healthy again after it was re-established or
  may have lost messages, with 0–5 s of random jitter (0–1 s if the tab just
  became visible), so a deploy does not make every client fetch at once;
* about 1 s after the tab becomes visible while the connection is healthy —
  skipped while a form field has focus.

At most one refetch is pending, whichever trigger asked first, and a hidden
tab defers it to its next wake.

"May have lost messages" cannot be read from the `connected` attribute of
`<turbo-cable-stream-source>` alone: after a silent network stall Action
Cable's monitor reopens the connection and the client's `Connection#open`
detaches the old socket's handlers, so no subscription is told it was
disconnected and the attribute never goes away while broadcasts are lost. The
controller therefore marks the connection suspect on any of:

* a stream source losing its `connected` attribute (an ordinary drop) — this
  counts even before the connection as a whole was first seen healthy (a
  stream is often confirmed before the page's other subscriptions, so that
  can take up to a second after the page loads);
* a stream source being confirmed again while it already was connected (each
  `confirm_subscription` re-sets the attribute, which the controller sees as a
  mutation record; a second confirmation means the server-side subscription
  was created anew);
* the consumer's `connection.webSocket` object changing;
* no message at all for 10 s (`monitor.refreshedAt`; the server pings every
  3 s).

"Healthy" is: the socket open, a message received recently, no subscription
waiting for its confirmation, and every stream source connected. A stalled or
never-established connection also turns the connection dot red. The 1 s check
only compares timestamps and object identity — it sends nothing.

`application.js` cancels `turbo:before-morph-attribute` for `connected` on a
`<turbo-cable-stream-source>`: the server never renders that attribute, so a
morph would strip it from a stream that is still subscribed (it turned the
voter's connection dot red after any broadcast). Since VOT-447 the same
listener keeps `open` on a `details[data-keep-open]` — the screen overview's
«QR-kode» — so a QR code the organiser opened stays open through the
overview's morph refresh, on the screen list and in the secretary's panel.
Since VOT-473 the secretary's Deltakere page opts in its «Feilregistrert»
group too, so a broadcast or visibility refetch does not close a group the
secretary opened. (The own-write refetch after «Angre» or a mark leaves the
stage frame alone altogether: `secretary_shell_controller.js` marks it
`data-turbo-permanent` for that one morph, so Turbo neither morphs it nor
reloads its `src`.)

Typed values in the secretary's stage (VOT-474). A broadcast refresh and the
visibility refetch morph the page, and once a frame navigation has given the
stage frame a `src` they reload it with morphing; either way Idiomorph puts
the server's value over a typed one, focused or not. What is protected, and
how:

* **`data-keep-typed`** (`application.js`): a form or wrapper with this
  attribute and an id keeps everything it holds once the user has changed
  one of its fields (`input`, `change`, `trix-change`): a morph of it is
  cancelled (`turbo:before-morph-element`, same id in the new render) until a
  submission from it starts (its answer renders as usual) or the page is
  left; its removal is never blocked. Until then the server cannot change it
  either (a lock notice inside it appears with the next render after the
  save, which the server validates anyway). Marked: Møtedetaljer's settings
  form (title, short name, language, dates, voting rights, welcome message),
  the Prøvekjøring bot console, «Legg til deltaker» and the participant edit
  form, the CSV import and the CSV editor («Rå»), Saksoppsett's renumbering
  tables, the Protokoll export form and the secretary comment on a completed
  item. Pinned by `test/system/secretary_pages_test.rb` (the bot count kept
  through a broadcast, before and after the stage frame has a `src`).
* **Permanent fields**: the «Ny sak» / «Rediger» question form (keyed per
  form, VOT-467/468), the Deltakere search (VOT-473) and the «Direktekontroller»
  fields on Projektorskjermer (VOT-445).
* **A stage that shows a write's 200 answer** (the CSV sync preview, the
  renumbering preview): its frame `src` is the POST path, so a refresh used to
  reload it with a GET of a POST-only route and put «No route matches» on
  screen. `secretary_shell_controller.js` keeps such a stage
  `data-turbo-permanent` through every refresh morph until the frame navigates
  on.
* **Left as is**: Tilpass and the projector editor carry no `updates` stream
  and no connection dot (design A11), so nothing refreshes them but their own
  submit; the confirmation pages (withdraw voting rights, the previews' own
  confirm) hold no typed field; a form's 422 echo is not protected until the
  user changes a field again (a refresh before that shows the stored values).

Self-refetches — this controller, `voter_controller.js#refreshPage` after the
voter's own vote, the live projector's one refetch on a refused cable (R1,
above) and the pause countdown at 00:00 — go through
`app/javascript/page_refetch.js`. It requests the page itself and hands only a
usable answer to `Turbo.visit` as a preloaded response (same URL + "replace" =
a morph refresh), so a failed request never makes Turbo hard-reload onto an
error page: offline waits for the `online` event; a network error, 429, 408 or
5xx is retried in place with backoff (2 s doubling to 30 s, never before
`Retry-After`); a hidden tab's retry waits until it is visible; one request is
in flight at most and one retry pending. Every other answer is terminal and
handed to Turbo once — the projector's three refusal pages among them. Since
VOT-447 it also waits while a form submission of the page itself is in flight
(`Turbo.navigator.formSubmission` in state `requesting`, `waiting` or
`receiving` — e.g. «Vis på skjermene» on the «Direktekontroller» card):
`Turbo.visit` would stop that submission (`Navigator#stop`) and the
organiser's request would be lost. It checks before its own request and again
before handing over, sends nothing while it waits and goes again once the
submission has ended; frame submissions (the secretary's panel) are not
stopped by a page visit and are not waited for. `formSubmission.state` is
undocumented Turbo 8.0.x API, pinned by a browser test
(`test/system/projector_live_controls_test.rb`).

For pages Turbo loads itself, Rack::Attack answers a throttled HTML navigation
with a small localised page that reloads itself after `Retry-After` (meta
refresh, no script; see [Rate Limiting](#rate-limiting)); JSON, cable and form
posts keep the plain-text 429.

## Deployment

The app deploys via **Kamal 2** (Docker). There are two environments:

| Environment | Target | Config | Access |
| - | - | - | - |
| **Production** | VPS | `config/deploy.yml` | Public (DNS points straight at the VPS; kamal-proxy terminates TLS) |
| **Staging** | UGreen NAS | `config/deploy.staging.yml` | Private (via Tailscale) |

> [!WARNING]
>
> Production is not behind Cloudflare's proxy. Turning proxying on would make every request arrive from Cloudflare's addresses, so every per-IP throttle (Rack::Attack and `rate_limit`) would be shared by all users — add trusted-proxy handling for the real client IP first.

Secrets are managed via Doppler and injected at deploy time. Each environment has its own secrets file:

* `.kamal/secrets` — production (Doppler `prd` config)
* `.kamal/secrets.staging` — staging (Doppler `stg` config)

Production runs Puma in cluster mode with two workers (`WEB_CONCURRENCY: 2` in
`config/deploy.yml`, one per vCPU, about 235 MiB and 3 threads each); staging pins
`WEB_CONCURRENCY: 0` (single process) in `config/deploy.staging.yml`, because Kamal
merges destination config over the base. Puma reads the variable itself
(`config/puma.rb` has no `workers` call) and preloads the app when there is more
than one worker. The Solid Queue and Litestream Puma plugins hook the master's boot
event, so each runs once, not per worker.

**No in-process state may be used for coordination.** Throttling (Rack::Attack and
`rate_limit`), broadcast coalescing flags and cable messages all go through Solid
Cache / Solid Cable. Development's `:memory_store` cache and `async` cable adapter
are single-process only.

Staging has its own Rails environment (`config/environments/staging.rb`):
production-like, but with debug logging, deprecation reporting, the Mailtrap sandbox
for email and `db:seed` support. Like production it sets `config.assume_ssl` and
`config.force_ssl` (HTTPS only, HSTS, secure cookies), with the `/up` health check
excluded from the redirect (VOT-184).

The staging environment runs on port 8080 to avoid conflicting with nginx on the NAS. SQLite databases persist in `/volume1/docker/votering/` via a bind mount.

See [Development Setup](/utvikling/oppsett#staging-deployment-nas) for detailed staging deployment instructions.


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