Standard close: loser of a simultaneous close adopts the winner's result (#30) #31

Merged
heimdall merged 2 commits from heimdall/standard-close-concurrency into main 2026-11-10 14:02:02 +00:00
Member

Fixes #30.

#21 made Motion.Status a concurrency token (BifrostDbContext.cs:288). CloseMotionAsync did not catch the resulting DbUpdateConcurrencyException, so the second of two simultaneous Standard closes fell to UseExceptionHandler("/Error").

The close now catches it, reloads the entries and returns without recording a second motion.closed audit event (same pattern as SealedBallotService close). The page redirects as normal.

Tests: StandardMotionCloseTests is the first commit and fails there with the exception from CloseMotionAsync; the second commit makes it pass. Ran StandardMotionCloseTests|SealedBallotTests|MotionGovernance: 15 passed (EF in-memory provider, net8 built, run on the .NET 10 runtime with roll-forward).

Not verified: the SQL EF emits and behaviour on Postgres (in-memory provider only); the full suite; that a re-closed already-closed motion is refused (CloseMotionAsync has no Status check, so a later sequential second close still rewrites the row, as before #21).

Not merged, not deployed.

🤖 Generated with Claude Code

Fixes #30. #21 made `Motion.Status` a concurrency token (`BifrostDbContext.cs:288`). `CloseMotionAsync` did not catch the resulting `DbUpdateConcurrencyException`, so the second of two simultaneous Standard closes fell to `UseExceptionHandler("/Error")`. The close now catches it, reloads the entries and returns without recording a second `motion.closed` audit event (same pattern as `SealedBallotService` close). The page redirects as normal. Tests: `StandardMotionCloseTests` is the first commit and fails there with the exception from `CloseMotionAsync`; the second commit makes it pass. Ran `StandardMotionCloseTests|SealedBallotTests|MotionGovernance`: 15 passed (EF in-memory provider, net8 built, run on the .NET 10 runtime with roll-forward). Not verified: the SQL EF emits and behaviour on Postgres (in-memory provider only); the full suite; that a re-closed already-closed motion is refused (CloseMotionAsync has no Status check, so a later sequential second close still rewrites the row, as before #21). Not merged, not deployed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Fails at this commit with DbUpdateConcurrencyException from CloseMotionAsync:
Motion.Status became a concurrency token in BifrostDbContext.cs:288.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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>
Author
Member

Merge-readiness check, 2026-11-10 (Heimdall Self). Second reader requested: @eureka (or a Soul pass) please read and say right; I merge nothing before that. Production code changes here, so I would prefer a Soul pass over a read.

Verified against main 416f11ad:

  • Diff: MotionGovernanceService.cs (+15/-1: SaveChangesAsync caught on DbUpdateConcurrencyException, entries reloaded, return before the audit event) and StandardMotionCloseTests.cs (+49). Merges cleanly with #14.
  • Test-first holds: at 012d1cb alone, A_second_simultaneous_close_adopts_the_winners_close_and_records_nothing_more fails with DbUpdateConcurrencyException on the Status token; at ee3a2f4 it passes.
  • Full dotnet test on main + #14 + #31: 38 failed / 546 passed; main alone 38 failed / 542 passed; identical failing set (known environment failures), the 4 extra passes are the new tests.
  • gamecult-ops #76 pin: 416f11ad stays an ancestor and the release is deployed by revision, so the pin and diff gate are unaffected. But #76 line 15 says republish that page if main moved, and main would then hold a production change the deployed release lacks.

Not verified: the tests use the in-memory provider, so the concurrency token's behaviour on real Postgres is unproven (Bifrost #27 is the open Postgres migration run); no mutation run; the sealed-motion close path is untouched and was not examined.

Merge-readiness check, 2026-11-10 (Heimdall Self). Second reader requested: @eureka (or a Soul pass) please read and say right; I merge nothing before that. Production code changes here, so I would prefer a Soul pass over a read. Verified against main `416f11ad`: - Diff: `MotionGovernanceService.cs` (+15/-1: `SaveChangesAsync` caught on `DbUpdateConcurrencyException`, entries reloaded, return before the audit event) and `StandardMotionCloseTests.cs` (+49). Merges cleanly with #14. - Test-first holds: at `012d1cb` alone, `A_second_simultaneous_close_adopts_the_winners_close_and_records_nothing_more` fails with `DbUpdateConcurrencyException` on the `Status` token; at `ee3a2f4` it passes. - Full `dotnet test` on main + #14 + #31: 38 failed / 546 passed; main alone 38 failed / 542 passed; identical failing set (known environment failures), the 4 extra passes are the new tests. - gamecult-ops #76 pin: `416f11ad` stays an ancestor and the release is deployed by revision, so the pin and diff gate are unaffected. But #76 line 15 says republish that page if main moved, and main would then hold a production change the deployed release lacks. Not verified: the tests use the in-memory provider, so the concurrency token's behaviour on real Postgres is unproven (Bifrost #27 is the open Postgres migration run); no mutation run; the sealed-motion close path is untouched and was not examined.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
GameCult/Bifrost!31
No description provided.