The voting service separates eligibility from ballot content. An invitation is associated with a voting token; an accepted ballot contains an election id and a choice payload, but no token or email address.
Request flow
flowchart LR LINK["Invitation link"] --> PAGE["Voting page"] PAGE -->|"token + choices"| ROUTE["Ballot route"] ROUTE --> VALIDATE["Validate ballot shape"] VALIDATE --> TOKEN["Consume token atomically"] TOKEN --> BALLOT[("Insert ballot in D1")] BALLOT --> CACHE["Clear result cache"] CACHE --> DONE["Return confirmation"]
An election is open when open_at <= now < close_at. Page rendering checks that state for voter feedback. Submission checks it again inside the per-election TokenManager Durable Object.
Ballot types
| Type | Submission route | Payload after token | Validation |
|---|---|---|---|
SIMPLE_TRIPLE | POST /e/:id/vote | choice: "YES" | "NO" | "ABSTAIN" | One of the three literal values |
RANKED_CONDORCET | POST /e/:id/vote | ranking: string[] | Every candidate id exactly once; no duplicates |
POLL | POST /e/:id/vote/poll | selected_option or selected_options | Existing option ids; at least one selection |
Poll options use the candidates table. settings.poll_response_type selects single or multiple; absence defaults to single.
Simple ballot
{
"token": "plaintext-voting-token",
"choice": "YES"
}The stored payload is:
{
"choice": "YES"
}Ranked ballot
{
"token": "plaintext-voting-token",
"ranking": ["candidate-a", "candidate-c", "candidate-b"]
}The model requires a full ranking. Partial rankings, duplicate ids, and ids outside the election’s candidate list are invalid.
Current submission defect
The shared
POST /e/:id/votehandler runs simple-ballot validation before checking the election type. A ranked request withoutchoicetherefore returns400before the ranked branch runs. Ranked result calculation exists, but normal ranked submission is currently unavailable. This is tracked in ihnyc-rc-vote issue #10.
Poll ballot
Single choice:
{
"token": "plaintext-voting-token",
"selected_option": "option-a"
}Multiple choice:
{
"token": "plaintext-voting-token",
"selected_options": ["option-a", "option-c"]
}The multiple-choice validator rejects duplicate option ids. It does not impose a maximum selection count.
Token boundary
Submission hashes the plaintext token with SHA-256. The per-election Durable Object:
- reads the election window;
- finds the matching
tokensrow; - conditionally writes
used_atonly while it isNULL; and - reports whether the token was invalid, already used, or outside an open election.
Only after a successful token update does the route insert the ballot. These are separate database operations, not one transaction.
See Token management for the Durable Object and batch contracts.
Ballot storage
| Column | Stored value |
|---|---|
id | Random UUID |
election_id | Owning election |
ballot_type | One of the three type strings |
payload_json | Choice, ranking, or poll selection object |
created_at | Acceptance time in milliseconds |
ballots has no email, invitation id, token hash, IP address, or user agent column. Request metadata can appear separately in audit records, but the vote audit call does not include the token.
Results visibility
settings.hide_results_until_close: true blocks the legacy JSON results route until the election closes and keeps open-period results off the combined election page. When false or absent, the election page can calculate results while voting is open.
settings.publish_results_to_home: true publishes a closed election on the service home page. It does not change ballot acceptance.
Manual results, when stored, replace calculated results on public result surfaces. See Results calculation.
Current constraints
- Ranked ballots cannot reach their submission branch because of the shared route’s early simple-ballot validation.
- A token is marked used before the ballot insert. A later D1 failure can consume the token without storing a ballot.
- Poll submission uses the general route middleware but does not attach the additional
votelimiter used byPOST /e/:id/vote. - Invitation records retain plaintext tokens for resend and magic-link election lists. Ballot rows remain separate from those records.
Sources
Verified against ihnyc-rc-vote commit 2451aa1.
src/models/ballot.tssrc/models/election.tssrc/routes/vote.tssrc/durable-objects/TokenManager.tssrc/utils/db.tsmigrations/001_initial_schema.sqlmigrations/007_add_poll_ballot_type.sql