BetterVoting — preliminary results: integration points & proposed solution¶
Investigation of Equal-Vote/bettervoting for the three linked work items:
#1350 (disclaimer), #1353 (public audit vs. immutability), BV230 (toggle change back and forth).
Repo investigated: /Volumes/T7/Voting/BetterVoting/BV/bettervoting
Read-only. Nothing was modified. Date: 2026-07-29.
0. READ THIS FIRST — four upstream facts that change the work¶
These were verified directly against a fresh git fetch origin. The detailed map in §2 onward was
produced against the local checkout (feature/clean-json-export, HEAD 516fbd69, branched Jun 17),
which is 98 commits behind upstream main (8d2b3f9a, Jul 29). Line numbers below can be off by
a few; the architecture is unchanged. Where the two disagree, this section wins.
(1) BV230 is very likely already fixed — two days ago.
7cbc6079 (Jul 27, PR #1459,
JacksonLoper/stalesettings) — "Refresh election after setPublicResults / setOpenState". Its commit
message is the BV230 repro verbatim: "toggle 'Show Preliminary Results', navigate to another admin
page, come back, and the switch has reverted to its old value even though the backend was updated. A
reload fixes it." Cause: setPublicResults bypasses updateElection, the only thing that folds a
change into the election context; enqueueWrite reads only update_date off the response, so the
context keeps the pre-write value until the next GET, and the optimistic toggle masks it until
re-mount. The backend was persisting the change the whole time; the UI was lying.
Not an ancestor of the local branch — verified with git merge-base --is-ancestor.
→ Re-run the repro against main before filing anything.
(2) The #1350 article is already written, and linked from nowhere.
docs/help/preliminary_results.md is on origin/main (front matter nav_order: 8). It already covers
every substantive requirement in the issue: what a live tally exposes, that admins always see who
voted, delta-analysis de-anonymization (:26), the editable-ballot interaction, and that turning the
setting off does not un-reveal (:44-50). Verified: the only preliminary_results hits under
packages/ on main are the i18n label key and its one consumer. #1350 is a linking + copy task,
not a writing task.
(3) #1353 Option A is already built, in an open PR.
origin/JacksonLoper/publicaudit (PR #1365) — 12 files, +912/−14, no migration. Its query at
getElectionHistoryController.ts:230-234 is
selectFrom('electionDB').where('election_id','=',id).select(['state','settings','update_date']).orderBy('update_date','asc')
— no head filter, explicit column allow-list. Emits six event types including
preliminary_results_change, voter_revealed, and state changes — all three sensitive actions Arend
named. Adds 29 i18n keys and a 299-line backend test. Tip 9129ea42 is main merged into the branch
today at 12:26 EDT, i.e. open and actively kept current, not merged.
Option A is a review, not a build.
(4) The sentence BV230's "Expected results" rests on does not exist in the product.
The BPML doc highlights "(Administrators can make results public at any time)". That string is in no
locale file on main. It survives only on branches dating to Feb 2025 — removed copy. The live tip is
the long public_results text at en.yaml:738-742. The one live "at any time" in the repo is
docs/help/preliminary_results.md:46, and it is about turning results off. Your screenshot shows
both tooltip bodies because the two images were captured at different times.
→ No shipped promise contradicts locking the toggle, which makes §6 easier to settle.
1. What's on disk¶
One boolean, public_results?: boolean (packages/shared/src/domain_model/ElectionSettings.ts:33),
does all the work. It is set to true by the wizard
(packages/frontend/src/components/ElectionForm/Wizard/Wizard.tsx:54) — and the wizard exposes no
control for it, so the creator is never shown the choice (a grep across the whole ElectionForm/ tree
yields that single line, while is_public: false is set deliberately two lines earlier at
Wizard.tsx:35). Three backend gates read it: results tabulation
(getElectionResultsController.ts:24-31), admin ballot list
(getBallotsByElectionIDController.ts:15-38), and the anonymous raw cast-vote-record endpoint
(getAnonymizedBallotsByElectionIDController.ts:17-31). Exactly one endpoint writes it —
POST /Election/:id/setPublicResults (packages/backend/src/Routes/elections.routes.ts:548) — gated
on permissions.canEditElectionState = [system_admin, owner]
(packages/shared/src/domain_model/permissions.ts:25, note the admin role is excluded) with no
election-state guard whatsoever (setPublicResultsController.ts:14-33, 37 lines total,
election.state never referenced). Every election write goes through ElectionsDB.updateElection
(Models/Elections.ts:60-92), which is append-only: flip the old head=true row to false under an
optimistic-concurrency check on expected_update_date, then INSERT a new full row.
2. The integration-point map¶
Frontend — voter-facing¶
| Surface | path:line | Role | Item |
|---|---|---|---|
| Ballot page banner slot | packages/frontend/src/components/Election/Voting/VotePage.tsx:259-265 |
Already stacks <DraftWarning/> + <ElectionStateWarning state="archived"> above the ballot. The insertion point for A. |
A |
| Submit-confirm modal | .../Voting/VotePage.tsx:330-376 (DialogTitle :333, DialogContentText :336) |
The actual last-chance-before-cast surface; only its Submit button POSTs | A |
| Reusable banner component | .../Election/ElectionStateWarning.tsx:6-24 |
{state?, title, description, hideIcon?, children}; state optional so it can render unconditionally; calls t(title)/t(description) with no values object (:17,:19) |
A |
| Generic ballot renderer | .../Voting/GenericBallotView/GenericBallotView.tsx:48-49,123-135 |
In-ballot external-link precedent: <Link href={learnLink} target='_blank'> |
A (rejected placement) |
| Draggable IRV renderer | .../Voting/DraggableIRVBallotView.tsx:106-155 |
Bypasses GenericBallotView entirely; no footer, no learn-more. Anything added to the ballot view misses draggable RCV | A trap |
| Voter landing page | .../Election/ElectionHome.tsx:86,101,126 |
Three separate public_results === true gates rendering a results link |
A |
| Post-submit page | .../Voting/Thanks.tsx:38 |
Where the voter is first shown preliminary results — i.e. too late for #1350 | A |
| Ballot receipt page | .../Voting/VerifyBallot.tsx:32 |
Not a pre-submit screen: /{id}/ballot/:ballot_id, needs a persisted ballot |
— |
| Results page | .../Results/ViewElectionResults.tsx:19 (fetch gate), :38-41 (PRELIMINARY vs OFFICIAL title), :47-52 (hardcoded English fallback), :81-92 (export), :96-107 (ShareButton) |
Only voter surface that says "preliminary". ShareButton is gated on voter_access !== "closed" — not on public_results |
A, B |
| Public CSV/JSON export | .../Results/BallotDataExport.tsx:46 |
ballot_id is column 1 of the public export |
A, privacy |
| Analytics | packages/frontend/index.html:44-57 (Matomo, trackPageView in <head>), :92 (Freshworks) |
Loads on /vote and /results. setExcludedQueryParams (:47) covers query strings only, not path segments |
privacy |
| Crawlers | packages/frontend/public/robots.txt:2-3 |
User-agent: * / Disallow: — allow everything; no noindex anywhere in the repo |
A |
Frontend — admin-facing¶
| Surface | path:line | Role | Item |
|---|---|---|---|
| The toggle | .../Election/Admin/ElectionSettings.tsx:52-62 (useSetPublicResults + useOptimisticToggle + enqueueWrite), :106-111 (SwitchSetting, no disabled prop) |
Only entry point. Label flips election_settings.public_results ↔ preliminary_results on state (:108) |
A, C |
| Fieldset disable | .../Admin/ElectionSettings.tsx:69 <FormControl disabled={election.state !== 'draft'}> |
Does not reach this switch — MUI 9 resolves the child's explicit disabled={false} first (FormControlLabel.js:142, internal/SwitchBase.js:156-159; default set at components/util.tsx:383). Ruled out as BV230's cause |
C |
| Sibling switches | .../Admin/ElectionSettings.tsx:27,31,102-105 — isDisabled = disabled ?? (election.state !== 'draft' && !availableDuringElection) |
The pre-existing "editable after draft" idiom; availableDuringElection is currently used by nobody |
C |
| Helper-text slot | components/util.tsx:383,393,416-420 |
FormHelperText renders only when disabled — no slot for a warning on an enabled switch |
privacy warning |
| Optimistic toggle | hooks/useOptimisticToggle.ts:23-32 |
Reverts local value on falsy commit; no try/catch around await commit() |
C |
| OCC token | components/ElectionContextProvider.tsx:68-72 (advance from GET), :74-100 (enqueueWrite, sends latestUpdateDate.current) |
The single source of expected_update_date |
C |
| API hook / error surface | hooks/useAPI.ts:102-104; hooks/useFetch.ts:39-41 |
Error text is Error making request: {status}: {server detail} — enough to distinguish 401/400/409 from the snackbar alone |
C |
| Finalize | .../Admin/PublishAndShare.tsx:46-47 await enqueueWrite(... finalize ...); await fetchElection(); |
The fetchElection() is what re-syncs the token after finalize |
C |
| Admin nav | .../Election/Sidebar.tsx:27 (Settings), :29 { label: isDraft ? 'Preview Results' : 'Live Results', path: /${id}/results }, :86 "Proceed to …" |
Funnels the admin to the live tally as a workflow step; hardcoded English literals, not t() keys |
B, C |
| Settings route | .../Admin/Admin.tsx:37 |
No state or permission gate on the route (contrast PublishAndShare.tsx:36, which bails on canEditElectionState) |
C |
| Duplicate election | .../Admin/AdminHome.tsx:49-53 |
structuredClone resets only title/frontend_url/owner_id/state — settings.public_results carries over into the new draft |
A |
| CVR upload | .../UploadElections.tsx:73,81-82 |
Spreads makeDefaultElection().settings, i.e. inherits public_results: true |
A |
| Voter roll UI | .../Admin/EditElectionRoll.tsx:157 (Has Voted: …), :96-119 (copy voting URL), :122-133 (reveal snackbar "Action has been logged"), :275-291 (Action/Actor/Timestamp table) |
The "admins see who voted" premise, verbatim; the only history UI in the app | A, B |
Backend¶
| Surface | path:line | Role | Item |
|---|---|---|---|
| Write endpoint | Controllers/Election/setPublicResultsController.ts:14-33 |
expectPermission(canEditElectionState) → type check → assign → expectUpdateDate → updateElection(..., 'Publish Results', ...). No state guard |
A, B, C |
| Results gate | Controllers/Election/getElectionResultsController.ts:24-31 |
Flag off + state open → Forbidden before any permission check, so even the owner cannot see a live tally; other states fall through to canViewPreliminaryResults |
A, B |
| Anonymous CVR | Controllers/Ballot/getAnonymizedBallotsByElectionIDController.ts:17-31 |
Flag on ⇒ no permission check, no state check; returns {ballot_id, election_id, precinct, votes} per ballot to anonymous callers, in any state including draft |
A |
| Admin ballot list | Controllers/Ballot/getBallotsByElectionIDController.ts:15-38 |
Permission first, then !public_results && state !== 'closed' → 401; response scrubbed + secureShuffled |
A |
| Unguarded per-ballot read | Controllers/Ballot/getBallotByBallotIDController.ts:10-41 |
No expectPermission, no flag check, no state check |
privacy |
| Write-in names | Controllers/Ballot/getWriteInNamesController.ts:16-18 |
canViewBallots only — returns ballot content mid-election with the flag off |
B |
| The one immutability guard | Controllers/Election/editElectionController.ts:24-27 |
state !== 'draft' && public_archive_id === null → BadRequest("Election is not editable"). Reads the client-supplied req.body.Election.state |
B, C |
| Auto state transition | Controllers/Election/elections.controllers.ts:120-172 (invoked at :74) |
Runs on every :id request incl. POSTs (Routes/elections.routes.ts:845-847); performs its own updateElection at :158, minting a new update_date mid-request |
C, B |
| Finalize | Controllers/Election/finalizeElectionController.ts:20-52 |
Copies settings through untouched; rejects non-draft |
C |
| Other state writes | setOpenStateController.ts:24-33, archiveElectionController.ts:20-27, editElectionRolesController.ts:19-22 (guard commented out, // TODO: should this only be allowed in draft??), setWriteInResultsController.ts:85-89 |
No state guards; the last one reads expected_update_date server-side — the in-repo precedent for de-risking C |
B, C |
| Shared guards | Controllers/controllerUtils.ts:42-46 (expectPermission → 401), :50-57 (expectUpdateDate → 400), :65-76 (secureShuffle, defeats order correlation only) |
B, C | |
| Append-only store | Models/Elections.ts:60-92; head = true filters at :102,120,131,146,167,178,194,211,228,263; reason at :46,60,270,289 — signatures only, never persisted |
The audit substrate, and its gap | B |
| Voter-ID reveal | Controllers/Roll/revealVoterIdController.ts:34-37 (mode gate before permission), :39, :62-69 (action_type: '🚨 VOTER_ID_REVEALED' + actor + timestamp) |
The one sensitive action already durably audited | B |
| Roll read | Controllers/Roll/getElectionRollController.ts:28-52 (sanitizeHistory keeps timestamp and submit/update), :108-110 (refuses open elections), :140-156 (ballot_id/ip_hash scrubbed unconditionally; voter_id deleted only when invitation === 'email') |
The timing index behind delta analysis | A |
| Ballot cast | Controllers/Ballot/castVoteController.ts:76-93 (reuses ballot_id across edits, :86), :100-112 (roll history + roll.ballot_id), :275-279 (scrubs ballot_id from the response "to prevent voters from creating receipts (vote buying/coercion)") |
A, privacy | |
Services/Email/EmailTemplates.ts:69 (vote URL = /{eid}/id/{voter_id}), :91-92 (/{eid}/ballot/{ballot_id}, /{eid}/id/{voter_id}) |
Earliest voter surface; also the analytics leak vector | A, privacy | |
| SSR meta injection | app.ts:61-79 + Util.ts:63-68,82-83,91-111 |
Unauthenticated getElectionByID per page URL; title/description into OG tags, title + first 5 candidate names into an ik.imagekit.io URL |
privacy |
Shared + DB¶
| Surface | path:line | Role | Item |
|---|---|---|---|
| The flag | shared/src/domain_model/ElectionSettings.ts:33 |
public_results?: boolean |
all |
| Edit-vote flag | .../ElectionSettings.ts:32 |
ballot_updates?: boolean (label "Allow Voters To Edit Vote") |
A, C |
| Compatibility rule | .../ElectionSettings.ts:44-56, enforced at :103 via electionValidation ← Models/Elections.ts:62 |
ballot_updates requires voter_access != 'open' and invitation == 'email' |
A, C |
| Access modes | .../VoterAuthenticationMode.ts:23-30 (MODE_SHAPES), :53 (throws on non-canonical) |
Only closed_admin_managed_ids and closed_bv_managed_ids carry voter_access: 'closed'; only the latter has invitation: 'email' |
A |
| Permissions | .../permissions.ts:8,12,19,25,27 |
canEditElectionState excludes admin; canViewPreliminaryResults is a misnomer (unreachable for a live open election) |
C |
| Versioning | Migrations/2024_01_29_pkeys_and_heads.ts:57-59; Migrations/2026_04_27_unique_head.ts:4-9 |
pkey (election_id, update_date) + unique partial index on head |
B |
| Roll audit shape | .../ElectionRoll.ts:11,15,37 |
ballot_id persists the voter→ballot link; history: ElectionRollAction[] = {action_type, actor, timestamp} |
A, B |
| ID entropy | shared/src/utils/makeID.ts:7,36-38 |
Election id = 6 chars from a 24-symbol alphabet via Math.random() (~1.9e8 keyspace); no rate limiting anywhere in the backend |
privacy |
| Artifacts | .gitignore:6 (**/dist), :18 (swagger.json) |
Both untracked — git ls-files confirms. Nothing generated gets committed |
mechanics |
i18n / docs¶
| Surface | path:line | Role | Item |
|---|---|---|---|
| The tip to rewrite | frontend/src/i18n/en.yaml:737-742 |
tips.public_results — verbatim the #1350 As-Is text, as a \| block with {{election}}/{{elections}} |
A |
| The two labels | en.yaml:1167 preliminary_results: Show Preliminary Results !tip(public_results), :1169 public_results: Make Results Public !tip(public_results) |
One tip serves both; ElectionSettings.tsx:108 swaps them by state |
A |
| Edit-vote label | en.yaml:1165 ballot_updates: Allow Voters To Edit Vote !tip(ballot_updates); tip at :733-735 titled "Allow Voters To Update Ballot" |
Label/tip title disagree | A |
| Dead keys | en.yaml:209 results.admin_results_toggle (grep: definition only); en.yaml:689 disabled_msgs.ballot_updates_when_open (no consumer) |
Traps for a copy pass | A |
| Translated key | en.yaml:211 / es.yaml:189 / pt-BR.yaml:181 / pl.yaml:179 (still English) |
The only preliminary-results string in the other locales | A |
| Link precedents | en.yaml:701-703 (markdown link in a tip description), en.yaml:793 (learn_link: https://equal.vote/pr) |
Proof a tip can carry a link | A |
| Renderer | components/util.tsx:25-27 (rLink/rBold/rTip), :235 (target={v['newWindow'] ? '_blank' : '_self'}), :240-246 (**bold** → <i>, i.e. italic), :258-262, :269-272 |
A | |
| Tip component | components/styles.tsx:27,33 |
learn_link auto-renders a hardcoded-English Learn More anchor with target='_blank' |
A |
| Priority bands | en.yaml:2,374,429,557,692,844 |
PRIORITY 0 = core voting path (translators' scope); tips: is PRIORITY 4, election_settings: is PRIORITY 99 |
A |
| The article | docs/help/preliminary_results.md (front matter nav_order: 8); key claims at :26, :44-50 |
Already written, already on origin/main, linked from nowhere in packages/ |
A |
| Second doc | docs/other_tools/google_forms.md:99 |
Instructs users to verify a test ballot via "the preliminary election results" | B |
Tests¶
| Surface | path:line | Role |
|---|---|---|
All existing public_results references |
backend/src/test/exportFormat.test.ts:37 (inert — buildElectionExport never reads it), backend/src/test/writeIns.test.ts:45, testing/tests/election-with-rolls.spec.ts:120, testing/tests/election-without-rolls.spec.ts:109 |
Four inert fixture literals. Zero tests call the endpoint. |
| OCC test pattern to copy | backend/src/test/editElection.test.ts:117-157 |
200 / 409-on-stale / 400-on-missing against /edit |
| Finalize scaffolding | backend/src/test/finalizeElection.test.ts:18-49; TestHelper.ts:107-118 |
Note finalizeElection.test.ts:19-20 mutates a shared fixture in place |
| Mock store blocker | backend/src/Models/__mocks__/Elections.ts:30-35 |
Overwrites in place, keeps no versions — an audit-history test cannot pass against it as written |
| Only settings-toggle spec | testing/tests/full-runthrough.spec.ts:51-57 |
Toggles a setting while still draft — which is exactly why BV230 was never caught |
| CI | .github/workflows/node.js.yml, end-to-end-tests.yml |
Both trigger on main only; no frontend test suite, no lint in CI |
3. Work item A — #1350 disclaimer¶
The article does not need writing (see §0.2). A grep for preliminary_results across packages/
returns only the label key (en.yaml:1167) and its consumer (ElectionSettings.tsx:108). #1350 is a
linking + copy task.
Can the tip renderer carry a link? Yes — but the anchor target is the blocker, not the capability.
util.tsx:25 defines rLink = /\[([^\]]*?)\]\(([^)]*?)\)/ and the pipeline at :269-272 applies
links before tips, so both coexist in one value; en.yaml:703 already ships a markdown link inside
tips.random_tie_order.description. The problem is util.tsx:235:
target={v['newWindow'] ? '_blank' : '_self'} — same-tab by default. ElectionStateWarning.tsx:17,19
calls t(title)/t(description) with no values object, so it cannot pass newWindow. On the
ballot that would navigate away and destroy the in-progress ballot (VotePage's pages array is
React state with no draft persistence). Decision: never express the ballot-side link as markdown in
an i18n value. Use an explicit <Link target='_blank' rel='noreferrer'> in
ElectionStateWarning's children slot (:20), copying GenericBallotView.tsx:130-134.
File-by-file¶
1. packages/frontend/src/i18n/en.yaml:737-742 — the copy edit.
Old:
public_results:
title: Public Results
description: |
Allows voters to see the results of the {{election}}.
If enabled while voting is open then voters will be shown to the preliminary results after completing their ballot.
High profile {{elections}} will usually keep the results hidden, and then reveal them after the {{election}} is closed.
New:
public_results:
title: Public Results
description: >
Controls whether voters can see {{election}} results.
When enabled during an open {{election}}, voters will see preliminary results after submitting their {{ballot}}.
High-profile {{elections}} typically keep results hidden until the {{election}} closes.
learn_link: https://docs.bettervoting.com/help/preliminary_results.html
Three deliberate deviations from the issue's literal Should-be text, each defensible in review:
(a) keep the {{election}}/{{elections}} templates and add {{ballot}} — the issue quotes the
rendered string; pasting it verbatim hard-codes "election" and regresses poll terminology for every
poll. (b) | → > — the Should-be is one paragraph, and applyLineBreaks
(util.tsx:258-262) turns the | block's newlines into three <br/>-separated lines.
(c) add learn_link — one line, zero TypeScript, gets the admin-side article link for free via
styles.tsx:33, precedent at en.yaml:793. Note the description must read sensibly under both
labels (en.yaml:1167 "Show Preliminary Results" and :1169 "Make Results Public"), since they share
this one tip. es/pl/pt-BR carry no tips: block at all, so this is en-only; tips: is PRIORITY 4
(en.yaml:692) so it creates no translator obligation.
Also delete the dead en.yaml:209 results.admin_results_toggle in the same pass — it is the third
!tip(public_results) call site, reads exactly like "the results-page toggle help text", and has no
consumer.
2. New packages/frontend/src/components/Election/PreliminaryResultsNotice.tsx. Modelled on
TemporaryAccessWarning.tsx (the precedent for a notice with no state prop that computes its own
visibility and puts an action element in children). Wraps ElectionStateWarning. Gate:
election.settings.public_results === true and election.state === 'open' || election.state === 'draft'.
The state condition matters: public_results does two jobs, and after close the flag means "final
results published", which carries none of the live-tally risk — the codebase already recognises the
split at ElectionSettings.tsx:108 and ViewElectionResults.tsx:38-41.
3. VotePage.tsx:265 — the chosen insertion point. Immediately after the existing
<ElectionStateWarning state="archived"> block and before <BallotContext.Provider>. This is the only
spot that (i) is above the ballot on every race page, (ii) renders once rather than once per race,
(iii) covers DraggableIRVBallotView, which bypasses GenericBallotView entirely
(.../DraggableIRVBallotView.tsx:106-155 re-implements its own instructions block with no footer), and
(iv) is unambiguously pre-submission. Placing it in the ballot footer is the rejected alternative for
exactly reason (iii). VotePage reads precinctFilteredElection, which spreads the whole election, so
settings is in hand.
4. VotePage.tsx:336 — one sentence inside the submit Dialog, above the receipt-email
TextField. The banner is scrollable-past; the modal is the only surface the voter must actively
confirm, which is what "transparent BEFORE they cast" actually demands. Keep it to one sentence — the
dialog already lists every race and score.
5. The closed-list extra warning — predicate: election.settings.voter_access === 'closed'. Not
getVoterAuthenticationMode(), which throws on any non-canonical settings shape
(VoterAuthenticationMode.ts:53) and would crash a ballot render for a legacy row. The raw check is
exactly equivalent here: MODE_SHAPES (:23-30) shows only closed_admin_managed_ids and
closed_bv_managed_ids carry voter_access: 'closed', and 'registration' is declared
(ElectionSettings.ts:22) but matches no canonical shape. Every voter-facing screen already
hand-rolls it this way (Thanks.tsx:60, ViewElectionResults.tsx:96).
What the closed-list copy may and may not say. It may say: admins see which voters have cast a
ballot — that is a first-class feature, rendered per voter at EditElectionRoll.tsx:157, and it is
true equally in both closed modes. It may say: combined with a live tally, timing inference can
reveal how a particular person voted. It must not say admins can look up a voter's ballot:
ballot_id is scrubbed from every roll response unconditionally
(getElectionRollController.ts:141-156 sets ballot_id: undefined for every roll; only voter_id
deletion is mode-conditional), and the voter→ballot join primitive getBallotByVoterID
(Models/Ballots.ts:121-137) is called from exactly one place — castVoteController.ts:78, the
edit-vote path — never from an admin endpoint. The redaction asymmetry between the two closed modes
concerns the ability to act as a voter, not to read their ballot.
6. Optional, lower priority: the article link beside results.preliminary_title on
ViewElectionResults.tsx:38-41 (the one place the word already appears, and where the audit-log link
will also want to live), beside the two election_home.or_view_results buttons
(ElectionHome.tsx:86,101), and one sentence in EmailTemplates.ts:46-74 Invites —
chronologically the first voter surface for closed lists, though English-only by construction
(backend templates have no i18n) and defeatable because the flag can flip after invites go out.
One correction the article itself needs. docs/help/preliminary_results.md:26 states "BetterVoting
hides the link between a voter and their specific ballot." That is true of the API and false of the
deployment: Matomo runs on every route (index.html:44-57) and the voter's credential and their
ballot id are both path segments (EmailTemplates.ts:69 → /{eid}/id/{voter_id}; :91 →
/{eid}/ballot/{ballot_id}; route at Election.tsx:45), so one analytics visitor profile joins the
two. setExcludedQueryParams (index.html:47) covers query strings only. Shipping a first-ever
privacy notice on the ballot while that line stands unqualified is the kind of thing that gets quoted
back at the project.
7. New PRIORITY 0 i18n keys (preliminary_results_notice: block with title, description,
closed_list_title, closed_list_description, link_text, article_url, plus a
ballot.dialog_warning* pair). This is the one part of A that lands in PRIORITY 0 and therefore
creates real translator work for es/pl/pt-BR. fallbackLng: 'en' (i18n/i18n.ts) makes untranslated
keys render English rather than a raw key, and pl.yaml:179 already ships untranslated English in this
very block — so English-first is precedented, but a privacy disclaimer in English for a Spanish voter
is a worse look than ordinary UI copy. Avoid **…** in the new copy: util.tsx:244 renders it as
<i>, not bold.
8. Playwright. Both election-with-rolls.spec.ts:120 and election-without-rolls.spec.ts:109
create elections with public_results: true, so both will start rendering the new notice and the new
dialog text. Expect selector churn; add one positive and one negative assertion.
4. Work item B — #1353¶
Verdict on Arend's premise: two-thirds right, and the right two-thirds are the headline¶
WHAT changed and WHEN is already on disk, completely, with no migration needed. electionDB has
been append-only since Migrations/2024_01_29_pkeys_and_heads.ts:57-59 (pkey
(election_id, update_date)), with a unique partial index guaranteeing one head row per election
(Migrations/2026_04_27_unique_head.ts:4-9). Models/Elections.ts:73-88 never UPDATEs a payload — it
flips head=false and INSERTs a new row carrying the entire settings JSON. Nothing prunes: the only
deleteFrom calls are Elections.ts:270-277 and :289-296, both whole-election. So every historical
value of public_results exists as a timestamped row.
WHO and WHY are not recoverable at any price. updateElection takes a reason: string and
discards it — grep -n reason packages/backend/src/Models/Elections.ts returns exactly four hits
(:46,60,270,289), all signatures, no function body, no insert, no log. Every call site passes
something useful (setPublicResultsController.ts:25 → `Publish Results`;
finalizeElectionController.ts:52 → `Finalizing election`). There is no actor column. Somebody
built the audit-log interface and never connected the wire.
And there is no read path. All eleven head references in Models/Elections.ts outside the write
are .where('head', '=', true) (:102,120,131,146,167,178,194,211,228,263). No
getElectionVersions, no history route.
What Option A costs¶
No migration, no new table. New: one model method that omits the head filter, one controller, one
route + @swagger JSDoc block, one frontend page + route + useAPI hook, one i18n block. That's it —
and the load-bearing design constraint is that the endpoint must project an explicit allow-list,
never selectAll(): superseded election rows carry auth_key, claim_key_hash, and
admin_ids/audit_ids/credential_ids (email addresses), and removeHiddenFields is applied only on
the single-election read path (elections.controllers.ts:193-197).
Which is exactly what already exists upstream — see §0.3. Option A is not a build; it is a
review. Two nits for that review: the i18n block was inserted at en.yaml:18, inside PRIORITY 0,
which is the wrong band for an audit page; and a GET /Election/:id/history routed through
electionsRouter.param('id') (Routes/elections.routes.ts:845-847) will itself trigger
updateElectionStateIfNeeded, so reading the audit log can append to the audit log — assert that
two consecutive GETs return the same entry count.
The one thing Option A cannot honestly show is who acted. If that is wanted, it is two nullable varchar
columns (update_reason, updated_by) on electionDB plus two lines in updateElection — a
migration, a shared-model change, and pre-migration rows that must render as "unknown (pre-audit)"
rather than implying nobody acted. The cleaner alternative is a purpose-built electionEventsDB copied
structurally from Migrations/2026_03_19_email_events.ts (the only existing event table, and the only
one that correctly uses timestamptz — update_date is a varchar holding a ms-epoch string).
Arend's second metric — how many times the reveal flow was used — is computable today with no new
writes, by counting '🚨 VOTER_ID_REVEALED' entries (revealVoterIdController.ts:62-69).
Publish an integer, never a list: a per-voter reveal timeline combined with roll.submitted is
itself de-anonymizing, and getRollsByElectionID is permission-gated and refuses open elections
outright for that reason.
What Option B costs¶
Mechanically trivial — one state guard in setPublicResultsController.ts:17, mirroring
editElectionController.ts:24-27, keyed off req.election.state (not req.body, the mistake the
existing guard makes). Substantively expensive: it is the direct negation of BV230, it invalidates
docs/help/preliminary_results.md:46,50 in print, and it does not actually close the hole — an
election finalized with public_results: true still leaks the live tally and the full raw CVR
(getAnonymizedBallots…:17-31, no permission check) for its entire duration.
Also worth knowing: the predicate cannot be state === 'finalized'. updateElectionStateIfNeeded
(elections.controllers.ts:129-144) promotes finalized→open on the very next request when there is no
start_time, so "finalized" is nearly a phantom state. The only workable predicate is
state !== 'draft'.
Recommendation¶
Option A, via review of origin/JacksonLoper/publicaudit (PR #1365), actor-less for now, with the
page stating plainly that the acting admin is not recorded. Ship it behind a beta flag as Arend asked:
one line in flagDefinitions (components/FeatureFlagContextProvider.tsx:39-54) plus
flags.isSet('ELECTION_HISTORY') at the route and the results-page link — copying the existing
ELECTION_ROLES nav pattern at Sidebar.tsx. No SharedConfig FF_ entry is needed: isSet
falls back to sharedConfig['FF_'+name] === 'true', and an absent key is undefined !== 'true' ⇒
false — three existing flags (PR_CONTROLS, both FORCE_DISABLE_*) have no FF_ entry. So the flag
is frontend-only and needs no shared rebuild. Gate only the frontend route and link, not the endpoint —
no route in elections.routes.ts is flag-gated today and inventing that pattern for this is scope
creep, though it does mean the endpoint stays reachable by direct URL during beta.
Note one hard gap for testing: Models/__mocks__/Elections.ts:30-35 overwrites in place and keeps no
versions, so an audit-history jest test cannot pass until the mock appends versions and head-filters
its reads — and that mock backs every backend test.
5. Work item C — BV230¶
What is ruled out, from code¶
- Not a backend state guard.
setPublicResultsController.ts:14-33never referenceselection.state. The backend already does what the help text promises. - Not the disabled toggle.
ElectionSettings.tsx:69's<FormControl disabled={election.state !== 'draft'}>looks like the smoking gun and is not: MUI 9 resolvesdisabledProp ?? control.props.disabled ?? muiFormControl?.disabled(FormControlLabel.js:142) andif (typeof disabled === 'undefined') disabled = muiFormControl.disabled(internal/SwitchBase.js:156-159), whileSwitchSettingpasses an explicitdisabled=falsedefault (components/util.tsx:383,393). Thecomponent="fieldset"does not emit a native<fieldset disabled>either —FormControldestructuresdisabledout and spreads only...other. The switch is live in all five states. Anyone who "fixes" line 69 fixes the wrong thing.
The most likely cause — confirmed upstream¶
See §0.1. 7cbc6079 is the stale-context bug and its description matches the repro exactly.
The historical cause, also provable¶
Before da5122f2 (Arend Peter, 2026-04-20, "Use dedicated API hook for public_results toggle and
remove open state UI"; confirmed an ancestor of the local HEAD), the toggle was
<ElectionSwitchSetting settingKey="public_results" … availableDuringElection />.
availableDuringElection kept it clickable after finalize (ElectionSettings.tsx:31), and its
defaultOnToggle (:30) routes through updateElection → POST /Election/:id/edit →
editElectionController.ts:24-27 → 400 "Election is not editable". That is BV230's symptom
exactly: toggle looks live, click fails. If the reporter tested a build predating 2026-04-20, this is
the whole answer and the issue closes as fixed.
Three remaining live candidates if it still reproduces on main¶
The mechanism most agents converged on — updateElectionStateIfNeeded consuming the client's OCC token
inside the POST — is real code but does not close for the plain repro. Every link verified: it runs
from electionPostAuthMiddleware (elections.controllers.ts:74) on every :id route
(Routes/elections.routes.ts:845-847); on a transition it writes at :158; Models/Elections.ts:74-83
throws Conflict('Concurrent write detected, please try again') when the head-row update matches zero
rows; expectUpdateDate reads the client-supplied req.body.expected_update_date
(controllerUtils.ts:52). But PublishAndShare.tsx:46-47 does
await enqueueWrite(… finalize …); await fetchElection(); — that GET performs the finalized→open
transition itself and returns the post-transition row, and ElectionContextProvider.tsx:68-72 advances
latestUpdateDate from it. So the plain create→finalize→toggle path should present a fresh token and
succeed.
The 409 is deterministic for a scheduled election: finalize while start_time is still future
(state stays finalized, client caches that version), then toggle at or after start_time with no
intervening GET — ElectionContextProvider's fetch effect keys on [id], so in-election route changes
do not refetch. It is also reachable via sendEmailController.ts:134-136, which bumps
email_campaign_count using the server's version and returns no election, while
ViewElectionRolls.tsx:54-68 fires it outside enqueueWrite and never refreshes — and
PublishAndShare.tsx:59-61 navigates the admin straight to /admin/voters after finalizing an email
election.
Candidate 2: role. canEditElectionState = [system_admin, owner] (permissions.ts:25) excludes
admin, yet ElectionSettings.tsx has no permission check and the Settings route/nav are ungated
(Admin.tsx:37, Sidebar.tsx:27). A co-admin gets 401 on this one switch while every other setting
works for them in draft, and useOptimisticToggle.ts:26-31 reverts silently. This presents
identically to the 409 and is the cheapest thing to check first.
Candidate 3: whole-election revalidation. updateElection runs electionValidation(election) over
the entire object (Models/Elections.ts:62), which reaches settingsCompatiblityValidation
(ElectionSettings.ts:44-56). Any row carrying an invalid ballot_updates combination — creatable
before that check existed — makes every election write fail with a 400 whose message is about
ballot updates and never mentions results. Permanently bricked, maximally confusing.
The repro to run, and what to instrument¶
Against main, not this branch. Create an election with preliminary results OFF, as the owner,
with no start_time/end_time, finalize via Publish & Share, go to /{id}/admin/settings, flip
the switch, then navigate away and back. With the network tab open, capture the
POST /API/Election/:id/setPublicResults status and error body. useFetch.ts:39-41 embeds both in
the snackbar verbatim, so even a screenshot decides it:
- 409 Concurrent write detected, please try again → OCC
- 401 Does not have permission → role
- 400 expected_update_date is required or ballot-updates text → validation
- 200, but the switch reverts after navigating away → 7cbc6079, already fixed on main
Then repeat with a start_time set 5 minutes out, and repeat as a co-admin.
The fix, whichever candidate lands¶
Give setPublicResults a server-side version read instead of the client token, following
setWriteInResultsController.ts:85-89 and claimElectionController.ts:34-38, both of which re-read
the election and use election.update_date with an explanatory comment. public_results is a single
boolean with no read-modify-write of client state, so it does not need cross-client conflict detection
— and the stale-branch form of this controller (pre-de0dc930) did exactly that, so the change is a
revert-in-spirit rather than a novelty. Add the permission gate to ElectionSettings.tsx:107-111
(hide or disable with disabledMessage unless canEditElectionState) so a co-admin is not shown a
switch that always 401s. Add try/catch to useOptimisticToggle.ts:23-32 so a thrown commit reverts
instead of leaving the UI silently disagreeing with the DB. And add the two tests that do not exist: a
backend test that finalizes then POSTs true and false expecting 200 both times, and a Playwright spec
that finalizes then flips the switch and asserts it survives a reload (full-runthrough.spec.ts:51-57
has the aria-pressed settle-point idiom).
6. The conflict that has to be resolved first¶
C says the toggle must work after finalize. B Option B says it must not. And the sentence BV230 leans
on — "Administrators can make results public at any time" — does not exist in the product (§0.4).
A grep across packages/frontend/src/i18n/*.yaml and docs/help/*.md for "at any time" returns one
hit, docs/help/preliminary_results.md:46, and it is about turning the setting off. So the bug
report's "Expected" is grounded in a document, not in shipped copy.
The two directions are not the same risk, and that asymmetry is the resolution.
Turning results ON after finalize makes the election more transparent to everyone
simultaneously. It is a visible, one-way act: docs/help/preliminary_results.md:48 already tells
voters that revealing cannot be undone, and the repo makes that literally true — CSV/JSON export
(BallotDataExport.tsx:46), print styling (ViewElectionResults.tsx:34), one-click social share
(:96-107), and an Disallow:-empty robots.txt all ensure a revealed tally persists outside the
app. Nobody gains a private advantage from it. It is also the direction the product defaults to
(Wizard.tsx:54).
Turning results OFF after having them on is the move that creates a private information
asymmetry: peek, then hide. Combine it with the roll's exact per-voter submit timestamps
(castVoteController.ts:106-110, preserved through sanitizeHistory) and it is the
screenshot-before-and-after monitoring vector the BPML doc describes. That is the risk #1353 Option B
actually names, and it is entirely in the ON→OFF direction.
Recommendation: a one-way ratchet, plus the audit log. After state !== 'draft', permit
false → true and forbid true → false. This satisfies BV230's literal repro (create INACTIVE →
finalize → set ACTIVE), blocks the peek-then-hide vector, and keeps
docs/help/preliminary_results.md:50 ("turning preliminary results on mid-election — is also
possible") true while requiring :46 ("can turn it off at any time") to be narrowed to draft. If the
ratchet is judged too clever, then the fallback is: fix C as written (fully bidirectional) and treat
Option A's audit log as a hard prerequisite rather than a parallel feature — because with a
bidirectional toggle the audit log is the only thing that makes a peek observable at all. Do not ship
Option B as written; it forbids the harmless direction to stop the harmful one.
Whichever way it goes, docs/help/preliminary_results.md:46-50 moves in the same PR as the code.
docs/ ships to docs.bettervoting.com from main independently of the frontend deploy, so a doc that
contradicts the product can stay live indefinitely.
7. The privacy analysis nobody wrote down¶
Three structural facts first, because they reframe the hazard.
(i) Preliminary results publish every ballot, not a tally. With the flag on,
getAnonymizedBallotsByElectionIDController.ts:17-31 applies no permission check and no state
check — the complete cast-vote record, {ballot_id, election_id, precinct, votes} per ballot, is
readable by anonymous callers in any state, including draft, which is the wizard default. No rate
limiting exists anywhere in the backend, and the only credential is a 6-character id from a 24-symbol
alphabet generated with Math.random() (shared/src/utils/makeID.ts:7,36-38).
(ii) ballot_id is stable across edits and it is published. castVoteController.ts:86 reuses the
prior ballot_id on an update, and BallotDataExport.tsx:46 makes it column 1 of the public CSV. So
with edit-vote on, an observer does not diff aggregates — they watch one row's scores change. That is
strictly stronger than the delta attack the help article describes, and it sits awkwardly beside
castVoteController.ts:275-279, which scrubs ballot_id from the cast response specifically "to
prevent voters from creating receipts (vote buying/coercion)" — while EmailTemplates.ts:91 emails the
voter that same id.
(iii) secureShuffle does not defend against this. controllerUtils.ts:65-76 randomizes order
within one response (its comment says so: to stop zipping against roll timestamps). It does nothing
about fetching twice and diffing. Do not let a reviewer see it and conclude the threat is handled.
And edit-vote implies closed list. settingsCompatiblityValidation (ElectionSettings.ts:44-56,
enforced on every write via :103) rejects ballot_updates unless voter_access != 'open' and
invitation == 'email'; the only MODE_SHAPES entry with invitation: 'email' is
closed_bv_managed_ids (VoterAuthenticationMode.ts:29). So ballot_updates: true is legal in
exactly 1 of 6 canonical modes, and that mode is always a closed list. #1350's "extra closed-list
layer" and BV230's edit-vote cross-reference are the same population, not two independent axes.
Hazard table¶
Edits require state === 'open'; the public tally is served in draft/open/closed; finalized has no
ballots (finalize deletes them); archived accepts none. So the hazard lives in open (plus draft
for test ballots).
| # | public_results | edit-vote | access | electorate | state | Risk | Warning |
|---|---|---|---|---|---|---|---|
| 1 | OFF | any | any | any | any | Safe. getElectionResultsController.ts:25-28 Forbids everyone during open, incl. the owner; getAnonymizedBallots 401s |
none |
| 2 | ON | OFF | open | large | open | Low. No per-voter list to attribute deltas to | voter disclosure (base) |
| 3 | ON | OFF | open | small | open | Moderate. Any ballot cast in a quiet window is fully revealed by the delta; the attacker just needs to arrange the timing out-of-band. Small electorates are hazardous independent of edit-vote | voter disclosure (base) |
| 4 | ON | OFF | closed | any | open | High. Roll gives Has Voted per named voter (EditElectionRoll.tsx:157) + exact timestamped submit history (castVoteController.ts:106-110); public tally gives the delta. One shot per voter, no cooperation needed. This is preliminary_results.md:26 verbatim |
admin-time warning + voter closed-list layer |
| 5 | ON | ON | closed (only legal combo) | any | open | Highest. Adds (a) repeatability — each edit is a fresh confirming sample; (b) manufactured quiet windows — sendEmailController.ts:33 target: 'single' is a built-in nudge-one-voter button, so the attacker schedules the window instead of waiting; (c) a stable pseudonymous key per (ii), so no aggregate arithmetic is needed |
admin-time warning, strongest + voter layer |
| 6 | flipped ON mid-election | any | any | any | open | High. No state guard, so flipping at time t exposes the whole accumulated tally at once and retroactively validates every prior nudge | audit log (B) — copy alone cannot cover this |
| 7 | ON | ON | closed | any | closed | Residual. Edits refused (castVoteController.ts:245), but the full CVR with stable ballot_ids stays publicly downloadable |
none new |
Admin-time warning (rows 4, 5) belongs in ElectionSettings.tsx:103-111, the only screen where
both switches are visible together, fired on election.settings.ballot_updates && publicResults and
escalated for closed lists. The helper-text slot exists but only renders when disabled
(components/util.tsx:416-420), so this needs either a warningMessage prop on SwitchSettingProps
(util.tsx:375-381) or a sibling ElectionStateWarning. Do not block the combination —
preliminary_results.md:42 states the maintainers' position: "BetterVoting will let you enable both,
but you should do so deliberately." While there, wire up the dead
en.yaml:689 disabled_msgs.ballot_updates_when_open so the email-list-only rule stops surfacing only
as a server 400.
Voter-time disclosure (rows 2–5) is the VotePage notice from §3. Row 6 is the case copy cannot
fix, and it is the strongest argument for landing B before or alongside A: a disclaimer that implies
the setting is fixed is a false promise while setPublicResults has no state guard. The disclaimer
should say the setting can change mid-election and point at the audit log.
Adjacent findings — held back pending disclosure. Reading this area surfaced several issues outside
1350's scope that touch data access rather than copy. They are deliberately not listed here,¶
because this repo is public and they have not yet been reported to the maintainers. They are held locally and will be raised on the #bettervoting Slack channel or directly with Arend, one at a time, with an "is this deliberate?" framing — most have a plausible innocent explanation, and leading with an accusation on a volunteer open-source project is both rude and likely wrong. Once reported, whatever the maintainers are comfortable publishing moves into this file.
The one that is in scope for #1350 and has already been raised: the help article's own claim at
docs/help/preliminary_results.md:26 — "BetterVoting hides the link between a voter and their specific
ballot" — is true of the API and needs qualifying for the deployment. That's in the posted #1350
comment.
8. Sequencing¶
Independently landable today, no upstream decision needed:
- A1 — the copy edit +
learn_link+ deleteen.yaml:209.packages/frontend/src/i18n/en.yamlonly. Trivial, en-only, no translation obligation (PRIORITY 4), no test asserts on it. - The C diagnostic repro (§5), against
main. Costs nothing, decides everything about C, and must precede any C code. - The missing backend tests for the three read gates and the toggle —
preliminaryResults.test.tsmodelled oneditElection.test.ts:117-157, plus a draft fixture with races intestInputs.ts(none exists that is draft + has races + namespublic_results). Write these before any fix so B Option B and C cannot silently undo each other.
Needs an upstream decision from Arend before code:
- The §6 ratchet-vs-bidirectional call. Blocks both C's fix and any B Option B work, and
determines the wording of both
en.yaml:1169's tip anddocs/help/preliminary_results.md:46-50. - A2 — the wording of the closed-list warning. "An admin may be able to work out how you voted" is a strong claim on Equal Vote's own product on the ballot page. Engineering should not sign that off, and per §3 the honest version differs from the issue's framing.
- B — review
origin/JacksonLoper/publicaudit(PR #1365) and add the beta flag Arend asked for on 2026-05-18 and which is still absent (noFeatureFlag/isSetreference in the branch's new frontend files; noHISTORY/AUDITkey inflagDefinitions). This is the fastest path to unblocking the PR. Do not propose building an audit log.
Then:
- A3 — the
VotePagenotice + submit-dialog sentence + the closed-list layer, once (5) lands. - C's fix (server-side version read + permission gate +
useOptimisticToggletry/catch), once (4) lands — and only if the repro still fails onmainafter7cbc6079. - The admin-time combination warning from §7.
Mechanical PR requirements¶
- Migrations: none needed for any of A, B-as-Option-A, or C. If one is ever added it goes in
packages/backend/src/Migrations/asYYYY_MM_DD_snake_name.tssorting after2026_04_27_unique_head.ts(FileMigrationProviderorders by filename,Migrators/migration-utils.ts:16), with a genuinely inversedown(), andnpm run build -w @equal-vote/star-vote-backendmust precedemigrate:latestbecause the runner executes./build/. - Generated artifacts: commit nothing.
.gitignore:18ignorespackages/backend/src/OpenApi/swagger.jsonand:6ignores**/dist;git ls-filesconfirms neither is tracked. The only swagger deliverable for a new route is the@swaggerJSDoc block above the route line inRoutes/*routes.ts(the glob atOpenApi/swaggerSpec.ts:59).packages/shared/distmust be built before the backend runs (swaggerSpec.tsthrows "Could not find shared module") but never committed. - Shared package: a frontend-only beta flag needs no shared change. Adding a new setting to
shared/src/domain_model/ElectionSettings.tswould require rebuildingdist+dist/schema.json— which is exactly thestrict_ballot_privacychange Arend told #1365 to strip out. - i18n:
tips:/election_settings:edits are en-only (es/pl/pt-BR carry neither block). New PRIORITY 0 keys create real translator work.!tip()requires a leading space (util.tsx:27) and is greedy — one tip per string, and no)may follow it, so it cannot be combined with a markdown link in the same value.TipName = keyof typeof en.tipsprovides no real safety (styles.tsx:11-13documents the vite-plugin-yaml issue), so a misspelled tip name fails silently at runtime. - PR shape: conventional title
<type>(scope): <desc>perdocs/contributions/developers/2_how_to_open_a_pull_request.md, baseEqual-Vote/bettervotingmain,Closes #NNNN, squash-merged. #1350 is currently labelledRole: Missing/Complexity: Missing, i.e. never triaged — it should probably be split four ways (docs link / copy / on-ballot notice / closed-list layer). CI runsnpm run build -ws && npm test(backend jest only — no frontend suite, no lint) plus the Playwright E2E job, both onmainonly, so neither runs on PRs targeting a feature branch. - Git hygiene for this checkout:
originhere isgithub.com/Equal-Vote/bettervoting.git. The fork's ownCLAUDE.md:7forbids pushing toEqual-Vote/*without explicit confirmation, so a habitualgit push origin <branch>would violate it. Push by full fork URL (masiarek/star-server). Also: rebase ontomainfirst — the checkout is 98 commits behind.
9. Open questions for upstream¶
- Which build did the BV230 reporter test? If it predates
7cbc6079(2026-07-27) the stale-context bug is the answer; if it predatesda5122f2(2026-04-20) the cause iseditElectionController.ts:26. Either way the issue may close as fixed. Cheapest, highest-value question. - What status code and
errorstring did the tester see? 401 / 400 / 409 / 200-then-revert map to four different fixes (§5) and the snackbar text alone decides it. - §6: ratchet, or fully bidirectional plus a mandatory audit log? Blocks C's fix, B Option B, and
the wording of two docs. Note the source of "at any time" is
docs/help/preliminary_results.md:46, not shipped UI copy, and it is about turning results off. - Should
getAnonymizedBallotsreally be unauthenticated in any state when the flag is on? Publishing the full raw CVR — including indraft, where the flag is the wizard default — is either a deliberate transparency feature or an oversight. The answer changes what the #1350 article must say, and I would not change the endpoint unilaterally:ballot_idis a documented hand-count/tiebreak affordance (docs/help/ties.md) and the CSV's join key for third-party recounts. - Does the #1350 disclaimer belong in the submit dialog as well as the ballot banner? The spec says "on the BALLOT", which reads as the banner; the modal is what actually satisfies "before they cast". I recommend both, one sentence in the modal — but the extra friction is a product call.
- What exactly may the closed-list warning claim? Per §3 the honest version is "admins see who
voted, and combined with a live tally, timing can reveal how" — not "admins can see your ballot".
Someone with authority over messaging should sign the wording, and the same person should decide
whether
docs/help/preliminary_results.md:26gets qualified given the Matomo path-segment issue. - B: is an actor-less audit log acceptable?
reasonand actor are both unrecorded (Models/Elections.ts:46,60). Actor-less ships now with no migration; adding an actor is a schema change and a new privacy-relevant data flow. - B: should the history endpoint be public for elections that are not themselves public?
is_publicandsettings.public_resultsare different flags. An unauthenticated/historyon a private draft leaks title/state/setting churn for an election never meant to be visible — and PR #1365's own open question notes turnout milestones leak even when an admin deliberately hid preliminary results. - Is #1365's beta flag still wanted, and frontend-only or endpoint too? Frontend-only leaves the endpoint reachable by URL during beta.
- Do any production rows carry the invalid
ballot_updatescombination that predatessettingsCompatiblityValidation? On those rows every election write, including the results toggle, fails permanently with a 400 about ballot updates. A one-off DB query confirms or kills BV230 candidate 3.
10. Unknowns / needs a live repro¶
- Whether the BV230 repro still fails on
mainafter7cbc6079. Not run — no stack was started. This is the single most important open item and it is a five-minute manual test. - Whether
editElectionController.ts:24's client-suppliedstateread is actually bypassable. It readsreq.body.Election.stateandupdateElectioninserts the body verbatim, so postingstate: 'draft'on a finalized election appears to slip past. Not executed — treat as unverified, but any new guard must key offreq.election.stateregardless. - PR #1365's current labels and review threads. Branch contents verified locally (
9129ea42, 12 files, +912/−14, no migration, noisSetin the new frontend files); GitHub was not queried. - That
https://docs.bettervoting.com/help/preliminary_results.htmlresolves. The file, its front matter (nav_order: 8), anddocs/CNAMEare confirmed in-tree onorigin/main; the URL shape is inferred from the existingApp.tsxdocs redirects, not fetched. - Whether any production election has
ballot_updates: trueat all. The wizard hardcodesfalse(Wizard.tsx:53) and the full-editor switch has no client-side compatibility gate, so flipping it on an open-access election 400s and silently reverts. If the answer is "essentially none", row 5 of the hazard table is theoretical for now and A's sequencing should reflect that. - Exact line numbers on
main. The map was built against the June checkout;en.yamlkeys have shifted by ~6 lines (preliminary_resultsis:1173onmain,:1167locally). Re-anchor before editing.
Produced read-only: 12 agents (9 subsystem explorers → adversarial verify + completeness critic →
synthesis), 220 raw integration points, plus a direct upstream-delta check against a fresh
git fetch. No files in the BetterVoting fork were modified.