Complete Rust voting feature parity and production readiness
Goal Complete the Rust-backed voting replacement so every retained Kaukus voting workflow uses Rust as the sole voting store/runtime, historical results remain available, and the temporary unsupported states can be removed without restoring Rails ballot models, dual writes, or runtime fallback. Terminology - A voting run is exactly one voting round: the period from Start voting to End voting in the Rails UI. It is not a case session or a meeting session. - One case can contain many sequential voting runs, and one meeting can contain many cases and runs. Current baseline and repository findings - Follow-on to tsk_asbyjwgxba. The branch has normalized Rust run/typed-option/electorate/vote rows, signed direct case queries, natural idempotency, binary and ordered selection ballots, optional abstention, meeting-leader casting weight, Rails PollView rendering, and reload recovery after missed best-effort broadcasts. - The shared secretary selection UI now exposes proposal, point, mixed/two-option and choose-one-of-several-points starts. Politician voting and secretary results reuse the same typed poll view. - Remaining product gaps are bias outcomes; cancellation/reset; unanimous shortcuts; manual secretary votes; private access; public/big-screen results; complete reports/history/export; metrics and capacity certification. - Legacy Rails behavior also locked proposal/speaker input at start and supported public-result visibility plus editable title/summary. SourceReconciler currently handles proposal and point status but the wider lifecycle and reporting parity remain incomplete. - Existing reusable UI includes the shared voting-source checkbox component, proposal_selection_controller, PollView, VoteSummaryComponent, OngoingPollCardComponent, PollHeaderComponent and secretary poll components. Reporting::ProceedingsBuilder, VotingBuilder and VoteDisplay strategies already consume a poll-like interface. Bias/VotingRight services and Vault/BankID flows remain Rails-owned. - Legacy tables still contain historical polls but runtime models/associations are intentionally removed. Current public, big-screen, voting-report, DOCX and research-export paths are placeholders, so historical data needs a deliberate one-time migration rather than a Rails runtime reader. Electorate boundary decision - Rails remains authoritative for voting rights until that subsystem is ported to Rust. - Every time a voting run starts, Rails computes a fresh electorate from the then-current active VotingRight records and sends it with the eligible meeting-leader subset and source revision. Rust freezes that snapshot only for that run. - Voting rights may change between runs, including between multiple votes in the same case. The next run receives a new snapshot. - There are no changes to the electorate while an individual run is open, and therefore no mid-run synchronization commands, mutation callbacks, or background reconciliation. Rails operations that would change voting eligibility are blocked only until that run ends. - Rust may continue to identify this temporary electorate by Rails user ID. More than one active eligible right for the same user is invalid input and fails start; it is not a multi-seat feature to implement. - The later voting-rights port replaces the Rails electorate builder/source, not the per-run snapshot semantics. Implementation sequence 1. Clean up and consolidate the current Rust integration first: remove obsolete prototype/compatibility paths, stale unsupported placeholders and duplicated boundaries; simplify around the single Gateway/PollView/component path before adding more behavior. 2. Close canonical command, electorate and Rails lifecycle correctness gaps. 3. Restore the retained confidentiality/visibility/presentation behavior. 4. Complete reports, exports and historical migration. 5. Only after the preceding work is complete, productionize deployment, observability, backup/restore, capacity validation and the hard-cutover rehearsal. Proposed implementation 1. Keep the completed binary/selection typed ballot contract and shared source-selection UI as the single path for proposal and point voting. Do not add mode-specific persistence or parallel UI flows. 2. Restore secretary lifecycle operations as naturally idempotent Rust commands with one Rails reconciliation path: unanimous approve/reject, manual vote/change attribution, cancel, reset by superseding rather than silently deleting history, and casting-weight apply/reset. Reconcile proposal/point states, proposal/speaker locks and bias voting-right outcomes idempotently after command responses and direct reads. 3. Restore all authorized audiences with the shared poll view/components: politician voting and history, confidential meeting/case handling with existing Vault/BankID checks, public-result visibility, public-info results, observer live tallies and the big-screen view. Broadcasts stay best effort and audience-safe; direct Rust reads remain the recovery source. 4. Restore protocol/decision generation, interactive voting reports, DOCX export and research export from Rust snapshots. Add a bounded, restartable, verifiable one-time Rails-to-Rust historical importer with stable identities and explicit handling for orphaned/deleted legacy sources; Rust never queries Rails tables and there is no ongoing legacy fallback. 5. Add production readiness: safe additive migrations, backup/restore and rollout guidance, health/metrics/alerts with bounded labels and no voter data, representative capacity tests, and staged parity/cutover verification. Reuse and consolidation constraints - Extend VotingService/VotingStore/run transactions, signed Client/Gateway/Snapshot and PollView rather than introducing parallel APIs or persistence paths. - Use one typed option/result projection across secretary, politician, public, big-screen, reporting and export code. Extract shared ViewComponents wherever equivalent markup would otherwise be repeated. - Reuse the shared voting-source checkbox component, proposal_selection_controller, existing poll components, reporting builders/strategies, bias and VotingRight services, Vault decryptors and standard Turbo/flash patterns. - Keep Rails authoritative for authorization, voting-right source records, encrypted content and source lifecycle effects; keep Rust authoritative for each run's frozen electorate, choices, totals, resolution and voting history. Do not add post-start electorate synchronization. - Add UI copy through bin/translation in nb, nn, sme, pl, en and da. Follow the view/style guides and run RuboCop on changed Ruby. Acceptance criteria - All retained ballot modes and administrative operations work through Rust with correct ordering, abstention and tie semantics; one meeting cannot have concurrent open runs. - Every start-voting action captures the complete current Rails electorate and casting-voter subset for that run. Rust freezes it until that run ends; voting rights may then change before the next run, including within the same case. - Duplicate eligible user identities fail closed, and Rails prevents voting-right changes only while an individual run is open. - Start/close/cancel/reset/unanimous/bias flows reconcile Rails proposal/point/lock/voting-right state after retries, lost responses and reloads without duplicate effects. - Private/authorized, politician, public-info and big-screen screens enforce the existing visibility rules and recover from missed broadcasts by querying Rust. Outages fail closed. - Protocols, reports, DOCX and research exports use Rust state. Historical Rails polls are imported once with stable identities and reconciled counts/results; unresolved legacy rows are reported, never silently discarded. - Domain/contract, real PostgreSQL constraint/concurrency/rollback, Rails authorization/controller/component/reporting/migration and authenticated browser tests cover the retained behavior. Rust fmt/check/test/Clippy/docs, Rails specs, JS/CSS builds, RuboCop, translation validation, capacity checks and operational review pass. Secretary lifecycle decisions - Starting a run may lock proposals and speaker/reply input, but the existing Rails controls must still let the secretary reopen them between runs. A case can return to discussion and accept remaining proposals after an earlier vote, including a bias vote. - Cancelled and superseded runs remain immutable in secretary audit history and are excluded from ordinary/public results. - Unanimous decisions record an explicit outcome and must not fabricate individual voter ballots. - Secretary-entered manual votes carry attribution, and a later self-cast vote from that voter replaces the manual choice and clears the manual attribution. - Ties use the existing casting/double-vote behavior: an eligible meeting leader's actual ballot receives one additional weight. Do not add a separate arbitrary chair-selected result. Bias outcome scope decision - Bias uses the existing typed ballot path only as a single binary proposal run; mixed/selection ballots containing a bias proposal fail closed, and ordinary voting remains blocked while required bias proposals are unresolved. - Rust remains authoritative for the immutable electorate, votes, tie/casting calculation and result. Rails derives and validates the case-specific bias intent from the proposal/declaration and remains authoritative for voting-right chains, invitations and declarations. - A decided Rust result is applied through one transactional, durable and idempotent Rails reconciliation path. It preserves the municipality's immediate-versus-wait-for-all-bias-decisions setting, and every later Start voting action reconciles prior terminal runs before rebuilding the electorate. - Cancelling an open bias run and superseding a closed unresolved bias run are allowed and retain the pending bias intent. Once a bias result is decided, generic supersession and casting-vote reset are forbidden even if no later run is visible; reversing voting-right/declaration effects requires a separate explicit correction workflow. The restriction is enforced server-side and reflected in the secretary UI. - Reconciliation failure after a committed Rust command fails visibly but is recoverable from the direct Rust read. The voting-right changes and reconciliation receipt commit together, so reload/retry cannot duplicate chains, notifications or declaration effects. Product decisions still required - Decision: anonymous ballots are not a Kaukus feature and are out of scope. “Secret voting” means the existing confidentiality model: authorized private meetings/cases and per-run hidden public results; authorized secretary/history/reporting paths may retain voter-choice linkage. - Confirm the historical migration boundary and retention policy: all existing Rails poll rows versus a date/tenant cutoff, plus handling for orphaned source records. Implementation status - Integration cleanup is complete: the unused secretary JSON create/show MVP surface and proposal-only adapters are removed; typed starts use one PollsController/Gateway path; secretary voting failures share one Turbo/HTML boundary; dead PollView/Run compatibility methods and obsolete unsupported copy are gone; architecture guards cover the retained routes. - The per-run electorate boundary is implemented: each Start voting action rebuilds the electorate and casting-voter subset from current Rails voting rights; participant switches fail closed while a run is open and work after it closes; Rust preserves each run's original electorate. - The generalized ballot model is implemented: binary proposal/point and ordered typed selection ballots, optional `abstain`, n-way result/casting logic, typed relational persistence and one open run per meeting are covered by real PostgreSQL tests. - Point selection and voting are implemented in the shared UI: single-point, proposal-versus-point, point-versus-point and choose-one-of-several-points payloads are browser-tested; submitting a selected point starts the run and renders voter controls. - The non-bias secretary lifecycle is implemented: reopenable Rails locks, immutable cancelled/superseded audit states, explicit unanimous outcomes without fabricated ballots, manual vote attribution with self-cast override, and casting-weight-only tie resolution are covered by domain, real PostgreSQL, request, component and browser tests. - Binary bias outcomes are implemented through the shared Rust ballot path. Rails freezes declaration/role/deputy/chain intent before start, records only a narrow effect receipt, and transactionally applies decided Rust results as deputy activation, vacancy, or voter restoration/declaration cleanup. Immediate and wait-for-all settings, unanimous outcomes, cancellation/retry, unresolved supersession, post-decision correction guards, intent drift, rollback/idempotency, next-run electorate changes and an authenticated real-Rust browser flow are covered. Valid and transitional bias states no longer render the obsolete “voting mode unsupported” secretary message; regressions cover both pending and decided bias UI. Audience/privacy, reporting/history and release work remain open. - Bigscreen and public-page results are implemented from Rust snapshots with their legacy visual presentations. One Rails background poller per open run queries Rust every 500 ms and broadcasts both shared audiences only when the monotonic revision changes; vote requests and browsers do not poll or broadcast individually. Bigscreen reloads use authenticated reads. Public reloads use signed actor-free `public_reader` case reads only after Rails verifies municipality, committee, meeting and case visibility. Current master behavior for close-meeting system proposals, case-specific binary labels and committee-level suppression of incomplete live tallies is adapted to PollView without restoring Rails ballot models. Per-run hidden-result configuration remains open. Production-readiness audit findings - The current deployment is not runnable as configured: Kamal declares the Rust database/signing variables but neither production secret loader supplies them, and no dedicated Rust database provisioning is checked in. - The embedded loopback service is reachable from web containers only; Sidekiq containers cannot execute the 500 ms bigscreen/public polling job or future background report reads against `127.0.0.1:3202`. - Rust state is absent from backup/restore, dependency readiness, metrics/alerts and CI. The current CI does not run Cargo or real-service round trips; real-Rust browser examples are opt-in and otherwise skipped. - Interactive/DOCX/research reports and the historical importer remain absent. Existing protocol adapters need correctness work for inactive/pending/n-way runs before they are production-safe. - Confidential cases, hidden public results and editable title/summary remain absent. Anonymous ballots are explicitly out of scope; restore only the existing confidentiality and visibility behavior. - Run start and eligibility writers do not yet share one locked open-run check/snapshot boundary, and meeting end/reset/destroy plus source deletion can strand or orphan Rust state. - Canonical vote commands are serialized by arrival but carry no per-voter ordering/precondition. A delayed or replayed older self/manual command can overwrite newer voter intent or restore cleared manual attribution. - A hard-cutover rehearsal must define the rollback boundary: once a Rust-only vote is accepted, rolling back to the old Rails ballot runtime cannot preserve it without an explicit recovery procedure.
Checklist
9 of 12 complete- Generalize the Rust ballot model to binary voting over one typed proposal/point source and selection voting over two to 100 ordered typed sources. Support the internal choice name `abstain` for both families, disabled by default and presented with simpler localized UI wording. Support pointwise voting as sequential binary point runs and the legacy choose-one-of-several-points ballot without separate ballot variants, while retaining immutable per-run electorates and one open run per meeting.
- Implement and verify the Rails start-run boundary: every Start voting action computes a fresh electorate and casting-voter subset from current VotingRight records; two sequential runs in the same case may receive different electorates, while each Rust run remains immutable until End voting. Do not capture electorate at case/meeting start or add post-start synchronization.
- Implement and verify idempotent secretary lifecycle operations for manual votes with voter override, unanimous outcomes without fabricated ballots, open-run cancellation, closed-run supersession, casting-weight apply/reset, proposal/point reset/end state, audit visibility, and proposal/speaker locks that can be reopened between runs.
- Expose the typed ballot model in the existing secretary and politician UI: selectable points support single-point, proposal-versus-point, point-versus-point and choose-one-of-several-points starts; ordered typed source payloads, shared checkbox rendering, responsive selection state, voting controls and lifecycle refreshes work without duplicating proposal/point flows.
- Restore shared politician, confidential, public-info and big-screen presentation with correct visibility, audience-safe best-effort broadcasts and reload recovery.
- Enable only valid single-proposal binary bias runs through the shared Rust ballot path; validate and freeze the Rails bias intent; reconcile decided Rust outcomes into Rails voting-right/declaration state through one transactional durable receipt, honoring immediate and wait-for-all settings; reconcile before every later run so its electorate is rebuilt; allow cancellation/unresolved supersession but reject supersession or casting reset after a bias result is decided; verify lost-response/reload retries, duplicate reconciliation, ties/unanimous outcomes, replacement/vacancy/rejection chains, delayed batches, and subsequent electorates.
- Restore protocol, interactive report, DOCX and research exports from Rust and complete a restartable verified migration of the agreed legacy voting history.
- Pass Rust/PostgreSQL concurrency and constraint tests, Rails/JS/reporting/migration regressions, authenticated browser parity/recovery checks, translations, documentation, observability and representative capacity/release gates.
- Ensure secretary close/cancel refreshes Rust run discovery at the mutation boundary so a stale request-local list cache cannot report `no_active_voting`; cover the failed-first-attempt regression.
- Prevent proposal lifecycle callbacks from broadcasting transient unavailable voting controls before a terminal Rust bias outcome is reconciled; retain normal proposal broadcasts and genuine outage warnings.
- Restore the exact legacy bigscreen voting presentation from Rust snapshots, with one 500 ms server-side revision poller per open run, coalesced broadcasts, terminal shutdown, authenticated reload recovery, and no per-vote or browser polling.
- Restore public-page ongoing and completed voting from Rust snapshots, including signed anonymous public-reader scope, legacy public poll cards, visibility-safe live broadcasts through the existing 500 ms revision poller, terminal lifecycle updates, outage handling, and reload recovery.
- Area
- Kaukus
- Kind
- Feature
- Priority
- High
- Task ID
tsk_86ysxcpr3h- Updated