#1117 — the sandbox accepts a score the tabulator will throw away¶
Fixed in Equal-Vote/bettervoting#1522 (2026-08-15). Issue: #1117.
The finding¶
Typing a 6 into a STAR ballot on https://bettervoting.com/sandbox produced results with no message. The issue calls this "silently fails", which undersells it — the failure is not that the 6 was accepted, it is what happens next.
The chain: Sandbox.tsx's getResults() validates only ballot length, never score values → POST /API/Sandbox → sandboxController.ts validates only the voting method → the tabulator's makeBoundsTest (Tabulators/Util.ts:89-94) drops the entire ballot and records it as nOutOfBoundsVotes, which nothing on the page displays.
So the sandbox shows a correct count of a smaller election than the one that was typed in. Same family as #1487 and the flat-ballot cases: a number computed over a quietly reduced ballot set, with the reduction invisible.
Why a naive fix is wrong¶
"Reject anything above 5" would be wrong for most of the sandbox's own menu. The ranges belong to the methods, and they come from the tabulators:
| Method | Bounds test | Valid range |
|---|---|---|
| STAR | Star.ts:12 |
0–5 |
| STAR-PR | AllocatedScore.ts:25 |
0–5 |
| Approval | Approval.ts:13 |
0–1 |
| Plurality | Plurality.ts:14 |
0–1 |
| Ranked Robin, IRV, STV | RankedRobin.ts:13, IRV.ts:36 |
rankings, bounded by max_rankings |
The sandbox controller passes no election settings, so for the three ranked methods the bound resolves to Infinity — with six candidates a rank of 6 is a legitimate mark. Ballot.ts deliberately does not cap ranks at the candidate count either, with a comment saying so ("that's not necessarily true for public RCV elections").
The fix therefore carries a per-method table and leaves ranked methods alone except for negatives.
The test, and proof it isn't vacuous¶
testing/tests/sandbox.spec.ts — the first test in the suite to visit /sandbox at all. Two cases:
- STAR with
5,4,3,2,6shows the error, and correcting to5,4,3,2,1clears it. - IRV with six candidates and ranks
1,2,3,4,5,6shows no error — the case a flat rule breaks.
Run against origin/main's Sandbox.tsx: test 1 fails, test 2 passes. That is the right shape — the first proves the fix does something, the second proves the fix doesn't over-reach and would have passed before too.
What review caught that the first draft got wrong¶
Four defects, none exotic, all of which survived my own first reading of the patch:
| Found by | Defect |
|---|---|
| adversarial pass | validation ran after parseInt, so 2.5 became 2 and the submitted ballot was not the ballot typed |
| adversarial pass | a trailing newline's "wrong length" complaint overwrote the score error above it — the very message the patch adds |
| adversarial pass | x:5,4,3 reaches Array(NaN), which throws out of an async effect with no handler and freezes the previous error on screen |
| CodeRabbit, on the PR | the submission still used parseInt while the check used Number: 1e2 validated as 100 (a legal rank) and submitted as 1, and a 2.5: repeat count was truncated to 2 before Number.isInteger saw it |
The last one is the interesting one, because it is the same bug as the first at one remove — two conversions of the same text, disagreeing. Fixing the check without fixing the submission just moved the seam. One conversion, Number, everywhere now.
(CodeRabbit's 0x1 example in that comment is wrong — parseInt('0x1') is 1, since the hex prefix is auto-detected without an explicit radix. The two real cases stand.)
Provenance¶
| Claim | How established |
|---|---|
| No score validation in the sandbox or its controller | read from source — Sandbox.tsx getResults(), sandboxController.ts |
| Out-of-bounds ballots are dropped, not clamped | read from Tabulators/Util.ts:89-94 (makeBoundsTest) and filterInitialVotes |
| The per-method range table | read from the five makeBoundsTest call sites |
| Ranked methods unbounded in the sandbox path | read — sandboxController.ts passes no electionSettings |
| Error appears, and clears | executed — Playwright, local stack, desktop 1280 and mobile 390 |
| Tests are non-vacuous | executed — same spec against origin/main's component |