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 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-09 20:47:23 +00:00
commit 55b7f79c13
3 changed files with 73 additions and 15 deletions

View file

@ -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:<RevealSeq>` 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:<RevealSeq>` 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 =

View file

@ -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<SealedMotionState> 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(

View file

@ -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<SealedBallotException>(() => rig.Reveal(a, motion.Id, "Ana", "Mine.", Salt + "\n"));
Assert.Equal(SealedBallotFailure.Invalid, ex.Failure);
Assert.Null((await rig.Db.SealedBallots.SingleAsync()).RevealedAtUtc);
}
}