Bv github issue abstention policy
Title: Record the abstention policy on the race, so an export says what was allowed — not just what voters did
Summary¶
An exported BetterVoting election cannot distinguish "nobody abstained" from "abstaining was impossible." Both look identical: no nulls, nAbstentions: 0. Whoever re-counts or audits the election later has no way to tell which one they are looking at, and no way to reproduce the election's actual rules.
This is a small, additive request: store the abstention / undervote policy on the race, and carry it into the export. It's the recording half — separate from, and cheaper than, the admin UI in #699.
What's there today¶
Election.races[] carries only:
{
"title": "…",
"race_id": "…",
"num_winners": 1,
"voting_method": "STAR",
"candidates": [ … ],
"description": ""
}
and Election.settings is election-wide, with nothing about undervotes:
{
"voter_access": "open",
"voter_authentication": { "ip_address": false, "voter_id": false, "email": false },
"ballot_updates": false,
"public_results": true,
"time_zone": "America/Los_Angeles",
"random_candidate_order": false,
"require_instruction_confirmation": true
}
So the policy that governed the ballots is nowhere in the artifact the ballots ship in.
Two distinct levels¶
They're worth separating, because a system can plausibly allow one and not the other:
- Race-level — may a voter skip the whole race? (This is what #699's setting would control.)
- Candidate-level — may a voter leave an individual candidate unscored on a STAR or Approval ballot, or must every line be marked?
Why it matters — the use case #699 asked for¶
"We could add an election setting to require a vote for every race, but we don't have a use case for this yet so it's not a high priority" — #699
Fair, for the setting. But the recording has a use case that doesn't depend on the setting ever shipping:
- Audit and recount. A third party re-tallying a frozen export has to interpret every
null. Whether zero abstentions is a fact about the voters or a fact about the ballot changes what that export means — and today the file is silent, so the auditor guesses. - Reproducing a count in another engine. This is the case I hit constantly. When a count is re-run outside BetterVoting, the abstention rule has to be supplied by hand from outside the file, which is exactly the class of ambiguity #778 is about.
- Intended vs. actual already differ. #627 reports that a voter can't abstain from the last race. So there is already at least one election where the effective policy isn't the documented one — and nothing in the export would ever show it.
- It's the cheap half. A recorded default costs one field and no UI. If #699 ever ships, the field is already there to hold its value; if it never ships, the export still stops being ambiguous.
Suggested shape¶
Whatever fits the schema best — the specific spelling matters much less than having the value somewhere. One option, per race:
"undervotes": {
"race_may_be_skipped": true,
"candidates_may_be_left_unscored": true
}
Both defaulting to today's behavior, so nothing changes for existing elections. Then:
- echo it in the JSON export (
Election.races[]), and - name it in the CSV download's header or sidecar, alongside whatever comes of #1160's processed-vs-raw split — a CSV of scores is where the ambiguity bites hardest (#791, #1090).
Relation to the other abstain tickets¶
This one is deliberately not about what counts as an abstention — that's #884, and it's a policy argument I've made elsewhere. This is the layer underneath: whatever the counting rule is, the file should say what the voter was permitted to do, so the rule can be applied — or audited — by someone who wasn't there.
Background and the neighbouring tickets, cross-referenced to reproducing cases: BV abstain / blank / zero — issue index.