From 96da1b7a8c3d280b62d02e42b07ff8150f5864f8 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 9 Nov 2026 20:30:35 +0000 Subject: [PATCH] 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")); + } }