From 55b7f79c13b9e26383f7a98054d5beae6b60b33e Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 9 Nov 2026 20:47:23 +0000 Subject: [PATCH] Sealed ballot: retry a reveal that loses a RevealSeq race while the window is open, \z in hex patterns, doc the contended 409 Co-Authored-By: Claude Sonnet 5.5 --- docs/sealed-ballot-motion.md | 2 +- .../Features/Motions/SealedBallotService.cs | 46 +++++++++++++------ tests/Bifrost.Web.Tests/SealedBallotTests.cs | 40 ++++++++++++++++ 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/docs/sealed-ballot-motion.md b/docs/sealed-ballot-motion.md index 6b252b0..35ce1e8 100644 --- a/docs/sealed-ballot-motion.md +++ b/docs/sealed-ballot-motion.md @@ -16,7 +16,7 @@ publication at close, no mediator. Code: `Features/Motions/SealedBallotService.c `SealedBallotCloser` sweep if nobody reads, writes status `Closed` and a digest of all commitments, then returns every revealed ballot (voter, candidate, one-sentence reason, salt, commitment) at once. The close freezes the reveal set: `Motion.RevealSeq` (bumped by every accepted reveal) and `Status` are concurrency tokens, so a reveal racing the close either lands before it (the close re-reads and its digest covers that reveal) or is - refused with 409; the digest covers `reveals:` plus every commitment's revealed/unrevealed state. A reveal after + refused with 409 ("the motion closed"). A conflict from another voter's reveal while the window is open is re-read and retried, up to five times, then answered 409 "contended; send it again"; the same 409 "contended" is what `/result` answers if its close loses five races in a row; the digest covers `reveals:` plus every commitment's revealed/unrevealed state. A reveal after the deadline or after close is refused. Anyone can recompute each commitment from the published fields. Nobody can close early, extend, edit or break a tie. Canonical document (`gamecult.sealed_ballot.v0`): `{schema, motionId, round, candidate, reason, salt}`. Commitment = diff --git a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs index 5e778ad..4517d55 100644 --- a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs +++ b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs @@ -22,8 +22,8 @@ public sealed class SealedBallotService( TimeProvider timeProvider) { public const int MaxReasonLength = 280; - private static readonly Regex HexSha256 = new("^[0-9a-f]{64}$", RegexOptions.Compiled); - private static readonly Regex HexSalt = new("^[0-9a-f]{32,128}$", RegexOptions.Compiled); + private static readonly Regex HexSha256 = new(@"^[0-9a-f]{64}\z", RegexOptions.Compiled); + private static readonly Regex HexSalt = new(@"^[0-9a-f]{32,128}\z", RegexOptions.Compiled); public async Task OpenAsync( CurrentBifrostActor actor, @@ -156,19 +156,37 @@ public sealed class SealedBallotService( throw new SealedBallotException(SealedBallotFailure.Invalid, "The ballot does not match the stored commitment."); } - ballot.RevealedCandidate = candidate; - ballot.RevealedReason = reason; - ballot.Salt = document.Salt; - ballot.RevealedAtUtc = now; - motion.RevealSeq++; - try + // RevealSeq and Status are concurrency tokens. A conflict means either the close froze the set (refuse, truthfully) or + // another voter's reveal landed first while the window is still open (re-read the row and write again). + for (var attempt = 0; ; attempt++) { - await dbContext.SaveChangesAsync(cancellationToken); - } - catch (DbUpdateConcurrencyException) - { - // The close froze the reveal set between the phase check and this write: this reveal is not in it. - throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The motion closed; the reveal window is over."); + ballot.RevealedCandidate = candidate; + ballot.RevealedReason = reason; + ballot.Salt = document.Salt; + ballot.RevealedAtUtc = now; + motion.RevealSeq++; + try + { + await dbContext.SaveChangesAsync(cancellationToken); + break; + } + catch (DbUpdateConcurrencyException error) + { + foreach (var entry in error.Entries) + { + await entry.ReloadAsync(cancellationToken); + } + + if (PhaseOf(motion, now) != SealedPhase.Reveal) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The motion closed; the reveal window is over."); + } + + if (attempt >= 4) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The reveal is contended; send it again."); + } + } } await auditTrailService.RecordAsync( diff --git a/tests/Bifrost.Web.Tests/SealedBallotTests.cs b/tests/Bifrost.Web.Tests/SealedBallotTests.cs index 33b2e30..1c91cb2 100644 --- a/tests/Bifrost.Web.Tests/SealedBallotTests.cs +++ b/tests/Bifrost.Web.Tests/SealedBallotTests.cs @@ -357,4 +357,44 @@ public sealed class SealedBallotTests Assert.Equal(expected, stored.ResultDigest); // the digest covers the reveal that raced the close Assert.Equal(1, await check.AuditEvents.CountAsync(x => x.Action == "sealed.closed")); } + + [Fact] + public async Task Two_simultaneous_reveals_both_land_while_the_window_is_open() + { + var name = $"sealed-two-reveals-{Guid.NewGuid():N}"; + var first = Peer(name, T0); + var (ana, motion) = await SeedPeerWorld(first.Db, first.Service); + var bo = new UserAccount { DisplayName = "Bo", GitHubLogin = "bo", BifrostIdentity = "bo", NormalizedBifrostIdentity = "BO", NormalizedGitHubLogin = "BO" }; + bo.Membership = new Membership { UserAccountId = bo.Id, Status = MembershipStatus.Active }; + first.Db.UserAccounts.Add(bo); + await first.Db.SaveChangesAsync(); + var boActor = new CurrentBifrostActor(true, bo); + var boDoc = new SealedBallotDocument(SealedBallotDocument.SchemaId, motion.Id, 1, "Bo", "Mine too.", Salt); + await first.Service.CommitAsync(boActor, motion.Id, SealedBallotDocument.Commit(boDoc, bo.Id), default); + first.Clock.Now = T0.AddMinutes(90); + + var second = Peer(name, T0.AddMinutes(90)); + _ = await second.Db.Motions.ToListAsync(); // stale RevealSeq 0, window open + + await first.Service.RevealAsync(ana, motion.Id, new SealedBallotDocument(SealedBallotDocument.SchemaId, motion.Id, 1, "Ana", "Mine.", Salt), default); + var receipt = await second.Service.RevealAsync(boActor, motion.Id, boDoc, default); // conflicts on RevealSeq, window still open: must retry, not claim a close + + Assert.True(receipt.Revealed); + await using var check = Peer(name, T0.AddHours(4)).Db; + Assert.Equal(2, await check.SealedBallots.CountAsync(x => x.RevealedAtUtc != null)); + Assert.Equal(2, (await check.Motions.AsNoTracking().SingleAsync()).RevealSeq); + } + + [Fact] + public async Task A_salt_with_a_trailing_newline_is_not_hex() + { + var rig = new Rig(); + var a = rig.Member("Ana"); + var motion = await rig.OpenAsync("Ana", "Bo"); + await rig.Cast(a, motion.Id, "Ana", "Mine.", Salt + "\n"); + rig.Clock.Now = T0.AddMinutes(90); + var ex = await Assert.ThrowsAsync(() => rig.Reveal(a, motion.Id, "Ana", "Mine.", Salt + "\n")); + Assert.Equal(SealedBallotFailure.Invalid, ex.Failure); + Assert.Null((await rig.Db.SealedBallots.SingleAsync()).RevealedAtUtc); + } }