Merge pull request 'Sealed-ballot motions: commit-reveal over gamecult.sealed_ballot.v0' (#21) from heimdall/sealed-ballot-motion into main

This commit is contained in:
Heimdall 2026-11-09 20:54:05 +00:00
commit b42b86f816
13 changed files with 1503 additions and 2 deletions

View file

@ -0,0 +1,101 @@
# 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 shape: the JSON record `SealedBallotDocument` carrying the string constant `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, 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. 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 motion closed"). A conflict from another voter's reveal while the window is open is re-read and retried, up to five times, then answered 409 "contended; send it again"; the same 409 "contended" is what `/result` answers if its close loses five races in a row; the digest covers `reveals:<RevealSeq>` plus every commitment's revealed/unrevealed state. A reveal after
the deadline or after close is refused. Anyone can recompute each commitment from the published fields. Nobody can close early, extend, edit or break a tie.
Canonical document (`gamecult.sealed_ballot.v0`): `{schema, motionId, round, candidate, reason, salt}`. Commitment =
`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. `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
| 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), 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
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 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
`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" = <old>` 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.

View file

@ -41,6 +41,8 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
public DbSet<Vote> Votes => Set<Vote>();
public DbSet<SealedBallot> SealedBallots => Set<SealedBallot>();
public DbSet<LedgerEntry> LedgerEntries => Set<LedgerEntry>();
public DbSet<PayoutProposalBatch> PayoutProposalBatches => Set<PayoutProposalBatch>();
@ -275,6 +277,9 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
entity.Property(x => x.Category).HasConversion<string>();
entity.Property(x => x.Status).HasConversion<string>();
entity.Property(x => x.ApprovalThreshold).HasPrecision(6, 3);
entity.Property(x => x.Kind).HasConversion<string>();
entity.Property(x => x.RevealSeq).IsConcurrencyToken();
entity.Property(x => x.Status).IsConcurrencyToken();
entity.HasOne(x => x.Project)
.WithMany(x => x.Motions)
.HasForeignKey(x => x.ProjectId)
@ -299,6 +304,18 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<SealedBallot>(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<LedgerEntry>(entity =>
{
entity.Property(x => x.EntryType).HasConversion<string>();

View file

@ -0,0 +1,60 @@
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<string>(name: "Candidates", table: "Motions", type: "text", nullable: false, defaultValue: "");
migrationBuilder.AddColumn<DateTimeOffset>(name: "CommitClosesAtUtc", table: "Motions", type: "timestamp with time zone", nullable: true);
migrationBuilder.AddColumn<string>(name: "Kind", table: "Motions", type: "text", nullable: false, defaultValue: "Standard");
migrationBuilder.AddColumn<Guid>(name: "PriorMotionId", table: "Motions", type: "uuid", nullable: true);
migrationBuilder.AddColumn<string>(name: "ResultDigest", table: "Motions", type: "text", nullable: false, defaultValue: "");
migrationBuilder.AddColumn<int>(name: "RevealSeq", table: "Motions", type: "integer", nullable: false, defaultValue: 0);
migrationBuilder.AddColumn<int>(name: "Round", table: "Motions", type: "integer", nullable: false, defaultValue: 1);
migrationBuilder.CreateTable(
name: "SealedBallots",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
MotionId = table.Column<Guid>(type: "uuid", nullable: false),
UserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
Commitment = table.Column<string>(type: "text", nullable: false),
Revision = table.Column<int>(type: "integer", nullable: false),
CommittedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RevealedCandidate = table.Column<string>(type: "text", nullable: true),
RevealedReason = table.Column<string>(type: "text", nullable: true),
Salt = table.Column<string>(type: "text", nullable: true),
RevealedAtUtc = table.Column<DateTimeOffset>(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", "RevealSeq", "Round" })
{
migrationBuilder.DropColumn(name: column, table: "Motions");
}
}
}
}

View file

@ -896,15 +896,29 @@ namespace Bifrost.Web.Data.Migrations
b.Property<DateTimeOffset>("ClosesAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Candidates")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("CommitClosesAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedByUserAccountId")
.HasColumnType("uuid");
b.Property<string>("Kind")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("OpensAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PriorMotionId")
.HasColumnType("uuid");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
@ -915,11 +929,23 @@ namespace Bifrost.Web.Data.Migrations
b.Property<DateTimeOffset?>("ResolvedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("RevealSeq")
.IsConcurrencyToken()
.HasColumnType("integer");
b.Property<string>("ResultDigest")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Round")
.HasColumnType("integer");
b.Property<string>("Scope")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsConcurrencyToken()
.IsRequired()
.HasColumnType("text");
@ -1414,6 +1440,50 @@ namespace Bifrost.Web.Data.Migrations
b.ToTable("VolunteerClaims");
});
modelBuilder.Entity("Bifrost.Web.Domain.SealedBallot", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Commitment")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CommittedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("MotionId")
.HasColumnType("uuid");
b.Property<string>("RevealedCandidate")
.HasColumnType("text");
b.Property<string>("RevealedReason")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevealedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("Salt")
.HasColumnType("text");
b.Property<Guid>("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<Guid>("Id")
@ -2209,6 +2279,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 +2420,8 @@ namespace Bifrost.Web.Data.Migrations
modelBuilder.Entity("Bifrost.Web.Domain.Motion", b =>
{
b.Navigation("SealedBallots");
b.Navigation("Votes");
});

View file

@ -692,11 +692,68 @@ 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: 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;
public Project? Project { get; set; }
public UserAccount CreatedByUserAccount { get; set; } = null!;
public ICollection<Vote> Votes { get; set; } = [];
public ICollection<SealedBallot> 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

View file

@ -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);

View file

@ -0,0 +1,438 @@
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;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Bifrost.Web.Features.Motions;
/// <summary>
/// Sealed-ballot motions: commit, then reveal, then one atomic publication at close.
/// This is the only code that reads <see cref="SealedBallot.RevealedCandidate"/>, <see cref="SealedBallot.RevealedReason"/>
/// or <see cref="SealedBallot.Salt"/>; a test fails if any other source file mentions them.
/// </summary>
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}\z", RegexOptions.Compiled);
private static readonly Regex HexSalt = new(@"^[0-9a-f]{32,128}\z", RegexOptions.Compiled);
public async Task<SealedMotionState> 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<SealedBallotReceipt> 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<SealedBallotReceipt> 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.");
}
// 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++)
{
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(
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);
}
/// <summary>Phase, times and the caller's own commitment status. Never another member's ballot, never revealed content.</summary>
public async Task<SealedMotionState> 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);
}
/// <summary>Finalizes the motion if its deadline has passed, then publishes every ballot at once. Before close it refuses.</summary>
public async Task<SealedMotionResult> 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.");
}
// 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)
{
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)))
.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;
/// <summary>
/// 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).
/// </summary>
private async Task<bool> FinalizeAsync(Motion motion, List<SealedBallot> ballots, DateTimeOffset now, CancellationToken cancellationToken)
{
var lines = ballots.OrderBy(x => x.UserAccountId)
.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;
motion.ResolutionNote = $"Sealed ballots published: {ballots.Count(x => x.RevealedAtUtc != null)} revealed, {ballots.Count(x => x.RevealedAtUtc == null)} committed but unrevealed.";
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException error)
{
foreach (var entry in error.Entries)
{
await entry.ReloadAsync(cancellationToken);
}
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;
}
/// <summary>Closes every sealed-ballot motion whose deadline has passed, so a motion nobody reads still closes.</summary>
public async Task<int> 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.AsNoTracking().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);
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<Motion> 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<string> 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;
}
/// <summary>
/// The typed ballot document, schema <c>gamecult.sealed_ballot.v0</c>. 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.
/// </summary>
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<string> 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<string> 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<SealedTally> Tally, IReadOnlyList<string> Leaders, string Outcome,
IReadOnlyList<RevealedBallot> Ballots, IReadOnlyList<AbstainedMember> Unrevealed, IReadOnlyList<AbstainedMember> NonVoters,
string CommitmentDigest);
public sealed record SealedCommitRequest(string Commitment);
/// <summary>Sweeps due sealed-ballot motions once a minute. Reads also close on demand; this covers a motion nobody reads.</summary>
public sealed class SealedBallotCloser(IServiceScopeFactory scopes, ILogger<SealedBallotCloser> 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<SealedBallotService>().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));
}
}

View file

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

View file

@ -121,6 +121,8 @@ builder.Services.AddScoped<MembershipSynchronizationService>();
builder.Services.AddScoped<BifrostIdentityService>();
builder.Services.AddScoped<GitHubWebhookService>();
builder.Services.AddScoped<MotionGovernanceService>();
builder.Services.AddScoped<SealedBallotService>();
builder.Services.AddHostedService<SealedBallotCloser>();
builder.Services.AddScoped<MotionEveSurfaceService>();
builder.Services.AddScoped<PatronageService>();
builder.Services.AddScoped<StripeWebhookService>();
@ -541,6 +543,43 @@ 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);
async Task<IResult> SealedCall(Func<Task<object>> call)
{
try
{
return Results.Text(JsonSerializer.Serialize(await call(), appJsonSerializerOptions), "application/json");
}
catch (SealedBallotException error)
{
return Results.Text(
JsonSerializer.Serialize(new { error = error.Message }, appJsonSerializerOptions),
"application/json",
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";

View file

@ -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());
}

View file

@ -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<TestWebApplicationFactory>
{
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<W> 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<BifrostDbContext>();
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<BifrostDbContext>();
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);
}
}

View file

@ -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<TestWebApplicationFactory>
{
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<Seeded> SeedAsync()
{
using (var warmup = factory.CreateClient()) { _ = await warmup.GetAsync("/App"); }
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
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<BifrostDbContext>();
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());
}
}

View file

@ -0,0 +1,400 @@
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;
using Microsoft.EntityFrameworkCore.Diagnostics;
using System.Security.Cryptography;
using System.Text;
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<BifrostDbContext>()
.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<SealedMotionState> 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<SealedBallotException>(() => 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<string>
{
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<SealedBallotException>(() => 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<SealedBallotException>(() => rig.Reveal(a, motion.Id, "Ana", "First thought."));
await rig.Reveal(a, motion.Id, "Bo", "Second thought.");
await Assert.ThrowsAsync<SealedBallotException>(() => 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<SealedBallotException>(() => 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<SealedBallotException>(() => 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<SealedBallotException>(() => rig.Reveal(a, motion.Id, "Ana", "Wrong text."));
await Assert.ThrowsAsync<SealedBallotException>(() => 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<InvalidOperationException>(() => 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<BifrostDbContext>().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"));
}
// 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<Task> action) : SaveChangesInterceptor
{
private bool _done;
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
if (!_done && eventData.Context!.ChangeTracker.Entries<Motion>().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<BifrostDbContext>().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<SealedBallotException>(() =>
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"));
}
[Fact]
public async Task Two_simultaneous_reveals_both_land_while_the_window_is_open()
{
var name = $"sealed-two-reveals-{Guid.NewGuid():N}";
var first = Peer(name, T0);
var (ana, motion) = await SeedPeerWorld(first.Db, first.Service);
var bo = new UserAccount { DisplayName = "Bo", GitHubLogin = "bo", BifrostIdentity = "bo", NormalizedBifrostIdentity = "BO", NormalizedGitHubLogin = "BO" };
bo.Membership = new Membership { UserAccountId = bo.Id, Status = MembershipStatus.Active };
first.Db.UserAccounts.Add(bo);
await first.Db.SaveChangesAsync();
var boActor = new CurrentBifrostActor(true, bo);
var boDoc = new SealedBallotDocument(SealedBallotDocument.SchemaId, motion.Id, 1, "Bo", "Mine too.", Salt);
await first.Service.CommitAsync(boActor, motion.Id, SealedBallotDocument.Commit(boDoc, bo.Id), default);
first.Clock.Now = T0.AddMinutes(90);
var second = Peer(name, T0.AddMinutes(90));
_ = await second.Db.Motions.ToListAsync(); // stale RevealSeq 0, window open
await first.Service.RevealAsync(ana, motion.Id, new SealedBallotDocument(SealedBallotDocument.SchemaId, motion.Id, 1, "Ana", "Mine.", Salt), default);
var receipt = await second.Service.RevealAsync(boActor, motion.Id, boDoc, default); // conflicts on RevealSeq, window still open: must retry, not claim a close
Assert.True(receipt.Revealed);
await using var check = Peer(name, T0.AddHours(4)).Db;
Assert.Equal(2, await check.SealedBallots.CountAsync(x => x.RevealedAtUtc != null));
Assert.Equal(2, (await check.Motions.AsNoTracking().SingleAsync()).RevealSeq);
}
[Fact]
public async Task A_salt_with_a_trailing_newline_is_not_hex()
{
var rig = new Rig();
var a = rig.Member("Ana");
var motion = await rig.OpenAsync("Ana", "Bo");
await rig.Cast(a, motion.Id, "Ana", "Mine.", Salt + "\n");
rig.Clock.Now = T0.AddMinutes(90);
var ex = await Assert.ThrowsAsync<SealedBallotException>(() => rig.Reveal(a, motion.Id, "Ana", "Mine.", Salt + "\n"));
Assert.Equal(SealedBallotFailure.Invalid, ex.Failure);
Assert.Null((await rig.Db.SealedBallots.SingleAsync()).RevealedAtUtc);
}
}