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

TypeSubmission routePayload after tokenValidation
SIMPLE_TRIPLEPOST /e/:id/votechoice: "YES" | "NO" | "ABSTAIN"One of the three literal values
RANKED_CONDORCETPOST /e/:id/voteranking: string[]Every candidate id exactly once; no duplicates
POLLPOST /e/:id/vote/pollselected_option or selected_optionsExisting 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/vote handler runs simple-ballot validation before checking the election type. A ranked request without choice therefore returns 400 before 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:

  1. reads the election window;
  2. finds the matching tokens row;
  3. conditionally writes used_at only while it is NULL; and
  4. 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

ColumnStored value
idRandom UUID
election_idOwning election
ballot_typeOne of the three type strings
payload_jsonChoice, ranking, or poll selection object
created_atAcceptance 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 vote limiter used by POST /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.ts
  • src/models/election.ts
  • src/routes/vote.ts
  • src/durable-objects/TokenManager.ts
  • src/utils/db.ts
  • migrations/001_initial_schema.sql
  • migrations/007_add_poll_ballot_type.sql