Standard close: the loser of a simultaneous close adopts the winner's result

Status is a concurrency token, so the second SaveChanges of two simultaneous
CloseMotionAsync calls throws DbUpdateConcurrencyException. Reload and return,
with no second motion.closed audit event; the same pattern as the sealed closer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-09 22:13:59 +00:00
commit ee3a2f49f5

View file

@ -126,7 +126,21 @@ public sealed class MotionGovernanceService(
? $"Closed with {votesFor:0.##} for and {votesAgainst:0.##} against at a {motion.ApprovalThreshold:P0} threshold."
: "Closed without decisive votes.";
await dbContext.SaveChangesAsync(cancellationToken);
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException error)
{
// Status is a concurrency token: another closer won. Adopt its result and record nothing a second time.
foreach (var entry in error.Entries)
{
await entry.ReloadAsync(cancellationToken);
}
return;
}
await auditTrailService.RecordAsync(
actorUser.Id,
nameof(Motion),