Fix stale and missing participant vote controls
Investigate and fix regressions in the Rust-backed live voting UI where some eligible participants do not receive vote buttons, receive an obsolete poll/selection state, or cannot vote until reloading. Scope - Trace start, cast/change, close/reset/cancel and sequential-run updates from authoritative Rust state through Rails reads, personalized rendering and Turbo broadcasts. - Reproduce races and stale-state paths, including missed/out-of-order broadcasts, per-request memoization, personalized voter state, frame targeting, reconnect/navigation, and multiple participants. - Fix the shared synchronization path rather than adding per-screen workarounds or reintroducing Rails voting persistence/fallback. - Preserve authorization and privacy: each participant must receive only correctly personalized controls/state, and ineligible users must fail closed. Acceptance criteria - Every eligible connected participant receives usable controls for the current open run without reloading after start and after transitions from a previous run. - Cast/change responses and broadcasts cannot restore an older revision, wrong selected choice, closed run, or prior run; stale/out-of-order updates are ignored or repaired from a direct authoritative read. - Reload/reconnect remains a recovery path, not a requirement for normal voting. - Regression coverage exercises multiple participants, sequential runs, missed or reordered updates, and relevant failure/recovery boundaries. - Focused Rails/Rust tests, appropriate browser acceptance, assets, changed-Ruby RuboCop, and Rust checks where Rust changes are made pass. Confirmed root cause - Voting lifecycle messages replaced the participant frame with an empty lazy frame and then performed a separate authoritative GET. This created a render-before-subscribe gap for newly connected clients and overlapping frame requests around close/start transitions. Updates carried no run/revision ordering, so a delayed old response could replace newer controls. - Stale forms returned an empty 409 that Turbo did not render. The first attempted fix added focus/visibility refetches, multiplying the same racing GETs, and changed ordinary vote responses into empty-frame refetches that visibly flashed. - The remaining vote-loss bug was in the command boundary: a submitted form named an exact Rust run, but Rails ignored that identity and rediscovered an active run through Rails current-case plus Rust list. Production logs captured Rust still holding the run open while this indirect lookup returned nil; Rails therefore skipped the vote command and cleared the frame. Vote submission now queries the exact run ID, validates its meeting/case mapping and open status, authorizes that poll, and only then commands Rust. Verified outcome - Lifecycle transitions now push personalized frame HTML built directly from the committed Rust snapshot on each electorate user's already-inline stream. Ordinary vote and stale-run responses also render state directly and atomically; they do not insert an empty frame or perform a follow-up GET. - Every frame carries run start/revision metadata. The browser rejects an older revision of the same run and any older run that arrives after a newer run. - The inline voting stream performs exactly one authoritative reconciliation read when it initially connects or reconnects, closing the snapshot/subscription gap. Focus, visibility and online events perform no voting requests. - Three-session Capybara coverage uses one secretary and two voters across sequential runs, deliberate stream loss, stale-form self-repair, injected out-of-order run HTML and reconnect recovery. It asserts one reconnect read, zero focus reads and no lazy frame after ordinary voting. - The same two-voter/sequential-run system flow passes against the actual Rust service through bin/kaukus round-trip. - Focused voting suite: 101 examples, 0 failures, 5 expected external-service pending examples. Regression coverage explicitly proves voting succeeds when list-based active discovery is empty but the exact Rust run remains open. The isolated real-Rust system run: 2 examples, 0 failures. JS build, Zeitwerk, changed-Ruby RuboCop and whitespace checks pass. Rust source was unchanged. - Repeated secretary manual-vote changes exposed a separate Turbo frame lifecycle bug: the first response replaced `#modal` with an empty string, deleting the target frame. The next modal action then fell back to the enclosing `voting_run_<id>` frame and raised TurboFrameMissingError. Modal-closing streams now replace the target with an empty `turbo-frame#modal`; repeated manual changes pass in both stubbed and real-Rust multi-session system tests.
Checklist
3 of 3 complete- Reproduce stale/missing controls and identify the authoritative synchronization failure
- Implement shared fix with regression coverage for multiple participants and sequential runs
- Verify focused tests, browser behavior, assets, RuboCop, and applicable Rust checks
- Area
- Kaukus
- Kind
- Bug
- Priority
- High
- Task ID
tsk_97d9xqcpqw- Updated