From ee3a2f49f57f3a7bdb0e30db72fb9e6520f830cc Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 9 Nov 2026 22:13:59 +0000 Subject: [PATCH] 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 --- .../Features/Motions/MotionGovernanceService.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs index 983526c..bbb1d6b 100644 --- a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs +++ b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs @@ -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),