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:
- Which term best describes your situation? —
Election/Poll. Writessettings.term_type; wording only. - How many races? —
Just one/More than one. - Elected Office Title, plus an optional Description behind a
+ Descriptiondisclosure. See the description defect below. - Candidates — rows auto-append as you fill the last one; trailing blanks are dropped on save.
- Voting method —
Single-Winner/Basic Multi-Winner/Proportional Multi-Winner, then the winner count → Next. - Which Voting Method? — STAR, Ranked Robin, Approval, then Plurality and IRV behind More Options → Next.
- 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-containerand 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/anonymizedBallotsreadable by anyone, in any state. The wizard never surfaces the choice. This is the concrete basis for open question Q4; see the warning inbv-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.jd78xdwas 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.
Related¶
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