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:
parent
8d6b45ae69
commit
55b7f79c13
3 changed files with 73 additions and 15 deletions
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue