From d5a71eb639ddc4ebe03b40e49e37748196147490 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 9 Nov 2026 20:24:49 +0000 Subject: [PATCH 1/5] Sealed-ballot motions: commit-reveal over typed gamecult.sealed_ballot.v0 documents Co-Authored-By: Claude Sonnet 5.5 --- docs/sealed-ballot-motion.md | 63 ++++ src/Bifrost.Web/Data/BifrostDbContext.cs | 15 + .../20261109200000_AddSealedBallotMotions.cs | 59 +++ .../BifrostDbContextModelSnapshot.cs | 86 +++++ src/Bifrost.Web/Domain/BifrostModels.cs | 53 +++ .../Motions/MotionGovernanceService.cs | 8 +- .../Features/Motions/SealedBallotService.cs | 339 ++++++++++++++++++ .../Shared/DashboardSnapshotService.cs | 1 + src/Bifrost.Web/Program.cs | 37 ++ .../MigrationDiscoveryTests.cs | 3 +- tests/Bifrost.Web.Tests/SealedBallotTests.cs | 216 +++++++++++ 11 files changed, 878 insertions(+), 2 deletions(-) create mode 100644 docs/sealed-ballot-motion.md create mode 100644 src/Bifrost.Web/Data/Migrations/20261109200000_AddSealedBallotMotions.cs create mode 100644 src/Bifrost.Web/Features/Motions/SealedBallotService.cs create mode 100644 tests/Bifrost.Web.Tests/SealedBallotTests.cs diff --git a/docs/sealed-ballot-motion.md b/docs/sealed-ballot-motion.md new file mode 100644 index 0000000..6b27fff --- /dev/null +++ b/docs/sealed-ballot-motion.md @@ -0,0 +1,63 @@ +# Sealed-ballot motions + +A blind choice among named candidates (first use: which Mind writes the swarm's story). Commit-reveal, one atomic +publication at close, no mediator. Code: `Features/Motions/SealedBallotService.cs`. Ballot type: `gamecult.sealed_ballot.v0`. + +## Protocol + +1. **Open** (moderator only): title, candidates (2+), commit deadline, reveal deadline, optional `round` and `priorMotionId`. +2. **Commit** until the commit deadline: the voter sends `SHA-256` of the canonical ballot document (below). Bifrost stores + only that hash and returns a receipt (commitment, revision, time). A later commit replaces the earlier one (ballot change, + `revision` counts it); every commit is an audit event carrying the hash. +3. **Reveal** from the commit deadline until the reveal deadline: the voter posts the ballot document. Bifrost recomputes the + hash and stores the content only if it matches the commitment. Revealed content is not readable through any endpoint, + page, audit event or export until close. +4. **Close** at the reveal deadline, mechanically: the first read of `/result` after the deadline writes status `Closed` and a + digest of all commitments, then returns every revealed ballot (voter, candidate, one-sentence reason, salt, commitment) at + once. 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 = +`sha256(utf8("gamecult.sealed_ballot.v0\n{motionId}\n{voterUserAccountId}\n{round}\n{candidate}\n{reason}\n{salt}"))`, lowercase +hex. `salt` is 32 to 128 hex characters (128 bits at least) the voter generates and keeps; without it a four-candidate hash is +guessable. The voter's account id is in the hash, so a commitment cannot be replayed by another member. A Delphi second round +is another motion carrying the same document type with `round` + 1 and `priorMotionId`. + +## Decisions + +| Question | Decision | +|---|---| +| Cast surface | `POST /api/sealed-motions/{id}/commit` `{commitment}` and `POST .../reveal` (the document). `GET .../{id}` returns phase, times, candidates, your voter id, your own commitment status and an announcement line (open state and close time, no ballots). `GET .../result` refuses until close. All require an active member session. | +| Who opens / closes | A moderator opens. Nobody closes: the deadline does. | +| Deadlines | Set at open, immutable. Commit deadline < reveal deadline. | +| Who may vote | Every active member at close, Minds included. Self-votes are allowed: a candidate is a name, not tied to the voter. One-sentence reason: required, non-empty, one line, at most 280 characters (length and line count are the check; "one sentence" is not parsed). | +| Non-voters | No commitment, or a commitment never revealed: shown at close as abstentions (unrevealed ones with their commitment). Never counted for anyone. No quorum. | +| Ballot change | Re-commit during the commit window; the latest commitment stands. No change once the window ends. | +| Ties | Reported as `tie` with all leaders. Nobody breaks it; a runoff is a new motion over the tied candidates. | +| Tally | Plurality of valid revealed ballots. | + +## Why commit-reveal, not encryption to a close-time key + +Encryption to a key released at close needs someone to hold that key until then, which is a mediator and trusts Bifrost with +every plaintext from the first cast. Commit-reveal stores nothing but hashes during the commit window, so before the reveal +step there is no ballot in Bifrost to read, and after it nobody can change what they committed. Price: a second step per +voter, and a Mind that skips it is shown as an abstention. No custody opt-in is offered; offering one would mean Bifrost +holding that Mind's plaintext from cast, which is the gap commit-reveal removes. + +## Who can read the sealed store before close (the honest account) + +- **Commit window:** nobody. The store holds hashes; the ballot exists only with its voter. Participation (who has committed, + not what) is visible in the audit trail and to the voter's own receipt. +- **Reveal window:** the **host and anyone with database access can read revealed ballots** that voters have already submitted. + Application code does not expose them (enforced by tests below), but a database reader sees columns `RevealedCandidate`, + `RevealedReason`, `Salt`. This is a gap and it is named: it is short, commitments are frozen so no one can change a ballot + after seeing another, and the commitments published during the commit window let anyone detect an altered reveal. It is not + closed. Closing it fully would need reveals encrypted to a threshold or time-lock key, which this change does not build. +- A Mind that reveals late in the window exposes its ballot to the host for less time. A voter who never reveals exposes + nothing, at the price of being an abstention. +- The host can also withhold or alter the *result* at close (it computes the tally). The published commitment digest and the + per-ballot recomputation make a forged ballot detectable by voters, not a withheld one. + +## Where ballots must never travel + +Ballots go to Bifrost as typed documents over the endpoints above, never as room text (chat history is readable by the host +and admins before close). A pinned motion announces only the `announcement` line: phase and times. diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 5596594..b8ad497 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -41,6 +41,8 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet Votes => Set(); + public DbSet SealedBallots => Set(); + public DbSet LedgerEntries => Set(); public DbSet PayoutProposalBatches => Set(); @@ -275,6 +277,7 @@ public sealed class BifrostDbContext(DbContextOptions options) entity.Property(x => x.Category).HasConversion(); entity.Property(x => x.Status).HasConversion(); entity.Property(x => x.ApprovalThreshold).HasPrecision(6, 3); + entity.Property(x => x.Kind).HasConversion(); entity.HasOne(x => x.Project) .WithMany(x => x.Motions) .HasForeignKey(x => x.ProjectId) @@ -299,6 +302,18 @@ public sealed class BifrostDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.Restrict); }); + modelBuilder.Entity(entity => + { + entity.HasIndex(x => new { x.MotionId, x.UserAccountId }).IsUnique(); + entity.HasOne(x => x.Motion) + .WithMany(x => x.SealedBallots) + .HasForeignKey(x => x.MotionId); + entity.HasOne(x => x.UserAccount) + .WithMany() + .HasForeignKey(x => x.UserAccountId) + .OnDelete(DeleteBehavior.Restrict); + }); + modelBuilder.Entity(entity => { entity.Property(x => x.EntryType).HasConversion(); diff --git a/src/Bifrost.Web/Data/Migrations/20261109200000_AddSealedBallotMotions.cs b/src/Bifrost.Web/Data/Migrations/20261109200000_AddSealedBallotMotions.cs new file mode 100644 index 0000000..31d7a2e --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261109200000_AddSealedBallotMotions.cs @@ -0,0 +1,59 @@ +using System; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + // Hand-written: no dotnet-ef on the authoring host. The snapshot was edited to match; BuildTargetModel is left to the base no-op. + [DbContext(typeof(BifrostDbContext))] + [Migration("20261109200000_AddSealedBallotMotions")] + public partial class AddSealedBallotMotions : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn(name: "Candidates", table: "Motions", type: "text", nullable: false, defaultValue: ""); + migrationBuilder.AddColumn(name: "CommitClosesAtUtc", table: "Motions", type: "timestamp with time zone", nullable: true); + migrationBuilder.AddColumn(name: "Kind", table: "Motions", type: "text", nullable: false, defaultValue: "Standard"); + migrationBuilder.AddColumn(name: "PriorMotionId", table: "Motions", type: "uuid", nullable: true); + migrationBuilder.AddColumn(name: "ResultDigest", table: "Motions", type: "text", nullable: false, defaultValue: ""); + migrationBuilder.AddColumn(name: "Round", table: "Motions", type: "integer", nullable: false, defaultValue: 1); + + migrationBuilder.CreateTable( + name: "SealedBallots", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MotionId = table.Column(type: "uuid", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + Commitment = table.Column(type: "text", nullable: false), + Revision = table.Column(type: "integer", nullable: false), + CommittedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + RevealedCandidate = table.Column(type: "text", nullable: true), + RevealedReason = table.Column(type: "text", nullable: true), + Salt = table.Column(type: "text", nullable: true), + RevealedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SealedBallots", x => x.Id); + table.ForeignKey("FK_SealedBallots_Motions_MotionId", x => x.MotionId, "Motions", "Id", onDelete: ReferentialAction.Cascade); + table.ForeignKey("FK_SealedBallots_UserAccounts_UserAccountId", x => x.UserAccountId, "UserAccounts", "Id", onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex(name: "IX_SealedBallots_MotionId_UserAccountId", table: "SealedBallots", columns: new[] { "MotionId", "UserAccountId" }, unique: true); + migrationBuilder.CreateIndex(name: "IX_SealedBallots_UserAccountId", table: "SealedBallots", column: "UserAccountId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "SealedBallots"); + foreach (var column in new[] { "Candidates", "CommitClosesAtUtc", "Kind", "PriorMotionId", "ResultDigest", "Round" }) + { + migrationBuilder.DropColumn(name: column, table: "Motions"); + } + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 8c47ed8..3955ad5 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -896,15 +896,29 @@ namespace Bifrost.Web.Data.Migrations b.Property("ClosesAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("Candidates") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommitClosesAtUtc") + .HasColumnType("timestamp with time zone"); + b.Property("CreatedAtUtc") .HasColumnType("timestamp with time zone"); b.Property("CreatedByUserAccountId") .HasColumnType("uuid"); + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + b.Property("OpensAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("PriorMotionId") + .HasColumnType("uuid"); + b.Property("ProjectId") .HasColumnType("uuid"); @@ -915,6 +929,13 @@ namespace Bifrost.Web.Data.Migrations b.Property("ResolvedAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("ResultDigest") + .IsRequired() + .HasColumnType("text"); + + b.Property("Round") + .HasColumnType("integer"); + b.Property("Scope") .IsRequired() .HasColumnType("text"); @@ -1414,6 +1435,50 @@ namespace Bifrost.Web.Data.Migrations b.ToTable("VolunteerClaims"); }); + modelBuilder.Entity("Bifrost.Web.Domain.SealedBallot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Commitment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("RevealedCandidate") + .HasColumnType("text"); + + b.Property("RevealedReason") + .HasColumnType("text"); + + b.Property("RevealedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("Salt") + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("MotionId", "UserAccountId") + .IsUnique(); + + b.ToTable("SealedBallots"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => { b.Property("Id") @@ -2209,6 +2274,25 @@ namespace Bifrost.Web.Data.Migrations b.Navigation("WorkItem"); }); + modelBuilder.Entity("Bifrost.Web.Domain.SealedBallot", b => + { + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany("SealedBallots") + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Motion"); + + b.Navigation("UserAccount"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => { b.HasOne("Bifrost.Web.Domain.Motion", "Motion") @@ -2331,6 +2415,8 @@ namespace Bifrost.Web.Data.Migrations modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => { + b.Navigation("SealedBallots"); + b.Navigation("Votes"); }); diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index 668af3d..d2946d6 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -692,11 +692,64 @@ public sealed class Motion public string ResolutionNote { get; set; } = string.Empty; + public MotionKind Kind { get; set; } = MotionKind.Standard; + + // Sealed-ballot motions only: commitments are accepted until this instant, reveals from then until ClosesAtUtc. + public DateTimeOffset? CommitClosesAtUtc { get; set; } + + // Sealed-ballot motions only: candidate names, one per line. + public string Candidates { get; set; } = string.Empty; + + // Sealed-ballot motions only: Delphi round number and the motion whose result this round answers. + public int Round { get; set; } = 1; + + public Guid? PriorMotionId { get; set; } + + // Sealed-ballot motions only: SHA-256 over the sorted commitments, written once at finalization. + public string ResultDigest { get; set; } = string.Empty; + public Project? Project { get; set; } public UserAccount CreatedByUserAccount { get; set; } = null!; public ICollection Votes { get; set; } = []; + + public ICollection SealedBallots { get; set; } = []; +} + +public enum MotionKind +{ + Standard, + SealedBallot +} + +public sealed class SealedBallot +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid MotionId { get; set; } + + public Guid UserAccountId { get; set; } + + // Hex SHA-256 of the canonical ballot document; the only ballot-derived value stored before the reveal step. + public string Commitment { get; set; } = string.Empty; + + public int Revision { get; set; } = 1; + + public DateTimeOffset CommittedAtUtc { get; set; } + + // Reveal step. Held unread by every code path until the motion is Closed. + public string? RevealedCandidate { get; set; } + + public string? RevealedReason { get; set; } + + public string? Salt { get; set; } + + public DateTimeOffset? RevealedAtUtc { get; set; } + + public Motion Motion { get; set; } = null!; + + public UserAccount UserAccount { get; set; } = null!; } public sealed class Vote diff --git a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs index 8c10dcf..983526c 100644 --- a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs +++ b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs @@ -56,7 +56,7 @@ public sealed class MotionGovernanceService( .Include(x => x.Votes) .SingleAsync(x => x.Id == motionId, cancellationToken); - if (motion.Status != MotionStatus.Open) + if (motion.Status != MotionStatus.Open || motion.Kind != MotionKind.Standard) { return; } @@ -102,6 +102,11 @@ public sealed class MotionGovernanceService( .Include(x => x.Votes) .SingleAsync(x => x.Id == motionId, cancellationToken); + if (motion.Kind != MotionKind.Standard) + { + throw new InvalidOperationException("Sealed-ballot motions close only at their deadline."); + } + if (!actor.CanModerateMotions && motion.CreatedByUserAccountId != actorUser.Id) { throw new UnauthorizedAccessException("Only the motion creator or a moderator can close this motion."); @@ -140,6 +145,7 @@ public sealed class MotionGovernanceService( .AsNoTracking() .Include(x => x.Project) .Include(x => x.Votes) + .Where(x => x.Kind == MotionKind.Standard) .OrderBy(x => x.ClosesAtUtc) .ToListAsync(cancellationToken); diff --git a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs new file mode 100644 index 0000000..e97f7b5 --- /dev/null +++ b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs @@ -0,0 +1,339 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Features.Motions; + +/// +/// Sealed-ballot motions: commit, then reveal, then one atomic publication at close. +/// This is the only code that reads , +/// or ; a test fails if any other source file mentions them. +/// +public sealed class SealedBallotService( + BifrostDbContext dbContext, + AuditTrailService auditTrailService, + 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); + + public async Task OpenAsync( + CurrentBifrostActor actor, + OpenSealedMotionCommand command, + CancellationToken cancellationToken) + { + var user = RequireMember(actor); + if (!actor.CanModerateMotions) + { + throw new SealedBallotException(SealedBallotFailure.Forbidden, "Only a moderator opens a sealed-ballot motion."); + } + + var now = timeProvider.GetUtcNow(); + var candidates = command.Candidates.Select(x => x.Trim()).Where(x => x.Length > 0).ToList(); + if (candidates.Count < 2 || candidates.Distinct(StringComparer.Ordinal).Count() != candidates.Count || + candidates.Any(x => x.Contains('\n') || x.Contains('\r'))) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "Two or more distinct single-line candidates are required."); + } + + var commitCloses = command.CommitClosesAtUtc.ToUniversalTime(); + var closes = command.ClosesAtUtc.ToUniversalTime(); + if (commitCloses <= now || closes <= commitCloses) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "The commit window must end in the future and the reveal window must end after it."); + } + + var motion = new Motion + { + CreatedByUserAccountId = user.Id, + Scope = MotionScope.Management, + Category = MotionCategory.Features, + Kind = MotionKind.SealedBallot, + Title = command.Title.Trim(), + Summary = command.Summary.Trim(), + Candidates = string.Join('\n', candidates), + Round = Math.Max(1, command.Round), + PriorMotionId = command.PriorMotionId, + ApprovalThreshold = 0m, + OpensAtUtc = now, + CommitClosesAtUtc = commitCloses, + ClosesAtUtc = closes, + CreatedAtUtc = now + }; + + dbContext.Motions.Add(motion); + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync( + user.Id, nameof(Motion), motion.Id, "sealed.opened", + $"Opened sealed-ballot motion {motion.Title} (round {motion.Round}, {candidates.Count} candidates).", + cancellationToken); + + return BuildState(motion, now, null, user.Id); + } + + public async Task CommitAsync( + CurrentBifrostActor actor, + Guid motionId, + string commitment, + CancellationToken cancellationToken) + { + var user = RequireMember(actor); + var motion = await LoadAsync(motionId, cancellationToken); + var now = timeProvider.GetUtcNow(); + if (PhaseOf(motion, now) != SealedPhase.Commit) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The commit window is not open."); + } + + commitment = (commitment ?? string.Empty).Trim().ToLowerInvariant(); + if (!HexSha256.IsMatch(commitment)) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "A commitment is 64 lowercase hex characters (SHA-256)."); + } + + var ballot = await dbContext.SealedBallots + .SingleOrDefaultAsync(x => x.MotionId == motionId && x.UserAccountId == user.Id, cancellationToken); + if (ballot is null) + { + ballot = new SealedBallot { MotionId = motionId, UserAccountId = user.Id }; + dbContext.SealedBallots.Add(ballot); + } + else + { + ballot.Revision++; + } + + ballot.Commitment = commitment; + ballot.CommittedAtUtc = now; + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync( + user.Id, nameof(Motion), motionId, "sealed.committed", + $"{actor.DisplayName} committed a sealed ballot (revision {ballot.Revision}), commitment {commitment}.", + cancellationToken); + + return new SealedBallotReceipt(motionId, user.Id, commitment, ballot.Revision, now, Revealed: false); + } + + public async Task RevealAsync( + CurrentBifrostActor actor, + Guid motionId, + SealedBallotDocument document, + CancellationToken cancellationToken) + { + var user = RequireMember(actor); + var motion = await LoadAsync(motionId, cancellationToken); + var now = timeProvider.GetUtcNow(); + if (PhaseOf(motion, now) != SealedPhase.Reveal) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The reveal window is not open."); + } + + var ballot = await dbContext.SealedBallots + .SingleOrDefaultAsync(x => x.MotionId == motionId && x.UserAccountId == user.Id, cancellationToken) + ?? throw new SealedBallotException(SealedBallotFailure.NotFound, "No commitment from this member on this motion."); + + var reason = (document.Reason ?? string.Empty).Trim(); + var candidate = (document.Candidate ?? string.Empty).Trim(); + if (document.Schema != SealedBallotDocument.SchemaId || document.MotionId != motionId || document.Round != motion.Round || + !CandidatesOf(motion).Contains(candidate, StringComparer.Ordinal) || + reason.Length is 0 or > MaxReasonLength || reason.Contains('\n') || reason.Contains('\r') || + !HexSalt.IsMatch(document.Salt ?? string.Empty)) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "The ballot document does not fit this motion (schema, motion, round, candidate, one-line reason, 128-bit hex salt)."); + } + + var recomputed = SealedBallotDocument.Commit(document with { Candidate = candidate, Reason = reason }, user.Id); + if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(recomputed), Encoding.ASCII.GetBytes(ballot.Commitment))) + { + 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; + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync( + user.Id, nameof(Motion), motionId, "sealed.revealed", + $"{actor.DisplayName} submitted a reveal for commitment {ballot.Commitment}; content stays sealed until close.", + cancellationToken); + + return new SealedBallotReceipt(motionId, user.Id, ballot.Commitment, ballot.Revision, ballot.CommittedAtUtc, Revealed: true); + } + + /// Phase, times and the caller's own commitment status. Never another member's ballot, never revealed content. + public async Task GetStateAsync( + CurrentBifrostActor actor, + Guid motionId, + CancellationToken cancellationToken) + { + var user = RequireMember(actor); + var motion = await LoadAsync(motionId, cancellationToken); + var now = timeProvider.GetUtcNow(); + var own = await dbContext.SealedBallots + .AsNoTracking() + .Where(x => x.MotionId == motionId && x.UserAccountId == user.Id) + .Select(x => new SealedBallotReceipt(x.MotionId, x.UserAccountId, x.Commitment, x.Revision, x.CommittedAtUtc, x.RevealedAtUtc != null)) + .SingleOrDefaultAsync(cancellationToken); + + return BuildState(motion, now, own, user.Id); + } + + /// Finalizes the motion if its deadline has passed, then publishes every ballot at once. Before close it refuses. + public async Task GetResultAsync( + CurrentBifrostActor actor, + Guid motionId, + CancellationToken cancellationToken) + { + RequireMember(actor); + var motion = await LoadAsync(motionId, cancellationToken); + var now = timeProvider.GetUtcNow(); + if (PhaseOf(motion, now) != SealedPhase.Closed) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "Ballots stay sealed until the motion closes."); + } + + var ballots = await dbContext.SealedBallots + .Include(x => x.UserAccount).ThenInclude(x => x.MemberProfile) + .Where(x => x.MotionId == motionId) + .ToListAsync(cancellationToken); + + if (motion.Status == MotionStatus.Open) + { + await FinalizeAsync(motion, ballots, now, cancellationToken); + } + + var revealed = ballots.Where(x => x.RevealedAtUtc != null).OrderBy(x => x.Id).ToList(); + var tally = CandidatesOf(motion) + .Select(c => new SealedTally(c, revealed.Count(x => x.RevealedCandidate == c))) + .ToList(); + var top = tally.Max(x => x.Votes); + var leaders = top == 0 ? [] : tally.Where(x => x.Votes == top).Select(x => x.Candidate).ToList(); + + var voterIds = ballots.Select(x => x.UserAccountId).ToHashSet(); + var nonVoters = await dbContext.Memberships + .AsNoTracking() + .Where(x => x.Status == MembershipStatus.Active && !voterIds.Contains(x.UserAccountId)) + .Select(x => x.UserAccount.MemberProfile != null ? x.UserAccount.MemberProfile.Nickname : x.UserAccount.DisplayName) + .ToListAsync(cancellationToken); + + return new SealedMotionResult( + motion.Id, + motion.Title, + motion.Round, + motion.PriorMotionId, + SealedBallotDocument.SchemaId, + tally, + leaders, + leaders.Count > 1 ? "tie" : leaders.Count == 1 ? "winner" : "no ballots", + revealed.Select(x => new RevealedBallot( + NameOf(x.UserAccount), x.UserAccountId, x.RevealedCandidate!, x.RevealedReason!, x.Salt!, x.Commitment)).ToList(), + ballots.Where(x => x.RevealedAtUtc == null) + .Select(x => new AbstainedMember(NameOf(x.UserAccount), "committed but did not reveal", x.Commitment)).ToList(), + nonVoters.Select(n => new AbstainedMember(n, "no commitment", null)).ToList(), + motion.ResultDigest); + } + + public static SealedPhase PhaseOf(Motion motion, DateTimeOffset now) => + motion.Status != MotionStatus.Open || now >= motion.ClosesAtUtc ? SealedPhase.Closed + : now >= motion.CommitClosesAtUtc ? SealedPhase.Reveal + : SealedPhase.Commit; + + private async Task FinalizeAsync(Motion motion, List ballots, DateTimeOffset now, CancellationToken cancellationToken) + { + var lines = ballots.OrderBy(x => x.UserAccountId) + .Select(x => $"{x.UserAccountId:D}:{x.Commitment}:{(x.RevealedAtUtc != null ? "revealed" : "unrevealed")}"); + motion.ResultDigest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\n', lines)))).ToLowerInvariant(); + motion.Status = MotionStatus.Closed; + motion.ResolvedAtUtc = now; + motion.ResolutionNote = $"Sealed ballots published: {ballots.Count(x => x.RevealedAtUtc != null)} revealed, {ballots.Count(x => x.RevealedAtUtc == null)} committed but unrevealed."; + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync( + null, nameof(Motion), motion.Id, "sealed.closed", + $"Closed sealed-ballot motion {motion.Title}; commitment digest {motion.ResultDigest}.", + cancellationToken); + } + + private SealedMotionState BuildState(Motion motion, DateTimeOffset now, SealedBallotReceipt? own, Guid voterId) + { + var phase = PhaseOf(motion, now); + var announcement = phase switch + { + SealedPhase.Commit => $"Sealed ballot open: {motion.Title}. Commit by {motion.CommitClosesAtUtc:u}; reveal until {motion.ClosesAtUtc:u}.", + SealedPhase.Reveal => $"Sealed ballot in reveal: {motion.Title}. Reveal by {motion.ClosesAtUtc:u}.", + _ => $"Sealed ballot closed: {motion.Title}." + }; + return new SealedMotionState( + motion.Id, motion.Title, motion.Summary, motion.Round, motion.PriorMotionId, SealedBallotDocument.SchemaId, + phase.ToString(), CandidatesOf(motion), motion.CommitClosesAtUtc!.Value, motion.ClosesAtUtc, voterId, own, announcement); + } + + private async Task LoadAsync(Guid motionId, CancellationToken cancellationToken) => + await dbContext.Motions.SingleOrDefaultAsync(x => x.Id == motionId && x.Kind == MotionKind.SealedBallot, cancellationToken) + ?? throw new SealedBallotException(SealedBallotFailure.NotFound, "No such sealed-ballot motion."); + + private static IReadOnlyList CandidatesOf(Motion motion) => motion.Candidates.Split('\n'); + + private static string NameOf(UserAccount user) => user.MemberProfile?.Nickname ?? user.DisplayName; + + private static UserAccount RequireMember(CurrentBifrostActor actor) => + actor is { IsActiveMember: true, UserAccount: { } user } + ? user + : throw new SealedBallotException(SealedBallotFailure.Forbidden, "An active member is required."); +} + +public enum SealedPhase { Commit, Reveal, Closed } + +public enum SealedBallotFailure { Forbidden, NotFound, WrongPhase, Invalid } + +public sealed class SealedBallotException(SealedBallotFailure failure, string message) : Exception(message) +{ + public SealedBallotFailure Failure { get; } = failure; +} + +/// +/// The typed ballot document, schema gamecult.sealed_ballot.v0. A Delphi second round is another motion carrying this +/// same type with a higher Round. The commitment binds the voter's account id, so a commitment cannot be replayed by another member. +/// +public sealed record SealedBallotDocument(string Schema, Guid MotionId, int Round, string Candidate, string Reason, string Salt) +{ + public const string SchemaId = "gamecult.sealed_ballot.v0"; + + public static string Commit(SealedBallotDocument d, Guid voterUserAccountId) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes( + $"{SchemaId}\n{d.MotionId:D}\n{voterUserAccountId:D}\n{d.Round}\n{d.Candidate}\n{d.Reason}\n{d.Salt}"))).ToLowerInvariant(); +} + +public sealed record OpenSealedMotionCommand( + string Title, + string Summary, + IReadOnlyList Candidates, + DateTimeOffset CommitClosesAtUtc, + DateTimeOffset ClosesAtUtc, + int Round = 1, + Guid? PriorMotionId = null); + +public sealed record SealedBallotReceipt(Guid MotionId, Guid VoterUserAccountId, string Commitment, int Revision, DateTimeOffset CommittedAtUtc, bool Revealed); + +public sealed record SealedMotionState( + Guid Id, string Title, string Summary, int Round, Guid? PriorMotionId, string BallotSchema, string Phase, + IReadOnlyList Candidates, DateTimeOffset CommitClosesAtUtc, DateTimeOffset ClosesAtUtc, + Guid YourVoterId, SealedBallotReceipt? YourCommitment, string Announcement); + +public sealed record SealedTally(string Candidate, int Votes); + +public sealed record RevealedBallot(string Voter, Guid VoterUserAccountId, string Candidate, string Reason, string Salt, string Commitment); + +public sealed record AbstainedMember(string Member, string Why, string? Commitment); + +public sealed record SealedMotionResult( + Guid Id, string Title, int Round, Guid? PriorMotionId, string BallotSchema, + IReadOnlyList Tally, IReadOnlyList Leaders, string Outcome, + IReadOnlyList Ballots, IReadOnlyList Unrevealed, IReadOnlyList NonVoters, + string CommitmentDigest); +public sealed record SealedCommitRequest(string Commitment); diff --git a/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs index 9f07cf9..3a31269 100644 --- a/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs +++ b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs @@ -29,6 +29,7 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro var openMotions = await dbContext.Motions .AsNoTracking() .Include(x => x.Votes) + .Where(x => x.Kind == MotionKind.Standard) .OrderBy(x => x.ClosesAtUtc) .Take(5) .Select(x => new DashboardMotion( diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index c8a524a..2c6282b 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -121,6 +121,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -541,6 +542,42 @@ eveGovernance.MapGet("/surface", async ( "application/json"); }); +// Sealed-ballot intake. Typed JSON documents posted directly by the voter; no ballot content ever rides in a room message, +// and no response before close carries another member's ballot. +var sealedMotions = app.MapGroup("/api/sealed-motions") + .RequireAuthorization(ActiveMemberRequirement.PolicyName); + +static async Task SealedCall(Func> call) +{ + try + { + return Results.Ok(await call()); + } + catch (SealedBallotException error) + { + return Results.Json( + new { error = error.Message }, + statusCode: error.Failure switch + { + SealedBallotFailure.Forbidden => StatusCodes.Status403Forbidden, + SealedBallotFailure.NotFound => StatusCodes.Status404NotFound, + SealedBallotFailure.WrongPhase => StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest + }); + } +} + +sealedMotions.MapPost("", (ICurrentBifrostActorAccessor actors, SealedBallotService service, OpenSealedMotionCommand command, CancellationToken ct) => + SealedCall(async () => await service.OpenAsync(await actors.GetAsync(ct), command, ct))); +sealedMotions.MapGet("/{id:guid}", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, CancellationToken ct) => + SealedCall(async () => await service.GetStateAsync(await actors.GetAsync(ct), id, ct))); +sealedMotions.MapPost("/{id:guid}/commit", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, SealedCommitRequest body, CancellationToken ct) => + SealedCall(async () => await service.CommitAsync(await actors.GetAsync(ct), id, body.Commitment, ct))); +sealedMotions.MapPost("/{id:guid}/reveal", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, SealedBallotDocument body, CancellationToken ct) => + SealedCall(async () => await service.RevealAsync(await actors.GetAsync(ct), id, body, ct))); +sealedMotions.MapGet("/{id:guid}/result", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, CancellationToken ct) => + SealedCall(async () => await service.GetResultAsync(await actors.GetAsync(ct), id, ct))); + app.MapGet("/auth/heimdall/complete", (HttpContext httpContext) => { httpContext.Response.Headers.CacheControl = "no-store"; diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index 04401c1..47fc76f 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -22,6 +22,7 @@ public sealed class MigrationDiscoveryTests "20260705190000_AddPatronSupportProjectAttribution", "20261010043937_AddConceptVotes", "20261022163159_AddOpenIddict", + "20261109200000_AddSealedBallotMotions", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. @@ -51,7 +52,7 @@ public sealed class MigrationDiscoveryTests } [Fact] - public void All_fourteen_migrations_are_listed_in_order() + public void All_fifteen_migrations_are_listed_in_order() { Assert.Equal(ExpectedMigrations, Discovered()); } diff --git a/tests/Bifrost.Web.Tests/SealedBallotTests.cs b/tests/Bifrost.Web.Tests/SealedBallotTests.cs new file mode 100644 index 0000000..53581d3 --- /dev/null +++ b/tests/Bifrost.Web.Tests/SealedBallotTests.cs @@ -0,0 +1,216 @@ +using System.Text.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Motions; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Tests; + +public sealed class SealedBallotTests +{ + private const string Salt = "00112233445566778899aabbccddeeff"; + private static readonly DateTimeOffset T0 = new(2026, 11, 10, 12, 0, 0, TimeSpan.Zero); + + private sealed class Clock(DateTimeOffset start) : TimeProvider + { + public DateTimeOffset Now { get; set; } = start; + public override DateTimeOffset GetUtcNow() => Now; + } + + private sealed class Rig + { + public readonly Clock Clock = new(T0); + public readonly BifrostDbContext Db = new(new DbContextOptionsBuilder() + .UseInMemoryDatabase($"sealed-{Guid.NewGuid():N}").Options); + public readonly SealedBallotService Service; + public readonly CurrentBifrostActor Moderator; + + public Rig() + { + Service = Make(Db); + Moderator = Member("Moderator", moderator: true); + } + + private SealedBallotService Make(BifrostDbContext db) => new(db, new AuditTrailService(db, Clock), Clock); + + public CurrentBifrostActor Member(string name, bool moderator = false) + { + var user = new UserAccount { DisplayName = name, GitHubLogin = name, BifrostIdentity = name, NormalizedBifrostIdentity = name.ToUpperInvariant(), NormalizedGitHubLogin = name.ToUpperInvariant() }; + user.Membership = new Membership { UserAccountId = user.Id, Status = MembershipStatus.Active, CanModerateMotions = moderator }; + Db.UserAccounts.Add(user); + Db.SaveChanges(); + return new CurrentBifrostActor(true, user); + } + + public async Task OpenAsync(params string[] candidates) => + await Service.OpenAsync(Moderator, + new OpenSealedMotionCommand("Who writes the swarm's story", "Blind vote.", candidates, T0.AddHours(1), T0.AddHours(2)), + default); + + public async Task Cast(CurrentBifrostActor who, Guid motionId, string candidate, string reason, string salt = Salt) + { + var doc = new SealedBallotDocument(SealedBallotDocument.SchemaId, motionId, 1, candidate, reason, salt); + await Service.CommitAsync(who, motionId, SealedBallotDocument.Commit(doc, who.UserAccount!.Id), default); + } + + public Task Reveal(CurrentBifrostActor who, Guid motionId, string candidate, string reason, string salt = Salt) => + Service.RevealAsync(who, motionId, new SealedBallotDocument(SealedBallotDocument.SchemaId, motionId, 1, candidate, reason, salt), default); + } + + [Fact] + public async Task Commit_reveal_close_publishes_every_ballot_and_each_verifies_against_its_commitment() + { + var rig = new Rig(); + var (a, b) = (rig.Member("Ana"), rig.Member("Bo")); + var motion = await rig.OpenAsync("Ana", "Bo"); + await rig.Cast(a, motion.Id, "Ana", "I love my own prose."); + await rig.Cast(b, motion.Id, "Ana", "Ana writes best."); + rig.Clock.Now = T0.AddMinutes(90); + await rig.Reveal(a, motion.Id, "Ana", "I love my own prose."); + await rig.Reveal(b, motion.Id, "Ana", "Ana writes best."); + + await Assert.ThrowsAsync(() => rig.Service.GetResultAsync(a, motion.Id, default)); + + rig.Clock.Now = T0.AddHours(3); + var result = await rig.Service.GetResultAsync(a, motion.Id, default); + Assert.Equal("winner", result.Outcome); + Assert.Equal(["Ana"], result.Leaders); + Assert.Equal(2, result.Ballots.Count); + foreach (var ballot in result.Ballots) + { + var doc = new SealedBallotDocument(SealedBallotDocument.SchemaId, motion.Id, 1, ballot.Candidate, ballot.Reason, ballot.Salt); + Assert.Equal(ballot.Commitment, SealedBallotDocument.Commit(doc, ballot.VoterUserAccountId)); + } + + var again = await rig.Service.GetResultAsync(b, motion.Id, default); + Assert.Equal(result.CommitmentDigest, again.CommitmentDigest); + Assert.Equal(MotionStatus.Closed, (await rig.Db.Motions.SingleAsync()).Status); + } + + [Fact] + public async Task Nothing_readable_before_close_carries_a_ballot() + { + var rig = new Rig(); + var a = rig.Member("Ana"); + var motion = await rig.OpenAsync("Ana", "Bo"); + const string reason = "Distinctive-reason-xyzzy."; + await rig.Cast(a, motion.Id, "Bo", reason); + + // Commit phase: the store holds the hash and nothing else. + var row = await rig.Db.SealedBallots.AsNoTracking().SingleAsync(); + Assert.Null(row.RevealedCandidate); + Assert.Null(row.RevealedReason); + Assert.Null(row.Salt); + + rig.Clock.Now = T0.AddMinutes(90); + await rig.Reveal(a, motion.Id, "Bo", reason); + + // Reveal phase, content now in the store: every read surface still withholds it. + var surfaces = new List + { + JsonSerializer.Serialize(await rig.Service.GetStateAsync(a, motion.Id, default)), + JsonSerializer.Serialize(await rig.Service.GetStateAsync(rig.Moderator, motion.Id, default)), + JsonSerializer.Serialize(await rig.Db.AuditEvents.AsNoTracking().ToListAsync()), + JsonSerializer.Serialize(await new MotionGovernanceService(rig.Db, new AuditTrailService(rig.Db, rig.Clock), rig.Clock).GetStateAsync(a, default)), + JsonSerializer.Serialize(await new DashboardSnapshotService(rig.Db, rig.Clock).GetAsync(a.UserAccount!.Id, default)) + }; + foreach (var surface in surfaces) + { + Assert.DoesNotContain(reason, surface); + Assert.DoesNotContain(Salt, surface); + } + + var ex = await Assert.ThrowsAsync(() => rig.Service.GetResultAsync(rig.Moderator, motion.Id, default)); + Assert.Equal(SealedBallotFailure.WrongPhase, ex.Failure); + } + + [Fact] + public void Only_the_sealed_ballot_service_reads_sealed_content() + { + var root = AppContext.BaseDirectory; + while (!Directory.Exists(Path.Combine(root, "src", "Bifrost.Web"))) root = Path.GetDirectoryName(root)!; + var offenders = Directory.EnumerateFiles(Path.Combine(root, "src", "Bifrost.Web"), "*.cs", SearchOption.AllDirectories) + .Where(f => !f.Contains($"{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}")) + .Where(f => !f.EndsWith("SealedBallotService.cs") && !f.EndsWith("BifrostModels.cs")) + .Where(f => File.ReadAllText(f) is var s && (s.Contains("RevealedCandidate") || s.Contains("RevealedReason") || s.Contains(".Salt"))) + .ToList(); + Assert.Empty(offenders); + } + + [Fact] + public async Task One_ballot_each_and_a_recommit_replaces_the_first() + { + var rig = new Rig(); + var a = rig.Member("Ana"); + var motion = await rig.OpenAsync("Ana", "Bo"); + await rig.Cast(a, motion.Id, "Ana", "First thought."); + await rig.Cast(a, motion.Id, "Bo", "Second thought."); + var row = await rig.Db.SealedBallots.SingleAsync(); + Assert.Equal(2, row.Revision); + Assert.Equal(2, await rig.Db.AuditEvents.CountAsync(x => x.Action == "sealed.committed")); + + rig.Clock.Now = T0.AddMinutes(90); + await Assert.ThrowsAsync(() => rig.Reveal(a, motion.Id, "Ana", "First thought.")); + await rig.Reveal(a, motion.Id, "Bo", "Second thought."); + await Assert.ThrowsAsync(() => rig.Cast(a, motion.Id, "Ana", "Late change.")); + } + + [Fact] + public async Task Unrevealed_and_absent_members_are_shown_as_abstentions_not_votes() + { + var rig = new Rig(); + var (a, b, c) = (rig.Member("Ana"), rig.Member("Bo"), rig.Member("Cy")); + var motion = await rig.OpenAsync("Ana", "Bo"); + await rig.Cast(a, motion.Id, "Ana", "Mine."); + await rig.Cast(b, motion.Id, "Bo", "Mine too."); + rig.Clock.Now = T0.AddMinutes(90); + await rig.Reveal(a, motion.Id, "Ana", "Mine."); + rig.Clock.Now = T0.AddHours(3); + + var result = await rig.Service.GetResultAsync(c, motion.Id, default); + Assert.Equal(["Ana"], result.Leaders); + Assert.Equal(1, result.Tally.Sum(t => t.Votes)); + Assert.Contains(result.Unrevealed, x => x.Member == "Bo" && x.Commitment is not null); + Assert.Contains(result.NonVoters, x => x.Member == "Cy"); + Assert.DoesNotContain(result.NonVoters, x => x.Member == "Bo"); + } + + [Fact] + public async Task Ties_are_reported_and_nobody_breaks_them() + { + var rig = new Rig(); + var (a, b) = (rig.Member("Ana"), rig.Member("Bo")); + var motion = await rig.OpenAsync("Ana", "Bo"); + await rig.Cast(a, motion.Id, "Ana", "Self vote."); + await rig.Cast(b, motion.Id, "Bo", "Self vote."); + rig.Clock.Now = T0.AddMinutes(90); + await rig.Reveal(a, motion.Id, "Ana", "Self vote."); + await rig.Reveal(b, motion.Id, "Bo", "Self vote."); + rig.Clock.Now = T0.AddHours(3); + var result = await rig.Service.GetResultAsync(a, motion.Id, default); + Assert.Equal("tie", result.Outcome); + Assert.Equal(2, result.Leaders.Count); + } + + [Fact] + public async Task Phase_gates_roles_and_the_old_vote_path_hold() + { + var rig = new Rig(); + var a = rig.Member("Ana"); + await Assert.ThrowsAsync(() => rig.Service.OpenAsync(a, + new OpenSealedMotionCommand("x", "y", ["A", "B"], T0.AddHours(1), T0.AddHours(2)), default)); + var motion = await rig.OpenAsync("Ana", "Bo"); + + await Assert.ThrowsAsync(() => rig.Reveal(a, motion.Id, "Ana", "Too early.")); + await rig.Cast(a, motion.Id, "Ana", "Fine."); + rig.Clock.Now = T0.AddMinutes(90); + await Assert.ThrowsAsync(() => rig.Reveal(a, motion.Id, "Ana", "Wrong text.")); + await Assert.ThrowsAsync(() => rig.Reveal(a, motion.Id, "Cy", "Fine.")); + + var standard = new MotionGovernanceService(rig.Db, new AuditTrailService(rig.Db, rig.Clock), rig.Clock); + await standard.CastVoteAsync(a, motion.Id, VoteChoice.For, "sneak", default); + Assert.Empty(rig.Db.Votes); + await Assert.ThrowsAsync(() => standard.CloseMotionAsync(rig.Moderator, motion.Id, default)); + } +} -- 2.47.3 From 96da1b7a8c3d280b62d02e42b07ff8150f5864f8 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 9 Nov 2026 20:30:35 +0000 Subject: [PATCH 2/5] Sealed ballot: race-safe close, close sweep, endpoint tests, backup reader and auth findings in the doc Co-Authored-By: Claude Sonnet 5.5 --- docs/sealed-ballot-motion.md | 40 ++++- src/Bifrost.Web/Data/BifrostDbContext.cs | 1 + .../BifrostDbContextModelSnapshot.cs | 1 + .../Features/Motions/SealedBallotService.cs | 60 ++++++- src/Bifrost.Web/Program.cs | 10 +- .../SealedBallotEndpointTests.cs | 152 ++++++++++++++++++ tests/Bifrost.Web.Tests/SealedBallotTests.cs | 55 +++++++ 7 files changed, 310 insertions(+), 9 deletions(-) create mode 100644 tests/Bifrost.Web.Tests/SealedBallotEndpointTests.cs diff --git a/docs/sealed-ballot-motion.md b/docs/sealed-ballot-motion.md index 6b27fff..2f01ed0 100644 --- a/docs/sealed-ballot-motion.md +++ b/docs/sealed-ballot-motion.md @@ -12,8 +12,8 @@ publication at close, no mediator. Code: `Features/Motions/SealedBallotService.c 3. **Reveal** from the commit deadline until the reveal deadline: the voter posts the ballot document. Bifrost recomputes the hash and stores the content only if it matches the commitment. Revealed content is not readable through any endpoint, page, audit event or export until close. -4. **Close** at the reveal deadline, mechanically: the first read of `/result` after the deadline writes status `Closed` and a - digest of all commitments, then returns every revealed ballot (voter, candidate, one-sentence reason, salt, commitment) at +4. **Close** at the reveal deadline, mechanically: the first read of `/result` after the deadline, or the once-a-minute + `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. 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 = @@ -48,8 +48,13 @@ holding that Mind's plaintext from cast, which is the gap commit-reveal removes. - **Commit window:** nobody. The store holds hashes; the ballot exists only with its voter. Participation (who has committed, not what) is visible in the audit trail and to the voter's own receipt. - **Reveal window:** the **host and anyone with database access can read revealed ballots** that voters have already submitted. - Application code does not expose them (enforced by tests below), but a database reader sees columns `RevealedCandidate`, - `RevealedReason`, `Salt`. This is a gap and it is named: it is short, commitments are frozen so no one can change a ballot + Application code does not expose them (enforced by tests), but a database reader sees columns `RevealedCandidate`, + `RevealedReason`, `Salt`. **Backups are another reader:** any `pg_dump` taken in the reveal window contains the reveals + already submitted. What this repo says about backups: `docs/deploy-main-to-live.md` (Precondition 2) takes a `pg_dump -Fc` + inside a deploy window as in the gamecult-ops runbook section 8, and `docs/full-implementation-strategy.md` lists a + backup/restore runbook as planned. This repo does not state a nightly schedule, where dumps are stored or who can read + them; if a nightly dump exists it is defined in gamecult-ops, which I have not read, so I do not claim it or its reach. + Dumps taken in the commit window hold hashes only. This is a gap and it is named: it is short, commitments are frozen so no one can change a ballot after seeing another, and the commitments published during the commit window let anyone detect an altered reveal. It is not closed. Closing it fully would need reveals encrypted to a threshold or time-lock key, which this change does not build. - A Mind that reveals late in the window exposes its ballot to the host for less time. A voter who never reveals exposes @@ -61,3 +66,30 @@ holding that Mind's plaintext from cast, which is the gap commit-reveal removes. Ballots go to Bifrost as typed documents over the endpoints above, never as room text (chat history is readable by the host and admins before close). A pinned motion announces only the `announcement` line: phase and times. + +## Close is race-safe + +`Motion.Status` is an EF concurrency token. Racing closers each compute the same result (it is a pure function of frozen +data) but only one `SaveChanges` succeeds; the loser reloads the winner's row and writes no second `sealed.closed` audit +event. Tests: a stale-copy conflict and four concurrent first reads each yield exactly one close; with the token removed the +conflict test fails. In Postgres the token becomes `WHERE "Status" = ` on the update. Not exercised against Postgres. + +## Can a Mind hold the member session these endpoints need? + +Only through the existing member cookie, and awkwardly. Read from the code: + +- Bifrost authenticates with one cookie scheme plus GitHub OAuth (`Program.cs:176`). There is no bearer or API-key scheme; + the endpoints use the same `ActiveMember` policy as the console (`Program.cs:236`, `ActiveMemberAuthorizationHandler.cs:22`), + which maps claims to an account by Bifrost identity, Heimdall account id or GitHub id (`CurrentBifrostActorAccessor.cs:33-38`). +- A non-browser client can obtain a cookie: `POST /auth/bifrost/register` is anonymous and signs a cookie in + (`Program.cs:358`), creating an account in status `Authenticated` (`BifrostIdentityService.cs:49`), not `Active`. It becomes + able to vote only after membership approval. +- The same route cannot sign an existing identity back in (`BifrostIdentityService.cs:26` refuses a registered identity), so a + Mind that loses its cookie jar cannot re-authenticate natively. The other sign-in routes (`/auth/sign-in`, + `/auth/heimdall/{provider}`, `Program.cs:390`) are browser redirect flows. +- The bridge token (`BridgeOptions.cs:7`) is read by no server code path (the only matches in `src` are its own definition), + so it opens nothing. `tools/*.mjs` bearer headers go to Reddit, Matrix and image hosts, not to Bifrost. + +Answer: not cleanly. A Mind can hold a session if a human registers and approves it and the Mind keeps one cookie jar alive; +Bifrost has no durable non-browser credential for a Mind. I invented no auth path. Whether to give Minds one (a Persona-signed +or token intake like `tools/bifrost-signed-identity.mjs`) is a ruling for the stewards, not this PR. diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index b8ad497..7cbc2a5 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -278,6 +278,7 @@ public sealed class BifrostDbContext(DbContextOptions options) entity.Property(x => x.Status).HasConversion(); entity.Property(x => x.ApprovalThreshold).HasPrecision(6, 3); entity.Property(x => x.Kind).HasConversion(); + entity.Property(x => x.Status).IsConcurrencyToken(); entity.HasOne(x => x.Project) .WithMany(x => x.Motions) .HasForeignKey(x => x.ProjectId) diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 3955ad5..78e0097 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -941,6 +941,7 @@ namespace Bifrost.Web.Data.Migrations .HasColumnType("text"); b.Property("Status") + .IsConcurrencyToken() .IsRequired() .HasColumnType("text"); diff --git a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs index e97f7b5..49abfc8 100644 --- a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs +++ b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs @@ -5,6 +5,9 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Shared; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Bifrost.Web.Features.Motions; @@ -244,6 +247,10 @@ public sealed class SealedBallotService( : now >= motion.CommitClosesAtUtc ? SealedPhase.Reveal : SealedPhase.Commit; + /// + /// Closes the motion once. Motion.Status is a concurrency token, so of two racing closers exactly one SaveChanges wins; + /// the loser reloads the winner's result and writes nothing (no second audit event). + /// private async Task FinalizeAsync(Motion motion, List ballots, DateTimeOffset now, CancellationToken cancellationToken) { var lines = ballots.OrderBy(x => x.UserAccountId) @@ -252,13 +259,42 @@ public sealed class SealedBallotService( motion.Status = MotionStatus.Closed; motion.ResolvedAtUtc = now; motion.ResolutionNote = $"Sealed ballots published: {ballots.Count(x => x.RevealedAtUtc != null)} revealed, {ballots.Count(x => x.RevealedAtUtc == null)} committed but unrevealed."; - await dbContext.SaveChangesAsync(cancellationToken); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException error) + { + foreach (var entry in error.Entries) + { + await entry.ReloadAsync(cancellationToken); + } + + return; + } + await auditTrailService.RecordAsync( null, nameof(Motion), motion.Id, "sealed.closed", $"Closed sealed-ballot motion {motion.Title}; commitment digest {motion.ResultDigest}.", cancellationToken); } + /// Closes every sealed-ballot motion whose deadline has passed, so a motion nobody reads still closes. + public async Task FinalizeDueAsync(CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var due = await dbContext.Motions + .Where(x => x.Kind == MotionKind.SealedBallot && x.Status == MotionStatus.Open && x.ClosesAtUtc <= now) + .ToListAsync(cancellationToken); + foreach (var motion in due) + { + var ballots = await dbContext.SealedBallots.Where(x => x.MotionId == motion.Id).ToListAsync(cancellationToken); + await FinalizeAsync(motion, ballots, now, cancellationToken); + } + + return due.Count; + } + private SealedMotionState BuildState(Motion motion, DateTimeOffset now, SealedBallotReceipt? own, Guid voterId) { var phase = PhaseOf(motion, now); @@ -337,3 +373,25 @@ public sealed record SealedMotionResult( IReadOnlyList Ballots, IReadOnlyList Unrevealed, IReadOnlyList NonVoters, string CommitmentDigest); public sealed record SealedCommitRequest(string Commitment); + +/// Sweeps due sealed-ballot motions once a minute. Reads also close on demand; this covers a motion nobody reads. +public sealed class SealedBallotCloser(IServiceScopeFactory scopes, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); + do + { + try + { + using var scope = scopes.CreateScope(); + await scope.ServiceProvider.GetRequiredService().FinalizeDueAsync(stoppingToken); + } + catch (Exception error) when (error is not OperationCanceledException) + { + logger.LogWarning(error, "Sealed-ballot close sweep failed; retrying next tick."); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken)); + } +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 2c6282b..4993e43 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -122,6 +122,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -547,16 +548,17 @@ eveGovernance.MapGet("/surface", async ( var sealedMotions = app.MapGroup("/api/sealed-motions") .RequireAuthorization(ActiveMemberRequirement.PolicyName); -static async Task SealedCall(Func> call) +async Task SealedCall(Func> call) { try { - return Results.Ok(await call()); + return Results.Text(JsonSerializer.Serialize(await call(), appJsonSerializerOptions), "application/json"); } catch (SealedBallotException error) { - return Results.Json( - new { error = error.Message }, + return Results.Text( + JsonSerializer.Serialize(new { error = error.Message }, appJsonSerializerOptions), + "application/json", statusCode: error.Failure switch { SealedBallotFailure.Forbidden => StatusCodes.Status403Forbidden, diff --git a/tests/Bifrost.Web.Tests/SealedBallotEndpointTests.cs b/tests/Bifrost.Web.Tests/SealedBallotEndpointTests.cs new file mode 100644 index 0000000..fb517bb --- /dev/null +++ b/tests/Bifrost.Web.Tests/SealedBallotEndpointTests.cs @@ -0,0 +1,152 @@ +using System.Net; +using System.Net.Http.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Motions; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +public sealed class SealedBallotEndpointTests(TestWebApplicationFactory factory) : IClassFixture +{ + private const string Salt = "00112233445566778899aabbccddeeff"; + private const string Reason = "Distinctive-endpoint-reason-plugh."; + + private static int _next = 1000; + + private sealed record Seeded(Guid RevealMotion, Guid CommitMotion, Guid ClosedMotion, Guid VoterId, string Ana, string Bo, string Outsider); + + private async Task SeedAsync() + { + using (var warmup = factory.CreateClient()) { _ = await warmup.GetAsync("/App"); } + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var now = DateTimeOffset.UtcNow; + var n = Interlocked.Add(ref _next, 10); + UserAccount Account(long gh, string name, MembershipStatus status) + { + var u = new UserAccount { GitHubUserId = gh, GitHubLogin = name, NormalizedGitHubLogin = name.ToUpperInvariant(), DisplayName = name }; + u.MemberProfile = new MemberProfile { UserAccountId = u.Id, Nickname = name, Headline = "seed", UpdatedAtUtc = now }; + u.Membership = new Domain.Membership { UserAccountId = u.Id, Status = status, CreatedAtUtc = now }; + if (status == MembershipStatus.Active) + { + Bifrost.Web.Features.Shared.ApplicationBootstrapper.EnsureRole(u.Membership, MemberRole.StandardMember, null, now, "seed"); + } + + db.UserAccounts.Add(u); + return u; + } + + var ana = Account(n + 1, $"ana{n}", MembershipStatus.Active); + Account(n + 2, $"bo{n}", MembershipStatus.Active); + Account(n + 3, $"outsider{n}", MembershipStatus.Authenticated); + Motion Make(DateTimeOffset commitCloses, DateTimeOffset closes) + { + var m = new Motion + { + CreatedByUserAccountId = ana.Id, Scope = MotionScope.Management, Kind = MotionKind.SealedBallot, Title = "t", Summary = "s", + Candidates = "Ana\nBo", ApprovalThreshold = 0m, OpensAtUtc = now.AddHours(-3), CommitClosesAtUtc = commitCloses, + ClosesAtUtc = closes, CreatedAtUtc = now.AddHours(-3) + }; + db.Motions.Add(m); + return m; + } + + var reveal = Make(now.AddHours(-1), now.AddHours(1)); + var commit = Make(now.AddHours(1), now.AddHours(2)); + var closed = Make(now.AddHours(-2), now.AddHours(-1)); + db.SealedBallots.Add(new SealedBallot + { + MotionId = reveal.Id, UserAccountId = ana.Id, CommittedAtUtc = now.AddHours(-2), + Commitment = SealedBallotDocument.Commit(new(SealedBallotDocument.SchemaId, reveal.Id, 1, "Bo", Reason, Salt), ana.Id) + }); + await db.SaveChangesAsync(); + return new Seeded(reveal.Id, commit.Id, closed.Id, ana.Id, (n + 1).ToString(), (n + 2).ToString(), (n + 3).ToString()); + } + + private HttpClient As(string ghId, string login, bool anonymous = false) + { + var c = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + if (anonymous) c.DefaultRequestHeaders.Add("X-Test-Anonymous", "true"); + else { c.DefaultRequestHeaders.Add("X-Test-GitHub-Id", ghId); c.DefaultRequestHeaders.Add("X-Test-GitHub-Login", login); } + return c; + } + + [Fact] + public async Task Unauthenticated_and_non_member_callers_are_refused_on_every_sealed_endpoint() + { + var s = await SeedAsync(); + foreach (var client in new[] { As("", "", anonymous: true), As(s.Outsider, "outsider") }) + { + var id = s.RevealMotion; + var responses = new[] + { + await client.GetAsync($"/api/sealed-motions/{id}"), + await client.GetAsync($"/api/sealed-motions/{id}/result"), + await client.PostAsJsonAsync($"/api/sealed-motions/{id}/commit", new { commitment = new string('a', 64) }), + await client.PostAsJsonAsync($"/api/sealed-motions/{id}/reveal", new SealedBallotDocument(SealedBallotDocument.SchemaId, id, 1, "Bo", Reason, Salt)) + }; + foreach (var r in responses) + { + Assert.Contains(r.StatusCode, new[] { HttpStatusCode.Redirect, HttpStatusCode.Forbidden, HttpStatusCode.Unauthorized }); + Assert.DoesNotContain(Reason, await r.Content.ReadAsStringAsync()); + } + } + + using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + Assert.All(db.SealedBallots.Where(x => x.MotionId == s.RevealMotion), b => Assert.Null(b.RevealedReason)); + } + + [Fact] + public async Task Member_commits_in_the_commit_window_and_reveals_in_the_reveal_window_without_any_response_leaking_it() + { + var s = await SeedAsync(); + var ana = As(s.Ana, "ana"); + var bo = As(s.Bo, "bo"); + + var commit = await ana.PostAsJsonAsync($"/api/sealed-motions/{s.CommitMotion}/commit", new { commitment = new string('a', 64) }); + Assert.True(commit.StatusCode == HttpStatusCode.OK, await commit.Content.ReadAsStringAsync()); + Assert.Equal(HttpStatusCode.Conflict, (await ana.PostAsJsonAsync($"/api/sealed-motions/{s.CommitMotion}/reveal", + new SealedBallotDocument(SealedBallotDocument.SchemaId, s.CommitMotion, 1, "Bo", Reason, Salt))).StatusCode); + Assert.Equal(HttpStatusCode.Conflict, (await ana.PostAsJsonAsync($"/api/sealed-motions/{s.RevealMotion}/commit", + new { commitment = new string('b', 64) })).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await ana.PostAsJsonAsync($"/api/sealed-motions/{s.CommitMotion}/commit", new { commitment = "nope" })).StatusCode); + + var wrong = await ana.PostAsJsonAsync($"/api/sealed-motions/{s.RevealMotion}/reveal", + new SealedBallotDocument(SealedBallotDocument.SchemaId, s.RevealMotion, 1, "Ana", Reason, Salt)); + Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode); + + var ok = await ana.PostAsJsonAsync($"/api/sealed-motions/{s.RevealMotion}/reveal", + new SealedBallotDocument(SealedBallotDocument.SchemaId, s.RevealMotion, 1, "Bo", Reason, Salt)); + Assert.Equal(HttpStatusCode.OK, ok.StatusCode); + + // After the reveal is stored, no sealed endpoint answers any member with the ballot. + foreach (var client in new[] { ana, bo }) + { + foreach (var response in new[] + { + await client.GetAsync($"/api/sealed-motions/{s.RevealMotion}"), + await client.GetAsync($"/api/sealed-motions/{s.RevealMotion}/result"), + ok + }) + { + var body = await response.Content.ReadAsStringAsync(); + Assert.DoesNotContain(Reason, body); + Assert.DoesNotContain(Salt, body); + } + } + + Assert.Equal(HttpStatusCode.Conflict, (await bo.GetAsync($"/api/sealed-motions/{s.RevealMotion}/result")).StatusCode); + } + + [Fact] + public async Task A_motion_past_its_deadline_closes_on_the_first_read_and_publishes() + { + var s = await SeedAsync(); + var response = await As(s.Bo, "bo").GetAsync($"/api/sealed-motions/{s.ClosedMotion}/result"); + Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync()); + Assert.Contains("no ballots", await response.Content.ReadAsStringAsync()); + } +} diff --git a/tests/Bifrost.Web.Tests/SealedBallotTests.cs b/tests/Bifrost.Web.Tests/SealedBallotTests.cs index 53581d3..754d28b 100644 --- a/tests/Bifrost.Web.Tests/SealedBallotTests.cs +++ b/tests/Bifrost.Web.Tests/SealedBallotTests.cs @@ -213,4 +213,59 @@ public sealed class SealedBallotTests Assert.Empty(rig.Db.Votes); await Assert.ThrowsAsync(() => standard.CloseMotionAsync(rig.Moderator, motion.Id, default)); } + + [Fact] + public async Task Racing_closers_close_once_and_the_loser_adopts_the_winners_result() + { + var clock = new Clock(T0); + var name = $"sealed-race-{Guid.NewGuid():N}"; + BifrostDbContext NewDb() => new(new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options); + SealedBallotService NewService(BifrostDbContext db) => new(db, new AuditTrailService(db, clock), clock); + + await using var seed = NewDb(); + var user = new UserAccount { DisplayName = "Ana", GitHubLogin = "ana", BifrostIdentity = "ana", NormalizedBifrostIdentity = "ANA", NormalizedGitHubLogin = "ANA" }; + user.Membership = new Membership { UserAccountId = user.Id, Status = MembershipStatus.Active, CanModerateMotions = true }; + seed.UserAccounts.Add(user); + await seed.SaveChangesAsync(); + var actor = new CurrentBifrostActor(true, user); + var motion = await NewService(seed).OpenAsync(actor, + new OpenSealedMotionCommand("t", "s", ["Ana", "Bo"], T0.AddHours(1), T0.AddHours(2)), default); + clock.Now = T0.AddHours(3); + + await using var first = NewDb(); + await using var second = NewDb(); + _ = await second.Motions.ToListAsync(); // second now holds a stale, still-Open copy + + var winner = await NewService(first).GetResultAsync(actor, motion.Id, default); + var loser = await NewService(second).GetResultAsync(actor, motion.Id, default); // would finalize again; the token refuses it + + Assert.Equal(winner.CommitmentDigest, loser.CommitmentDigest); + await using var check = NewDb(); + Assert.Equal(1, await check.AuditEvents.CountAsync(x => x.Action == "sealed.closed")); + + // Genuinely concurrent first reads on fresh contexts also close exactly once. + var other = await NewService(check).OpenAsync(actor, + new OpenSealedMotionCommand("t2", "s", ["Ana", "Bo"], T0.AddHours(4), T0.AddHours(5)), default); + clock.Now = T0.AddHours(6); + await Task.WhenAll(Enumerable.Range(0, 4).Select(async _ => + { + await using var db = NewDb(); + await NewService(db).GetResultAsync(actor, other.Id, default); + })); + await using var after = NewDb(); + Assert.Equal(1, await after.AuditEvents.CountAsync(x => x.Action == "sealed.closed" && x.EntityId == other.Id.ToString())); + } + + [Fact] + public async Task The_sweep_closes_a_due_motion_nobody_read() + { + var rig = new Rig(); + var motion = await rig.OpenAsync("Ana", "Bo"); + Assert.Equal(0, await rig.Service.FinalizeDueAsync(default)); + rig.Clock.Now = T0.AddHours(3); + Assert.Equal(1, await rig.Service.FinalizeDueAsync(default)); + Assert.Equal(MotionStatus.Closed, (await rig.Db.Motions.SingleAsync(x => x.Id == motion.Id)).Status); + Assert.Equal(0, await rig.Service.FinalizeDueAsync(default)); + Assert.Equal(1, await rig.Db.AuditEvents.CountAsync(x => x.Action == "sealed.closed")); + } } -- 2.47.3 From 264233e08f53f72f54299656e42db11362b44f42 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 9 Nov 2026 20:40:54 +0000 Subject: [PATCH 3/5] Sealed ballot: freeze the reveal set at close (RevealSeq token), HTTP tests for replay/non-voter/late reveal/close, doc corrected on the ballot schema Co-Authored-By: Claude Sonnet 5.5 --- docs/sealed-ballot-motion.md | 14 +- src/Bifrost.Web/Data/BifrostDbContext.cs | 1 + .../20261109200000_AddSealedBallotMotions.cs | 3 +- .../BifrostDbContextModelSnapshot.cs | 4 + src/Bifrost.Web/Domain/BifrostModels.cs | 4 + .../Features/Motions/SealedBallotService.cs | 43 ++++-- .../SealedBallotCloseHttpTests.cs | 138 ++++++++++++++++++ tests/Bifrost.Web.Tests/SealedBallotTests.cs | 89 +++++++++++ 8 files changed, 281 insertions(+), 15 deletions(-) create mode 100644 tests/Bifrost.Web.Tests/SealedBallotCloseHttpTests.cs diff --git a/docs/sealed-ballot-motion.md b/docs/sealed-ballot-motion.md index 2f01ed0..16bc806 100644 --- a/docs/sealed-ballot-motion.md +++ b/docs/sealed-ballot-motion.md @@ -1,7 +1,7 @@ # Sealed-ballot motions A blind choice among named candidates (first use: which Mind writes the swarm's story). Commit-reveal, one atomic -publication at close, no mediator. Code: `Features/Motions/SealedBallotService.cs`. Ballot type: `gamecult.sealed_ballot.v0`. +publication at close, no mediator. Code: `Features/Motions/SealedBallotService.cs`. Ballot shape: the JSON record `SealedBallotDocument` carrying the string constant `gamecult.sealed_ballot.v0`. ## Protocol @@ -14,13 +14,19 @@ publication at close, no mediator. Code: `Features/Motions/SealedBallotService.c page, audit event or export until close. 4. **Close** at the reveal deadline, mechanically: the first read of `/result` after the deadline, or the once-a-minute `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. Anyone can recompute each commitment from the published fields. Nobody can close early, extend, edit or break a tie. + 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 + 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 = `sha256(utf8("gamecult.sealed_ballot.v0\n{motionId}\n{voterUserAccountId}\n{round}\n{candidate}\n{reason}\n{salt}"))`, lowercase hex. `salt` is 32 to 128 hex characters (128 bits at least) the voter generates and keeps; without it a four-candidate hash is -guessable. The voter's account id is in the hash, so a commitment cannot be replayed by another member. A Delphi second round -is another motion carrying the same document type with `round` + 1 and `priorMotionId`. +guessable. The voter's account id is in the hash, so a commitment cannot be replayed by another member. `gamecult.sealed_ballot.v0` is only a string +constant on a JSON record (`SealedBallotDocument`); it is **not** a CultCache or CultNet schema, and nothing here registers +one. A second Delphi round is modelled as another motion (`Round` and `PriorMotionId` columns) whose ballots have the same JSON +shape. A real typed document schema, with CultCache/CultNet registration so a round-two ballot is the same registered type, is +a follow-up (Bifrost issue linked in the PR). ## Decisions diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 7cbc2a5..fa4d2ae 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -278,6 +278,7 @@ public sealed class BifrostDbContext(DbContextOptions options) entity.Property(x => x.Status).HasConversion(); entity.Property(x => x.ApprovalThreshold).HasPrecision(6, 3); entity.Property(x => x.Kind).HasConversion(); + entity.Property(x => x.RevealSeq).IsConcurrencyToken(); entity.Property(x => x.Status).IsConcurrencyToken(); entity.HasOne(x => x.Project) .WithMany(x => x.Motions) diff --git a/src/Bifrost.Web/Data/Migrations/20261109200000_AddSealedBallotMotions.cs b/src/Bifrost.Web/Data/Migrations/20261109200000_AddSealedBallotMotions.cs index 31d7a2e..2629190 100644 --- a/src/Bifrost.Web/Data/Migrations/20261109200000_AddSealedBallotMotions.cs +++ b/src/Bifrost.Web/Data/Migrations/20261109200000_AddSealedBallotMotions.cs @@ -19,6 +19,7 @@ namespace Bifrost.Web.Data.Migrations migrationBuilder.AddColumn(name: "Kind", table: "Motions", type: "text", nullable: false, defaultValue: "Standard"); migrationBuilder.AddColumn(name: "PriorMotionId", table: "Motions", type: "uuid", nullable: true); migrationBuilder.AddColumn(name: "ResultDigest", table: "Motions", type: "text", nullable: false, defaultValue: ""); + migrationBuilder.AddColumn(name: "RevealSeq", table: "Motions", type: "integer", nullable: false, defaultValue: 0); migrationBuilder.AddColumn(name: "Round", table: "Motions", type: "integer", nullable: false, defaultValue: 1); migrationBuilder.CreateTable( @@ -50,7 +51,7 @@ namespace Bifrost.Web.Data.Migrations protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable(name: "SealedBallots"); - foreach (var column in new[] { "Candidates", "CommitClosesAtUtc", "Kind", "PriorMotionId", "ResultDigest", "Round" }) + foreach (var column in new[] { "Candidates", "CommitClosesAtUtc", "Kind", "PriorMotionId", "ResultDigest", "RevealSeq", "Round" }) { migrationBuilder.DropColumn(name: column, table: "Motions"); } diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 78e0097..289201d 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -929,6 +929,10 @@ namespace Bifrost.Web.Data.Migrations b.Property("ResolvedAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("RevealSeq") + .IsConcurrencyToken() + .HasColumnType("integer"); + b.Property("ResultDigest") .IsRequired() .HasColumnType("text"); diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index d2946d6..5d6aab6 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -705,6 +705,10 @@ public sealed class Motion public Guid? PriorMotionId { get; set; } + // Sealed-ballot motions only: bumped by every accepted reveal. A concurrency token, so a reveal and the close cannot both + // succeed on the same row version: the close freezes exactly the reveals it counted. + public int RevealSeq { get; set; } + // Sealed-ballot motions only: SHA-256 over the sorted commitments, written once at finalization. public string ResultDigest { get; set; } = string.Empty; diff --git a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs index 49abfc8..5e778ad 100644 --- a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs +++ b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs @@ -160,7 +160,17 @@ public sealed class SealedBallotService( ballot.RevealedReason = reason; ballot.Salt = document.Salt; ballot.RevealedAtUtc = now; - await dbContext.SaveChangesAsync(cancellationToken); + motion.RevealSeq++; + try + { + 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."); + } + await auditTrailService.RecordAsync( user.Id, nameof(Motion), motionId, "sealed.revealed", $"{actor.DisplayName} submitted a reveal for commitment {ballot.Commitment}; content stays sealed until close.", @@ -201,16 +211,27 @@ public sealed class SealedBallotService( throw new SealedBallotException(SealedBallotFailure.WrongPhase, "Ballots stay sealed until the motion closes."); } - var ballots = await dbContext.SealedBallots - .Include(x => x.UserAccount).ThenInclude(x => x.MemberProfile) - .Where(x => x.MotionId == motionId) - .ToListAsync(cancellationToken); + // Freeze: the close counts the reveals it read and succeeds only if no reveal landed since (RevealSeq is a concurrency + // token); a conflict re-reads and retries, so the digest always covers exactly the set that is published. + for (var attempt = 0; motion.Status == MotionStatus.Open && attempt < 5; attempt++) + { + var frozen = await dbContext.SealedBallots.AsNoTracking().Where(x => x.MotionId == motionId).ToListAsync(cancellationToken); + if (await FinalizeAsync(motion, frozen, now, cancellationToken)) + { + break; + } + } if (motion.Status == MotionStatus.Open) { - await FinalizeAsync(motion, ballots, now, cancellationToken); + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The close is contended; read again."); } + var ballots = await dbContext.SealedBallots.AsNoTracking() + .Include(x => x.UserAccount).ThenInclude(x => x.MemberProfile) + .Where(x => x.MotionId == motionId) + .ToListAsync(cancellationToken); + var revealed = ballots.Where(x => x.RevealedAtUtc != null).OrderBy(x => x.Id).ToList(); var tally = CandidatesOf(motion) .Select(c => new SealedTally(c, revealed.Count(x => x.RevealedCandidate == c))) @@ -251,10 +272,11 @@ public sealed class SealedBallotService( /// Closes the motion once. Motion.Status is a concurrency token, so of two racing closers exactly one SaveChanges wins; /// the loser reloads the winner's result and writes nothing (no second audit event). /// - private async Task FinalizeAsync(Motion motion, List ballots, DateTimeOffset now, CancellationToken cancellationToken) + private async Task FinalizeAsync(Motion motion, List ballots, DateTimeOffset now, CancellationToken cancellationToken) { var lines = ballots.OrderBy(x => x.UserAccountId) - .Select(x => $"{x.UserAccountId:D}:{x.Commitment}:{(x.RevealedAtUtc != null ? "revealed" : "unrevealed")}"); + .Select(x => $"{x.UserAccountId:D}:{x.Commitment}:{(x.RevealedAtUtc != null ? "revealed" : "unrevealed")}") + .Prepend($"reveals:{motion.RevealSeq}"); motion.ResultDigest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\n', lines)))).ToLowerInvariant(); motion.Status = MotionStatus.Closed; motion.ResolvedAtUtc = now; @@ -270,13 +292,14 @@ public sealed class SealedBallotService( await entry.ReloadAsync(cancellationToken); } - return; + return motion.Status != MotionStatus.Open; } await auditTrailService.RecordAsync( null, nameof(Motion), motion.Id, "sealed.closed", $"Closed sealed-ballot motion {motion.Title}; commitment digest {motion.ResultDigest}.", cancellationToken); + return true; } /// Closes every sealed-ballot motion whose deadline has passed, so a motion nobody reads still closes. @@ -288,7 +311,7 @@ public sealed class SealedBallotService( .ToListAsync(cancellationToken); foreach (var motion in due) { - var ballots = await dbContext.SealedBallots.Where(x => x.MotionId == motion.Id).ToListAsync(cancellationToken); + var ballots = await dbContext.SealedBallots.AsNoTracking().Where(x => x.MotionId == motion.Id).ToListAsync(cancellationToken); await FinalizeAsync(motion, ballots, now, cancellationToken); } diff --git a/tests/Bifrost.Web.Tests/SealedBallotCloseHttpTests.cs b/tests/Bifrost.Web.Tests/SealedBallotCloseHttpTests.cs new file mode 100644 index 0000000..012d319 --- /dev/null +++ b/tests/Bifrost.Web.Tests/SealedBallotCloseHttpTests.cs @@ -0,0 +1,138 @@ +using System.Net; +using System.Net.Http.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Motions; +using Bifrost.Web.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +public sealed class SealedBallotCloseHttpTests(TestWebApplicationFactory factory) : IClassFixture +{ + private const string Salt = "00112233445566778899aabbccddeeff"; + private const string Reason = "Distinctive-close-reason-plugh."; + private static int _next = 7000; + + private sealed record W(Guid RevealA, Guid RevealB, Guid LateMotion, Guid ClosedWithBallot, string Ana, string Bo, string Cy); + + private async Task SeedAsync() + { + using (var warmup = factory.CreateClient()) { _ = await warmup.GetAsync("/App"); } + var n = Interlocked.Add(ref _next, 10); + var now = DateTimeOffset.UtcNow; + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + UserAccount Make(long gh, string name) + { + var u = new UserAccount { GitHubUserId = gh, GitHubLogin = name, NormalizedGitHubLogin = name.ToUpperInvariant(), DisplayName = name }; + u.MemberProfile = new MemberProfile { UserAccountId = u.Id, Nickname = name, Headline = "seed", UpdatedAtUtc = now }; + u.Membership = new Domain.Membership { UserAccountId = u.Id, Status = MembershipStatus.Active, CreatedAtUtc = now }; + Bifrost.Web.Features.Shared.ApplicationBootstrapper.EnsureRole(u.Membership, MemberRole.StandardMember, null, now, "seed"); + db.UserAccounts.Add(u); + return u; + } + + var ana = Make(n + 1, $"ana{n}"); + Make(n + 2, $"bo{n}"); + Make(n + 3, $"cy{n}"); + Motion M(DateTimeOffset commitCloses, DateTimeOffset closes) + { + var m = new Motion + { + CreatedByUserAccountId = ana.Id, Scope = MotionScope.Management, Kind = MotionKind.SealedBallot, Title = "t", Summary = "s", + Candidates = "Ana\nBo", OpensAtUtc = now.AddHours(-3), CommitClosesAtUtc = commitCloses, ClosesAtUtc = closes, CreatedAtUtc = now + }; + db.Motions.Add(m); + return m; + } + + var a = M(now.AddHours(-1), now.AddHours(1)); // reveal phase: Ana's real ballot + var b = M(now.AddHours(-1), now.AddHours(1)); // reveal phase: Ana's commitment from A replayed here + var late = M(now.AddHours(-2), now.AddHours(-1)); // past deadline, Ana committed and never revealed + var done = M(now.AddHours(-2), now.AddHours(-1)); // past deadline, Ana's reveal is in + string Commit(Guid motion) => SealedBallotDocument.Commit(new(SealedBallotDocument.SchemaId, motion, 1, "Bo", Reason, Salt), ana.Id); + db.SealedBallots.Add(new SealedBallot { MotionId = a.Id, UserAccountId = ana.Id, CommittedAtUtc = now.AddHours(-2), Commitment = Commit(a.Id) }); + db.SealedBallots.Add(new SealedBallot { MotionId = b.Id, UserAccountId = ana.Id, CommittedAtUtc = now.AddHours(-2), Commitment = Commit(a.Id) }); + db.SealedBallots.Add(new SealedBallot { MotionId = late.Id, UserAccountId = ana.Id, CommittedAtUtc = now.AddHours(-3), Commitment = Commit(late.Id) }); + db.SealedBallots.Add(new SealedBallot + { + MotionId = done.Id, UserAccountId = ana.Id, CommittedAtUtc = now.AddHours(-3), Commitment = Commit(done.Id), + RevealedCandidate = "Bo", RevealedReason = Reason, Salt = Salt, RevealedAtUtc = now.AddHours(-1.5) + }); + await db.SaveChangesAsync(); + return new W(a.Id, b.Id, late.Id, done.Id, (n + 1).ToString(), (n + 2).ToString(), (n + 3).ToString()); + } + + private HttpClient As(string id, string login) + { + var c = factory.CreateClient(); + c.DefaultRequestHeaders.Add("X-Test-GitHub-Id", id); + c.DefaultRequestHeaders.Add("X-Test-GitHub-Login", login); + return c; + } + + private static SealedBallotDocument Doc(Guid motion) => new(SealedBallotDocument.SchemaId, motion, 1, "Bo", Reason, Salt); + + private static async Task Refused(HttpResponseMessage r, HttpStatusCode expected) + { + var body = await r.Content.ReadAsStringAsync(); + Assert.True(r.StatusCode == expected, $"{r.StatusCode}: {body}"); + Assert.DoesNotContain(Reason, body); + Assert.DoesNotContain(Salt, body); + Assert.DoesNotContain("\"Bo\"", body); + } + + [Fact] + public async Task A_commit_or_reveal_replayed_into_another_motion_is_refused() + { + var w = await SeedAsync(); + var ana = As(w.Ana, "ana"); + // A's document aimed at B: wrong motion id inside, and B's stored commitment is A's hash, which no B-bound document reproduces. + await Refused(await ana.PostAsJsonAsync($"/api/sealed-motions/{w.RevealB}/reveal", Doc(w.RevealA)), HttpStatusCode.BadRequest); + await Refused(await ana.PostAsJsonAsync($"/api/sealed-motions/{w.RevealB}/reveal", Doc(w.RevealB)), HttpStatusCode.BadRequest); + // A's commit hash sent into a motion that is no longer in its commit window. + await Refused(await ana.PostAsJsonAsync($"/api/sealed-motions/{w.RevealB}/commit", new { commitment = new string('a', 64) }), HttpStatusCode.Conflict); + Assert.Equal(HttpStatusCode.OK, (await ana.PostAsJsonAsync($"/api/sealed-motions/{w.RevealA}/reveal", Doc(w.RevealA))).StatusCode); + } + + [Fact] + public async Task A_non_voter_cannot_reveal() + { + var w = await SeedAsync(); + await Refused(await As(w.Cy, "cy").PostAsJsonAsync($"/api/sealed-motions/{w.RevealA}/reveal", Doc(w.RevealA)), HttpStatusCode.NotFound); + } + + [Fact] + public async Task A_reveal_after_the_deadline_is_refused_and_the_voter_is_shown_unrevealed() + { + var w = await SeedAsync(); + var ana = As(w.Ana, "ana"); + await Refused(await ana.PostAsJsonAsync($"/api/sealed-motions/{w.LateMotion}/reveal", Doc(w.LateMotion)), HttpStatusCode.Conflict); + var result = await As(w.Bo, "bo").GetAsync($"/api/sealed-motions/{w.LateMotion}/result"); + var body = await result.Content.ReadAsStringAsync(); + Assert.True(result.StatusCode == HttpStatusCode.OK, body); + Assert.Contains("committed but did not reveal", body); + Assert.DoesNotContain(Reason, body); + // Still refused after the close has run, and the published result did not change. + await Refused(await ana.PostAsJsonAsync($"/api/sealed-motions/{w.LateMotion}/reveal", Doc(w.LateMotion)), HttpStatusCode.Conflict); + Assert.Equal(body, await (await As(w.Cy, "cy").GetAsync($"/api/sealed-motions/{w.LateMotion}/result")).Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Close_with_ballots_publishes_them_once_with_a_stable_digest() + { + var w = await SeedAsync(); + var first = await (await As(w.Bo, "bo").GetAsync($"/api/sealed-motions/{w.ClosedWithBallot}/result")).Content.ReadAsStringAsync(); + Assert.Contains(Reason, first); + Assert.Contains(Salt, first); + Assert.Contains("\"outcome\":\"winner\"", first); + var second = await (await As(w.Cy, "cy").GetAsync($"/api/sealed-motions/{w.ClosedWithBallot}/result")).Content.ReadAsStringAsync(); + Assert.Equal(first, second); + + using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + Assert.Equal(1, await db.AuditEvents.CountAsync(x => x.Action == "sealed.closed" && x.EntityId == w.ClosedWithBallot.ToString())); + Assert.Equal(MotionStatus.Closed, (await db.Motions.SingleAsync(x => x.Id == w.ClosedWithBallot)).Status); + } +} diff --git a/tests/Bifrost.Web.Tests/SealedBallotTests.cs b/tests/Bifrost.Web.Tests/SealedBallotTests.cs index 754d28b..33b2e30 100644 --- a/tests/Bifrost.Web.Tests/SealedBallotTests.cs +++ b/tests/Bifrost.Web.Tests/SealedBallotTests.cs @@ -4,6 +4,9 @@ using Bifrost.Web.Domain; using Bifrost.Web.Features.Motions; using Bifrost.Web.Features.Shared; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using System.Security.Cryptography; +using System.Text; namespace Bifrost.Web.Tests; @@ -268,4 +271,90 @@ public sealed class SealedBallotTests Assert.Equal(0, await rig.Service.FinalizeDueAsync(default)); Assert.Equal(1, await rig.Db.AuditEvents.CountAsync(x => x.Action == "sealed.closed")); } + + // Runs a callback once, just before the first save that closes a motion: the window between the close reading its ballots and writing. + private sealed class BeforeCloseSave(Func action) : SaveChangesInterceptor + { + private bool _done; + + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (!_done && eventData.Context!.ChangeTracker.Entries().Any(e => e.State == EntityState.Modified && e.Entity.Status == MotionStatus.Closed)) + { + _done = true; + await action(); + } + + return result; + } + } + + private static (BifrostDbContext Db, Clock Clock, SealedBallotService Service) Peer(string name, DateTimeOffset at, IInterceptor? interceptor = null) + { + var clock = new Clock(at); + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(name); + if (interceptor is not null) options.AddInterceptors(interceptor); + var db = new BifrostDbContext(options.Options); + return (db, clock, new SealedBallotService(db, new AuditTrailService(db, clock), clock)); + } + + private static async Task<(CurrentBifrostActor Actor, SealedMotionState Motion)> SeedPeerWorld(BifrostDbContext db, SealedBallotService service) + { + var user = new UserAccount { DisplayName = "Ana", GitHubLogin = "ana", BifrostIdentity = "ana", NormalizedBifrostIdentity = "ANA", NormalizedGitHubLogin = "ANA" }; + user.Membership = new Membership { UserAccountId = user.Id, Status = MembershipStatus.Active, CanModerateMotions = true }; + db.UserAccounts.Add(user); + await db.SaveChangesAsync(); + var actor = new CurrentBifrostActor(true, user); + var motion = await service.OpenAsync(actor, new OpenSealedMotionCommand("t", "s", ["Ana", "Bo"], T0.AddHours(1), T0.AddHours(2)), default); + var doc = new SealedBallotDocument(SealedBallotDocument.SchemaId, motion.Id, 1, "Ana", "Mine.", Salt); + await service.CommitAsync(actor, motion.Id, SealedBallotDocument.Commit(doc, user.Id), default); + return (actor, motion); + } + + [Fact] + public async Task A_reveal_that_loses_the_race_to_the_close_is_refused_and_is_not_in_the_frozen_set() + { + var name = $"sealed-freeze-a-{Guid.NewGuid():N}"; + var closer = Peer(name, T0); + var (actor, motion) = await SeedPeerWorld(closer.Db, closer.Service); + closer.Clock.Now = T0.AddMinutes(90); + var revealer = Peer(name, T0.AddMinutes(90)); // its clock still says "reveal window" + _ = await revealer.Db.Motions.ToListAsync(); // and it holds a stale, Open copy of the motion + + closer.Clock.Now = T0.AddHours(3); + var result = await closer.Service.GetResultAsync(actor, motion.Id, default); + Assert.Empty(result.Ballots); + + var ex = await Assert.ThrowsAsync(() => + revealer.Service.RevealAsync(actor, motion.Id, new SealedBallotDocument(SealedBallotDocument.SchemaId, motion.Id, 1, "Ana", "Mine.", Salt), default)); + Assert.Equal(SealedBallotFailure.WrongPhase, ex.Failure); + + await using var check = Peer(name, T0.AddHours(4)).Db; + Assert.Null((await check.SealedBallots.AsNoTracking().SingleAsync()).RevealedAtUtc); + Assert.Equal(result.CommitmentDigest, (await check.Motions.AsNoTracking().SingleAsync()).ResultDigest); + } + + [Fact] + public async Task A_close_that_read_before_a_reveal_landed_retries_and_the_digest_covers_that_reveal() + { + var name = $"sealed-freeze-b-{Guid.NewGuid():N}"; + var revealer = Peer(name, T0); + var (actor, motion) = await SeedPeerWorld(revealer.Db, revealer.Service); + revealer.Clock.Now = T0.AddMinutes(90); + var doc = new SealedBallotDocument(SealedBallotDocument.SchemaId, motion.Id, 1, "Ana", "Mine.", Salt); + // The reveal lands after the close read its ballots and before the close wrote. + var closer = Peer(name, T0.AddHours(3), new BeforeCloseSave(() => revealer.Service.RevealAsync(actor, motion.Id, doc, default))); + + var result = await closer.Service.GetResultAsync(actor, motion.Id, default); + + Assert.Single(result.Ballots); + Assert.Empty(result.Unrevealed); + await using var check = Peer(name, T0.AddHours(4)).Db; + var stored = await check.Motions.AsNoTracking().SingleAsync(); + Assert.Equal(1, stored.RevealSeq); + var ballot = await check.SealedBallots.AsNoTracking().SingleAsync(); + var expected = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"reveals:1\n{ballot.UserAccountId:D}:{ballot.Commitment}:revealed"))).ToLowerInvariant(); + 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")); + } } -- 2.47.3 From 8d6b45ae696e2cf6757d07ce0b9edfa975a2346a Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 9 Nov 2026 20:41:06 +0000 Subject: [PATCH 4/5] doc: ballots are JSON documents, not typed CultNet documents Co-Authored-By: Claude Sonnet 5.5 --- docs/sealed-ballot-motion.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sealed-ballot-motion.md b/docs/sealed-ballot-motion.md index 16bc806..6b252b0 100644 --- a/docs/sealed-ballot-motion.md +++ b/docs/sealed-ballot-motion.md @@ -70,7 +70,7 @@ holding that Mind's plaintext from cast, which is the gap commit-reveal removes. ## Where ballots must never travel -Ballots go to Bifrost as typed documents over the endpoints above, never as room text (chat history is readable by the host +Ballots go to Bifrost as JSON documents over the endpoints above, never as room text (chat history is readable by the host and admins before close). A pinned motion announces only the `announcement` line: phase and times. ## Close is race-safe -- 2.47.3 From 55b7f79c13b9e26383f7a98054d5beae6b60b33e Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 9 Nov 2026 20:47:23 +0000 Subject: [PATCH 5/5] 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); + } } -- 2.47.3