Skip to content
All projects
Civic Tech2025

EVSPolls

Role-based election platform tallying results in real time as votes land.

evspolls.netlify.app

The problem

An election has to hold two things at once: every voter votes exactly once, and nobody can discover how any individual voted. They pull against each other: the first wants a record of who has voted, the second wants no record that can be joined to a choice.

Decisions

01

Secrecy is enforced at the API boundary

Election endpoints never return the roll of who voted. They return turnout, and whether the requesting user has voted. Per-candidate results are withheld until an election is active or completed, so a live tally cannot be scraped from a draft.

02

The lifecycle is driven by the election’s own dates

Draft, upcoming, active and completed are computed from the start and end times, with cancellation as the one manual transition. An election cannot be left open because somebody forgot to close it.

03

Three roles, separated by what they are for

Voter, admin and sysadmin. Maintenance mode and registration toggles belong to the last of them, because running the platform and running an election are different jobs.

The hard part

One ballot per voter, enforced by the database

The natural way to write this is read, check, write: fetch the election, confirm the voter is not on the roll, then record the vote. That leaves a window between the check and the write, and a contested election is precisely the situation where someone will go looking for one.

A ballot is instead recorded as a single conditional update. The filter carries every precondition (the election is open by date, this voter is not already on the roll), and the update adds the voter, increments turnout and increments that candidate’s counter together, in one atomic operation. Two simultaneous ballots cannot both read a stale document and overwrite each other: the second one matches nothing and writes nothing, and the caller is told so.

The correctness argument is the same one that makes the feature auditable. There is no sequence of requests, at any timing, that records two ballots for one voter. Not because the code checks carefully, but because there is no point at which a check and a write can be separated.

Two details fall out of writing it this way. The filter tests the dates, not the stored status, because a status field only moves when something writes to it. An election whose start time has passed can still be sitting at ‘upcoming’, and refusing that ballot would close a poll that is still open. So the filter accepts either state inside the date window, excludes draft and cancelled outright since no calendar should let those through, and promotes the status in the same write that records the vote.

The other is the error message. Atomicity usually costs you one: a filter that matches nothing cannot say which precondition failed. A null result therefore triggers a single re-read whose only job is to tell the voter whether they had already voted or the election was not open. That re-read is safe precisely because it decides nothing. The ballot was already accepted or refused by the write above it.