Skip to main content

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:

Domain Model

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 «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::: 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
  • 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/: 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: 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)
  • 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).

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.
  • 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.
  • 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.
  • 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) — 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. 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; 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. 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. 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); JSON, cable and form posts keep the plain-text 429.

Deployment

The app deploys via Kamal 2 (Docker). There are two environments:
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 for detailed staging deployment instructions.
Last modified on October 21, 2026