Skip to content

Creating an election

How to get a test election onto production, what the wizard writes that you didn't choose, and the two defects the quick path hits.

Three routes, in order of how much you control:

Route Login Use it when
Web wizard, Publish Now none You need a public ballot URL in under a minute and will never administer it
Web wizard, See more options none You need voter restrictions, a support email, or a draft to edit
API cookies you mint You need a reproducible fixture, or an owner role you can actually use — see bv-api-checks.md

Everything below was run against production on 2026-08-03 and captured as jd78xd.

The web wizard

https://bettervoting.com/new_election is a real route — the URL sticks. It renders the homepage layout with the wizard inlined roughly 750 px down and does not scroll to it, so a link to /new_election still lands the reader above the fold on marketing copy. Worth knowing before citing the URL as a call to action.

Steps, in the order the wizard presents them:

  1. Which term best describes your situation? — Election / Poll. Writes settings.term_type; wording only.
  2. How many races? — Just one / More than one.
  3. Elected Office Title, plus an optional Description behind a + Description disclosure. See the description defect below.
  4. Candidates — rows auto-append as you fill the last one; trailing blanks are dropped on save.
  5. Voting method — Single-Winner / Basic Multi-Winner / Proportional Multi-Winner, then the winner count → Next.
  6. Which Voting Method? — STAR, Ranked Robin, Approval, then Plurality and IRV behind More Options → Next.
  7. A Publish? modal: "Would you like to publish your simple poll now or continue to customize further?" — SEE MORE OPTIONS / PUBLISH NOW.

PUBLISH NOW skips step 8 — the Just a few more questions… panel holding Restricted? (pre-defined voter list) and Election Support Email. Those fields take their defaults silently. The election goes straight to state: open; there is no draft to review.

Automation note. The Publish? modal is a MUI dialog that covers the page. Clicks aimed at the form underneath land on .MuiDialog-container and are swallowed with no error. If a scripted run stops responding at step 7, that's why.

What the wizard writes that you never chose

curl -s https://bettervoting.com/API/Election/jd78xd | jq '.election.settings':

{
  "voter_access": "open",
  "voter_authentication": { "voter_id": true },
  "ballot_updates": false,
  "public_results": true,
  "random_candidate_order": true,
  "require_instruction_confirmation": false,
  "draggable_ballot": false,
  "term_type": "election"
}

Three of these are decisions the creator was never shown:

  • public_results: true — this is the flag that makes /anonymizedBallots readable by anyone, in any state. The wizard never surfaces the choice. This is the concrete basis for open question Q4; see the warning in bv-api-checks.md.
  • voter_authentication.voter_id: true — set even on a fully open election reached by a bare link.
  • random_candidate_order: true — ballot order is shuffled per voter. jd78xd was authored Coffee, Tea, Water, Hot Chocolate, Sparkling Water and rendered Tea, Sparkling Water, Hot Chocolate, Coffee, Water. Never assert on ballot row order in a UI test unless you first assert this flag is false.

contact_email is absent from the object entirely when left blank, rather than present as "" — so read it with .contact_email // "", not by key presence.

⚠️ Publish Now produces an election nobody can administer

jd78xd was created through the wizard while signed out. Five minutes later, in the same browser, carrying both guest cookies (temp_id and jd78xd_claim_key were present in document.cookie, neither HttpOnly), a same-origin fetch with credentials: 'include' returns:

{ "authorized_voter": true, "has_voted": false, "roles": [], "permissions": [] }

No owner role. Well inside the 10-hour TEMPORARY_ACCESS_HOURS window. The reason is visible on the election object: owner_id is null.

The guest-owner grant fails — but not on the v- convention, which is worth stating precisely, because bv-api-checks.md condition 1 is easy to misread here:

// elections.controllers.ts:85-92
const ownerIsTempUser = !req.election.owner_id || req.election.owner_id.startsWith('v-');
const tempUserAuth =
    ownerIsTempUser &&
    req.election.owner_id == req.cookies.temp_id &&
    hoursSinceCreate < sharedConfig.TEMPORARY_ACCESS_HOURS &&
    hashString(req.cookies[`${req.election.election_id}_claim_key`]) === req.election.claim_key_hash;

null passes ownerIsTempUser — the !req.election.owner_id disjunct admits it. It fails on the next line: owner_id == cookies.temp_id compares null against the browser's v-… temp id. So the grant never fires, and since canClaimElection comes from the same role, signing in can't rescue it. The wizard did write a claim_key_hash; it's the owner_id half that's missing.

owner_id is not stripped from the anonymous view in general — it comes back populated on other elections read the same way. So null here reads as genuinely unset rather than filtered.

This contradicts bv-api-checks.md, which currently states that the web wizard always sets both and that "hand-rolled API calls are the only way to produce an orphan." The normal UI produced one, and the practical consequence is the same as the mj26yj / vgwvjr rows in that table: state: open forever, ballots accepted forever, no way to close it.

Root cause

packages/frontend/src/components/ElectionForm/Wizard/Wizard.tsx, on main. The quick path passes owner_id: null explicitly:

// Wizard.tsx:119 — the PUBLISH NOW branch of onNext()
onAddElection({...updatedElection, owner_id: null, state: 'finalized',
               settings: setVoterAuthenticationMode(updatedElection.settings, 'open_unique_cookie')}, '/')

and onAddElection assigns the temp id only when owner_id is already non-null:

// Wizard.tsx:83-87
if (election.owner_id != null){
election.owner_id = authSession.isLoggedIn() ? authSession.getIdField('sub') : submitTempID;
}
const claimKey = crypto.randomUUID();
election.claim_key_hash = hashString(claimKey);

So the guard is false, the temp id is never assigned — while claim_key_hash is set unconditionally two lines below, outside the guard. That is exactly the shape production shows: hash present, owner absent.

The guard reads inverted: as written it only sets an owner when one already exists. This also explains why only the quick path orphans. makeDefaultElection() sets owner_id: '0' (Wizard.tsx:34), which is non-null, so the See more options branch — which calls setPage(1) without the owner_id: null override — passes the guard and gets a real temp id.

The same line explains two of the unchosen settings above: state: 'finalized' auto-promotes to open on the next request, and open_unique_cookie is what surfaces as voter_authentication.voter_id.

The control run

rqq2pw, same browser and session, identical up to the dialog, then SEE MORE OPTIONS → Restricted No → template one person, one vote:

PUBLISH NOW (jd78xd) SEE MORE OPTIONS (rqq2pw)
owner_id null v-dbg9w2gt — equal to the temp_id cookie
state on creation open draft
voterAuth.roles [] ["owner"], 23 permissions
Owner-only call setOpenState → 401 Does not have permission DELETE → 200 Election Deleted

The control was deleted by its creator right after, which is the capability the quick path never grants. rqq2pw no longer exists; the row is the record.

Don't create throwaway elections with Publish Now. Use See more options, or the API with an owner_id you control.

Description came back null — probably our harness, not a defect

Recorded so nobody re-derives it. Roughly 350 characters were typed into the + Description textarea at step 3 and accepted without complaint; after publish, .election.description and .election.races[0].description were both null.

The source does not support this being a product bug. Wizard.tsx:110-116 maps it through on the same object as the title:

const updatedElection = {
    ...election, races: [editedRace],
    title: editedRace.title,
    description: editedRace.description,
}

Both the election-level copy and the race inside races: [editedRace] should carry it. Since the title written through the identical input path did persist, the likeliest explanation is that the browser-automated fill never reached React state for that one textarea — it binds differently from the title and candidate name fields.

Do not file this without a hand-typed run reproducing it.

Verifying a new election

curl -s https://bettervoting.com/API/Election/<id> \
  | jq '.election | {state, title, description, owner_id,
                     claimable: (.claim_key_hash != null),
                     races: [.races[] | {voting_method, num_winners,
                                         candidates: [.candidates[].candidate_name]}]}'

Check state is what you expect (open straight away from Publish Now, draft from the longer path), that the candidate list survived, and that owner_id is non-null if you intend to administer it.

  • bv-api-checks.md — reading settings, the claim mechanics, the orphan table, OCC tokens on state changes
  • ../README.md — ground rules, including report-before-publishing