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>/voteThe 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/pollsVoting A then B on that poll leaves total_ballots: 1 with A at 0 and B at
- 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:
| Value | Effect |
|---|---|
private (default) | Callers may fetch their own receipt |
public | Identical in effect to private |
owner_only | Gated 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:
| Value | Who may read results |
|---|---|
public (default) | Any authenticated caller |
private | The poll owner or a platform admin |
owner_only | The 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 ForbiddenNote 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.
| Value | Non-privileged view of a live poll |
|---|---|
aggregate_only (default) | Per-option counts, but irv_rounds and winner are null |
full | Everything, including the running winner |
hidden | 403 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.