ServicesPoll

Integrity and Visibility

Before you run a vote that matters, four questions need answers: who counts as a voter, can they vote twice, who can read the tally, and can anyone see how an individual voted.

The voter is the token, not the payload

The ballot's identity comes from the JWT subject. VoteRequest has no voter field, and extra fields in the body are ignored - a caller who sends another user's subject and ballot hash still gets a ballot recorded under their own identity:

curl -X POST -H "Authorization: Bearer $OTHER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"option": "TqKH...", "voter_sub": "docs-wave",
       "voter_hash": "sha256:731f9c49..."}' \
  http://localhost:4000/api/v1/polls/<poll-id>/vote

The response carries a receipt hash derived from the caller's own identity, and the tally shows two separate ballots rather than an overwrite. There is no impersonation path through the vote endpoint.

Internally the subject is trimmed, lowercased, and hashed with the poll id and the guard kind, so the stored ballot never contains a plaintext subject and the same person hashes differently in every poll. identity_guard.kind selects jwt_sub (default) or service_principal; both hash the caller's principal, so the choice changes the hash domain rather than the source of identity.

One ballot per identity

The duplicate check and the tally update happen inside a single atomic script, so a second ballot cannot slip through a race. With the default guard, voting twice is rejected outright:

{
  "status": 409,
  "msg": "Duplicate ballot for this poll and identity",
  "error": "duplicate_vote"
}

Set identity_guard.allow_change_vote at create time to let voters revise instead. A replacement ballot reverses the prior selections before applying the new ones, so the totals stay honest:

curl -X POST -H "Authorization: Bearer $SNUG_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title": "Revisable", "options": ["A", "B"],
       "identity_guard": {"kind": "jwt_sub", "allow_change_vote": true}}' \
  http://localhost:4000/api/v1/polls

Voting A then B on that poll leaves total_ballots: 1 with A at 0 and B at

  1. There is still no way to withdraw a ballot entirely.

Ballots are self-inspection only

GET /polls/{id}/ballot takes no voter parameter. It hashes the caller and looks up that hash, so it can only ever return the caller's own ballot. Two voters on the same poll each get their own receipt, and the poll owner, having never voted, gets 404 ballot_not_found. Setting ballots_visibility to public does not change any of that.

ballots_visibility therefore does not expose other people's ballots; what it controls is whether the receipt endpoint answers at all:

ValueEffect
private (default)Callers may fetch their own receipt
publicIdentical in effect to private
owner_onlyGated on platform-admin rights

owner_only is the surprising one: the gate checks admin rights rather than poll ownership, so a voter cannot retrieve even their own receipt.

{
  "status": 403,
  "msg": "Result visibility forbids this access: Ballot inspection is owner-only",
  "error": "results_forbidden"
}

Who can read the tally

results_visibility gates GET /polls/{id}/results and the WebSocket handshake:

ValueWho may read results
public (default)Any authenticated caller
privateThe poll owner or a platform admin
owner_onlyThe poll owner or a platform admin

private and owner_only are checked identically; pick whichever name documents your intent. A blocked caller gets 403 results_forbidden, and the stream refuses to upgrade:

$ snug poll stream <private-poll-id> --raw
Error: WebSocket handshake failed: HTTP error: 403 Forbidden

Note that visibility covers the tally, not the poll. GET /polls/{id} still returns the title, options, status, and ballot count of a private-results poll to any authenticated caller.

What voters see while the poll is live

before_close narrows results further, but only for callers who are neither the owner nor a platform admin, and only while the poll is still running. Once it is closed, expired, cancelled, or finalized, the full result is released to everyone who passes results_visibility.

ValueNon-privileged view of a live poll
aggregate_only (default)Per-option counts, but irv_rounds and winner are null
fullEverything, including the running winner
hidden403 Results are hidden until the poll closes

The default matters most for ranked-choice polls, where suppressing the rounds and the winner is the whole point. For single-choice and multi-select polls aggregate_only and full return the same payload, because those strategies produce no rounds to hide. Choose hidden if you need to keep running counts secret too.

Closing a ranked poll flips the same caller from winner: null to the real outcome:

snug --output json poll results --poll-id <ranked-poll-id>
# before close: "irv_rounds": null, "winner": null
# after close:  "irv_rounds": [...], "winner": "Alpha"

Who can change a poll

Creation requires a platform admin token. Everything that mutates an existing poll - close, finalize, and options - is owner-or-admin, and a voter attempting any of them gets:

{
  "status": 403,
  "msg": "Insufficient permissions: Only the poll owner or an admin may perform this action",
  "error": "insufficient_permissions"
}

Admin rights are not scoped to the polls you created. A platform admin who did not create a poll can still close or finalize it, verified by creating a poll under one admin subject and closing it with another. If that matters for your deployment, treat the admin token as the control surface and keep it off voter-facing clients.

Full parameter shapes for every endpoint are in the Poll API reference.

On this page