VxAdmin: Add support for qualified write-in candidates#8254
VxAdmin: Add support for qualified write-in candidates#8254
Conversation
a8441dc to
a81643b
Compare
There was a problem hiding this comment.
Pull request overview
Adds a “qualified write-in candidates” mode to VxAdmin, introducing a managed list of allowed write-in candidates and ensuring they appear in write-in summaries/reports (including zero tallies) while restricting ad-hoc candidate creation during adjudication.
Changes:
- Add a system setting (
areWriteInCandidatesQualified) and expose it in VxDesign system settings UI. - Add VxAdmin UI + API endpoints for managing qualified write-in candidates, and restrict write-in adjudication UI from creating new candidates in qualified mode.
- Update tabulation/summaries/reports to include qualified candidates with 0 tallies when qualified mode is enabled.
Reviewed changes
Copilot reviewed 28 out of 28 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| libs/ui/src/reports/write_in_adjudication_report.tsx | Plumbs showZeroTallyCandidates into the UI report. |
| libs/ui/src/reports/contest_write_in_summary_table.tsx | Conditionally includes 0-tally candidates in the summary table. |
| libs/ui/src/reports/contest_write_in_summary_table.test.tsx | Adds coverage for showing/hiding 0-tally candidates. |
| libs/types/src/system_settings.ts | Adds areWriteInCandidatesQualified to system settings schema. |
| apps/design/frontend/src/system_settings_screen.tsx | Adds checkbox to configure qualified write-in mode. |
| apps/design/frontend/src/system_settings_screen.test.tsx | Updates control count for the added checkbox. |
| apps/admin/frontend/src/screens/write_in_candidates_tab.tsx | New tab UI for managing qualified write-in candidates per contest. |
| apps/admin/frontend/src/screens/write_in_candidates_tab.test.tsx | Tests contest list behavior, edit flows, deletion modal, and validation. |
| apps/admin/frontend/src/screens/contest_adjudication_screen.tsx | Passes qualified-mode flag into write-in adjudication UI. |
| apps/admin/frontend/src/screens/contest_adjudication_screen.test.tsx | Adds test ensuring qualified mode prevents “press enter to add”. |
| apps/admin/frontend/src/screens/ballot_adjudication_screen.tsx | Sources qualified-mode flag from system settings and passes it down. |
| apps/admin/frontend/src/screens/adjudication_start_screen.tsx | Adds qualified-mode tab bar (Ballot Adjudication / Write-In Candidates). |
| apps/admin/frontend/src/screens/adjudication_start_screen.test.tsx | Updates setup to expect system settings fetch. |
| apps/admin/frontend/src/router_paths.ts | Adds route for /adjudication/candidates. |
| apps/admin/frontend/src/components/write_in_adjudication_button.tsx | Disables ad-hoc candidate creation in qualified mode + adjusts messaging. |
| apps/admin/frontend/src/components/navigation_screen.tsx | Adds style passthrough to MainContent for layout customization. |
| apps/admin/frontend/src/components/entity_list.tsx | Adds selected support (aria-selected + styling) and makes onHover optional. |
| apps/admin/frontend/src/components/app_routes.tsx | Adjusts routing so /adjudication/* goes through the start screen. |
| apps/admin/frontend/src/api.ts | Adds react-query hooks for get/update qualified write-in candidates. |
| apps/admin/backend/src/types.ts | Introduces QualifiedWriteInCandidateRecord type for management UI. |
| apps/admin/backend/src/tabulation/write_ins.ts | Ensures qualified candidates appear in results with 0 tallies. |
| apps/admin/backend/src/tabulation/write_ins.test.ts | Adds tests for qualified mode behavior pre/post adjudication. |
| apps/admin/backend/src/tabulation/full_results.ts | Loads qualified candidates (when enabled) during full tabulation. |
| apps/admin/backend/src/store.ts | Adds store queries + batch update/delete logic for qualified candidates. |
| apps/admin/backend/src/reports/write_in_adjudication_report.ts | Enables 0-tally candidate display in WIA report when qualified mode is on. |
| apps/admin/backend/src/app.ts | Adds API endpoints for qualified candidate management + logging. |
| apps/admin/backend/src/app.adjudication.test.ts | Adds integration coverage for qualified candidate management and deletion effects. |
| apps/admin/backend/src/adjudication.ts | Prevents auto-deleting write-in candidates when in qualified mode. |
b1f71b7 to
ccbfaf9
Compare
f257f53 to
13c7dd3
Compare
…ed write-in candidates
…adjudication_button when in qualified candidate mode
13c7dd3 to
493340e
Compare
| ); | ||
|
|
||
| // For each affected CVR, clear adjudicated votes for the contest and | ||
| // mark the CVR as not adjudicated |
There was a problem hiding this comment.
Do we really want to clear all of the other adjudicated votes for the contest? Or do we just want them to re-adjudicate the write-in? If I had done a bunch of other adjudication work for a contest, I don't think I'd want to have to redo it. I guess we don't really have a concept of partially adjudicating a contest currently though - is that why we're clearing all of it?
There was a problem hiding this comment.
Also, what happens if there are multiple write-ins adjudicated for the contest? Do we also need to clear their adjudications?
There was a problem hiding this comment.
It's a good question. The reason I cleared it is that we currently set a contestCvrTag as resolved depending on if adjudicatedVotes for the contest is undefined. So that is how I'm signaling this contest is not resolved. This could be changed if the flow checked write-in records directly to determine if a contest still requires adjudication.
The tradeoff I made was that I left write-in records adjudicated for the contest, so when a user does open up a contest to re-adjudicate the write-in, they have maintained the write-ins they've previously adjudicated for that contest.
Maybe that just makes things more confusing?
There was a problem hiding this comment.
It does seem a little weird to clear some of their work but not all of it, especially since the work that gets cleared is unrelated to the write-in they deleted.
This could be changed if the flow checked write-in records directly to determine if a contest still requires adjudication.
This sounds like an interesting direction to explore, but I can't immediately imagine what the consequences of that would be.
There was a problem hiding this comment.
Okay I think for now then I will clear other write-ins within the cvr-contest as well so the contest gets fully reset. Especially given this is a pretty rare case.
And then when I do the frontend refactor to make adjudication saves on the ballot level vs. the contest level, CvrContestTags might be dropped and then I can revisit changing how we determine if a contest still requires adjudication.
| </Section> | ||
| <NetworkSection /> | ||
| </Column> | ||
| )} |
There was a problem hiding this comment.
Seems a little weird that we need to load system settings again within this component when we've already branched based on the qualified write-in mode flag in the parent component.
Can this logic be pulled up into the parent?
There was a problem hiding this comment.
Two questions -
- Which parent are you referencing? Is it that
AdjudicationStartScreenfetches system settings and thenBallotAdjudicationScreenalso fetches them? - This brings up a point I've wondered about regarding query caching and could benefit from hearing your thinking. Since we cache queries, why is it better to pass query results from parents to children vs. just re-querying within the child?
There was a problem hiding this comment.
-
I was referring to
BallotAdjudicationContentand its parentAdjudicationStartScreen. I saw that in a later commit you changed it to pass the flag as a prop instead, but I would still offer similar feedback with that pattern. The main thing I'm trying to point out is that it's slightly leaky to have the parent component distinguish between the two different cases and then also have to distinguish the two cases within a shared child. -
I don't have a strong stance on the best way to do this in all cases. My general approach is that I try to match queries to testing boundaries so that I can write tests that mock API request/response patterns. If I pass in props to a component I'm testing, it often makes it harder to test how those props change in response to user actions that make API requests. Often this means queries at the level of screen components, with data passed via props internally.
The cache is just for performance, not correctness. If a component loads data from an API query, then it shouldn't assume that the cache has the data already. It might be tempting if that component is only used in one place and we know its parent already loaded that data, but it sets up a bit of a footgun if that component ever gets used elsewhere.
There was a problem hiding this comment.
-
I see, that makes sense. It did feel a little funny. The prop is passed because the child only renders the header if the tab bar is not there. That being said, I'll be dropping the tab bar in the follow up PR, so I'll probably just leave as is for now.
-
That's a helpful explanation, thanks.
Well, I guess I hadn't been assuming the data would already be there, but was thinking there was not a risk to having the component use the query, if it requires the data. Because if the parent already queried for it, great, it's in the cache. If the parent didn't, that's fine, because the component needs the data, so the query is required, whether it's passed from a parent or not. Not saying that's the right way to think, but that is the thinking that has made me feel unsure why it would be a footgun potentially (sorry if I misunderstood your point)
There was a problem hiding this comment.
Yes, good point. I guess I was thinking about a case where the child component doesn't handle the query loading state since it relies on the parent having already loaded the data - that would be the footgun. Not an issue here though. I agree it's fine if both components handle the loading state.
| <Container> | ||
| <ContestListContainer> | ||
| <EntityList.Box> | ||
| <EntityList.Items> |
There was a problem hiding this comment.
Might be nice to have a header with a title for this list - e.g. "Contests"
There was a problem hiding this comment.
I initially left it out because I thought it might be implicitly clear, but I can see the reasoning for including it. It looks a little funny in this quick mockup where I just added the header, but maybe there is a way to improve it.
I'm thinking I'll just move forward in this PR without it and then bring it up along with this comment at Software Design tomorrow to fix in a follow up.
There was a problem hiding this comment.
Discussed during Software Design Team that the tab bar should be dropped, will be done in a follow up PR that redesigns the adjudication start screen
| }; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Why do we need to implement this logic twice (once in getElectionWriteInSummary and once in modifyElectionResultsWithWriteInSummary? Is there a way we could implement it earlier in the pipeline so as to only do it once?
6411bd9 to
ffb97e1
Compare
…nd tab UX Backend: use ON DELETE SET NULL on the write_ins FK so removing a qualified candidate auto-resets its adjudicated rows to pending, dropping the hand-rolled cleanup and the adjudicated_at column. Frontend: derive new-vs-saved candidates from the query rather than a local isNew flag, decouple the start-edit and add-candidate flows, and surface a loading state on the empty-contest
ffb97e1 to
caf16c4
Compare
|
Validation error callouts - Updated callout when there are no contest qualified candidates (changed to neutral because of contrast issue) - |
…s in centralized location Disable the report's "insignificant write-in" bucketing in qualified mode (it was dropping 0-vote candidates whenever a winner had non-zero votes), and consolidate qualified-candidate injection into tabulateWriteInTallies as the single source of truth.
…d candidate is deleted
caf16c4 to
21d3427
Compare
jonahkagan
left a comment
There was a problem hiding this comment.
Looks good! Thanks for working through all that feedback. The commit that simplified where the 0 tallies were added was a particularly nice improvement

Overview
#7898
This PR adds a feature flag that determines whether write-in candidates must be qualified, meaning they are pre-input in VxAdmin before adjudication, and any write-in that doesn't match a qualified candidate will be adjudicated as invalid.
It adds an interface in VxAdmin to manage qualified write-in candidates. If a candidate is deleted after having an adjudicated vote, the write-in will be reset to pending, and the cvr-contest will have adjudicated votes deleted, with the cvr being marked as pending adjudication again.
It updates the contest adjudication UI to restrict adding new candidates via write in adjudication.
A follow up to this will be adding a UX improvement where write-ins for contests that have no qualified candidates are auto-adjudication as invalid.
Demo Video or Screenshot
Screen.Recording.2026-04-13.at.4.33.13.PM.mov
No contests with write-ins:

No qualified candidates for write-in in management interface (also contrived example when contests overflow):

No candidates match input during write-in adjudication:

Updated tally report showing all qualified candidates:

Testing Plan
Checklist