Polls where nobody can tell who voted for what

The secure-aggregation core has been in the engine since it was written and
nothing used it: there was no way to ask a poll, vote in one, or see a
result. This is the rest.

`PollBook` is the sequencing, deliberately the same shape as the iOS one. A
poll crosses between the two engines, so a rule enforced on one side and not
the other is a poll that tallies differently depending on who is looking at
it. No sum is published until every member has voted: a member who has not
voted has handed nobody a piece, so nobody holds a complete set, and a sum
published early is noise rather than a partial answer.

Asking a poll sends the same envelope to the group. Voting does not: each
piece is addressed to the member it belongs to and sent to them alone. That
is the one group envelope in the app which is not the same bytes to
everybody, and a test pins it, because sending one share to the group would
let any member subtract it from the tally and read the vote, with the counts
still coming out right and nothing at runtime to notice.

Kept in the vault, or a poll could never finish: it waits for everybody, and
the pieces gathered would be gone the first time the app closed.

The card says what the maths actually gives. Everyone else would have to work
together to know how one person voted, never that nobody can know. A poll of
two is not anonymous at all, because the other person holds a piece of the
vote and sees the tally, so the word is not offered for a pair and the card
says why.

22 new tests. 647 in the engine and 430 in the client, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-12 10:10:01 -04:00
co-authored by Claude Opus 5
parent 13f55076b9
commit 315e6d328f
10 changed files with 1085 additions and 3 deletions
@@ -0,0 +1,216 @@
package io.flamenet.e2e
import org.json.JSONObject
import java.util.Random
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Running a whole poll between three people, without a relay (§6.29).
*
* The sequencing is the part that goes wrong: a sum published before everybody
* has voted is not a partial answer, it is noise, and a piece counted twice is
* a wrong count that nothing else would catch.
*/
class PollBookTest {
private val members = listOf(4L, 7L, 9L)
private fun book(): PollBook =
PollBook().apply {
remember("p1", "g1", "Lunch?", listOf("Thai", "Pizza"), PollBook.memberSet(members))
}
private fun rng(seed: Long) = Random(seed)
/** Everybody votes, the pieces travel, everybody publishes. */
@Test
fun `a whole poll between three people`() {
val books = members.associateWith { book() }
val choices = mapOf(4L to 1, 7L to 0, 9L to 1)
for (voter in members) {
val outgoing = books[voter]!!.vote("p1", voter, choices[voter]!!, rng(voter))
assertEquals(members.size - 1, outgoing.size, "my own piece never leaves")
for ((member, piece) in outgoing) {
assertTrue(books[member]!!.receivePiece("p1", voter, piece))
}
}
val sums = members.associateWith { assertNotNull(books[it]!!.sumToPublish("p1", it)) }
for ((publisher, sum) in sums) {
for (member in members) {
if (member != publisher) {
assertTrue(books[member]!!.receiveSum("p1", publisher, sum))
}
}
}
for (member in members) {
val standing = books[member]!!.poll("p1")!!.standing
assertTrue(standing is PollBook.Standing.Counted, "$member has no tally")
assertContentEquals(
intArrayOf(1, 2),
(standing as PollBook.Standing.Counted).counts,
"one Thai, two Pizza",
)
}
}
/** The rule the interface depends on. */
@Test
fun `no sum is published before everybody has voted`() {
val mine = book()
mine.vote("p1", 4L, 0, rng(1))
assertNull(mine.sumToPublish("p1", 4L), "only my own vote is in")
val other = book()
val from7 = other.vote("p1", 7L, 1, rng(2))
mine.receivePiece("p1", 7L, from7.first { it.first == 4L }.second)
assertNull(mine.sumToPublish("p1", 4L), "still nothing from 9")
val third = book()
val from9 = third.vote("p1", 9L, 1, rng(3))
mine.receivePiece("p1", 9L, from9.first { it.first == 4L }.second)
assertNotNull(mine.sumToPublish("p1", 4L), "now every member has voted")
}
/** And it says who it is waiting on. */
@Test
fun `it says who it is waiting for`() {
val mine = book()
mine.vote("p1", 4L, 0, rng(1))
val standing = mine.poll("p1")!!.standing
assertTrue(standing is PollBook.Standing.WaitingForVotes)
assertEquals(listOf(7L, 9L), (standing as PollBook.Standing.WaitingForVotes).from)
}
@Test
fun `the sum is published only once`() {
val mine = book()
mine.vote("p1", 4L, 0, rng(1))
for (voter in listOf(7L, 9L)) {
val out = book().vote("p1", voter, 1, rng(voter))
mine.receivePiece("p1", voter, out.first { it.first == 4L }.second)
}
assertNotNull(mine.sumToPublish("p1", 4L))
assertNull(mine.sumToPublish("p1", 4L), "a second publish would double the count")
}
/** Redelivery is the failure that shows up as a tally quietly being wrong. */
@Test
fun `a redelivered piece is ignored`() {
val out = book().vote("p1", 7L, 1, rng(2))
val piece = out.first { it.first == 4L }.second
val mine = book()
assertTrue(mine.receivePiece("p1", 7L, piece))
assertFalse(mine.receivePiece("p1", 7L, piece), "counted twice")
}
@Test
fun `a redelivered sum is ignored`() {
val mine = book()
assertTrue(mine.receiveSum("p1", 7L, intArrayOf(3, -3)))
assertFalse(mine.receiveSum("p1", 7L, intArrayOf(3, -3)))
}
@Test
fun `a stranger cannot join the arithmetic`() {
val mine = book()
assertFalse(mine.receivePiece("p1", 999L, intArrayOf(1, 0)))
assertFalse(mine.receiveSum("p1", 999L, intArrayOf(1, 0)))
}
@Test
fun `a vector of the wrong width is refused`() {
val mine = book()
assertFalse(mine.receivePiece("p1", 7L, intArrayOf(1, 0, 0)))
assertFalse(mine.receiveSum("p1", 7L, intArrayOf(1)))
}
/** Both engines index the pieces the same way, whatever order was learned. */
@Test
fun `the member set is order independent`() {
assertEquals("4,7,9", PollBook.memberSet(listOf(9L, 4L, 7L)))
assertEquals("4,7,9", PollBook.memberSet(listOf(9L, 9L, 4L, 7L)))
assertEquals(listOf(4L, 7L, 9L), PollBook.members("4,7,9"))
}
@Test
fun `a redelivered poll does not reset it`() {
val mine = book()
mine.vote("p1", 4L, 0, rng(1))
assertFalse(
mine.remember(
"p1",
"g1",
"Lunch?",
listOf("Thai", "Pizza"),
PollBook.memberSet(members),
),
)
assertEquals(0, mine.poll("p1")!!.myChoice, "the vote survived")
}
@Test
fun `voting twice changes nothing`() {
val mine = book()
val first = mine.vote("p1", 4L, 0, rng(1))
val second = mine.vote("p1", 4L, 1, rng(1))
assertTrue(first.isNotEmpty())
assertTrue(second.isEmpty(), "a second vote must send nothing")
assertEquals(0, mine.poll("p1")!!.myChoice)
}
/**
* A poll has to survive being closed, or it could never finish: it waits
* for every member, and the pieces already gathered would be gone.
*/
@Test
fun `it survives a round trip through storage`() {
val mine = book()
mine.vote("p1", 4L, 1, rng(1))
val out = book().vote("p1", 7L, 0, rng(2))
mine.receivePiece("p1", 7L, out.first { it.first == 4L }.second)
val back = PollBook()
back.restore(JSONObject(mine.archive().toString()))
val e = assertNotNull(back.poll("p1"))
assertEquals("Lunch?", e.question)
assertEquals(listOf("Thai", "Pizza"), e.options)
assertEquals(members, e.members)
assertEquals(1, e.myChoice)
assertContentEquals(
mine.poll("p1")!!.piecesHeld[7L],
e.piecesHeld[7L],
"the piece somebody handed us has to come back exactly",
)
assertContentEquals(mine.poll("p1")!!.piecesHeld[4L], e.piecesHeld[4L])
}
/** And the negative values in a share have to survive it too. */
@Test
fun `storage keeps the sign and the extremes`() {
val mine = book()
mine.receiveSum("p1", 7L, intArrayOf(Int.MIN_VALUE, Int.MAX_VALUE))
val back = PollBook()
back.restore(JSONObject(mine.archive().toString()))
assertContentEquals(
intArrayOf(Int.MIN_VALUE, Int.MAX_VALUE),
back.poll("p1")!!.sumsPublished[7L],
)
}
/** One member is not a poll. */
@Test
fun `a poll needs at least two members`() {
val b = PollBook()
assertTrue(b.remember("p2", "g1", "?", listOf("a", "b"), "4,7"))
assertFalse(b.remember("p3", "g1", "?", listOf("a", "b"), "4"))
}
}