diff --git a/src/DodoSSH.Api/Features/Identity/EnrollmentService.cs b/src/DodoSSH.Api/Features/Identity/EnrollmentService.cs
index 8e61083..7bf49c5 100644
--- a/src/DodoSSH.Api/Features/Identity/EnrollmentService.cs
+++ b/src/DodoSSH.Api/Features/Identity/EnrollmentService.cs
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using System.Text.Json;
+using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
using DodoSSH.Domain;
@@ -106,9 +107,7 @@ internal sealed class EnrollmentService(
byte[] fingerprint,
CancellationToken cancellationToken)
{
- var strategy = database.Database.CreateExecutionStrategy();
-
- return await strategy.ExecuteAsync(async () =>
+ return await database.ExecuteFreshAsync([user], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
diff --git a/src/DodoSSH.Api/Features/Sync/SyncService.cs b/src/DodoSSH.Api/Features/Sync/SyncService.cs
index 7245aaf..bd59d94 100644
--- a/src/DodoSSH.Api/Features/Sync/SyncService.cs
+++ b/src/DodoSSH.Api/Features/Sync/SyncService.cs
@@ -100,9 +100,7 @@ internal sealed class SyncService(
{
ValidateBatchLimits(request);
- var strategy = database.Database.CreateExecutionStrategy();
-
- return await strategy.ExecuteAsync(async () =>
+ return await database.ExecuteFreshAsync([vault], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
diff --git a/src/DodoSSH.Api/Features/Teams/TeamService.cs b/src/DodoSSH.Api/Features/Teams/TeamService.cs
index ec952fd..0417432 100644
--- a/src/DodoSSH.Api/Features/Teams/TeamService.cs
+++ b/src/DodoSSH.Api/Features/Teams/TeamService.cs
@@ -1,5 +1,6 @@
using System.Globalization;
using DodoSSH.Api.Features.Events;
+using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
@@ -265,9 +266,7 @@ internal sealed class TeamService(
}
var now = clock.GetUtcNow();
- var strategy = database.Database.CreateExecutionStrategy();
-
- var archived = await strategy.ExecuteAsync(async () =>
+ var archived = await database.ExecuteFreshAsync([team], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
@@ -343,9 +342,7 @@ internal sealed class TeamService(
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
.ConfigureAwait(false);
- var strategy = database.Database.CreateExecutionStrategy();
-
- await strategy.ExecuteAsync(async () =>
+ await database.ExecuteFreshAsync([incoming, outgoing], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
@@ -724,9 +721,7 @@ internal sealed class TeamService(
}
var now = clock.GetUtcNow();
- var strategy = database.Database.CreateExecutionStrategy();
-
- var revoked = await strategy.ExecuteAsync(async () =>
+ var revoked = await database.ExecuteFreshAsync([membership], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
diff --git a/src/DodoSSH.Api/Setup/FreshAttempts.cs b/src/DodoSSH.Api/Setup/FreshAttempts.cs
new file mode 100644
index 0000000..30e5f99
--- /dev/null
+++ b/src/DodoSSH.Api/Setup/FreshAttempts.cs
@@ -0,0 +1,72 @@
+using DodoSSH.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+
+namespace DodoSSH.Api.Setup;
+
+///
+/// Runs a write transaction under the retrying execution strategy so that a retry starts from the database
+/// rather than from what the failed attempt left in the change tracker.
+///
+///
+///
+/// The strategy re-runs the delegate on the same context, and the context remembers the attempt that
+/// failed. When the failure is the commit itself — a connection dropped after SaveChanges had
+/// already succeeded — every entity that attempt wrote is marked unchanged, holding values the database
+/// rolled back. The retry then assigns the same values again, change detection sees nothing to save, and
+/// the transaction commits without them. Enrolment lost EnrolledAtUtc that way, and a team hand-over
+/// its two role changes, each reporting success.
+///
+///
+/// So a retry clears the tracker, and reloads each entity the delegate was handed from outside it — the ones
+/// it modifies or reads but did not load itself — so they are tracked again with what the database actually
+/// holds and the delegate's own assignments register as changes. The first attempt runs exactly as it did
+/// before: nothing is cleared or reloaded until something has failed.
+///
+///
+internal static class FreshAttempts
+{
+ /// Runs under the context's execution strategy, freshly each time.
+ /// The request's context.
+ /// Entities loaded before the transaction that the attempt reads or modifies.
+ /// The transaction, which begins and commits its own.
+ internal static Task ExecuteFreshAsync(
+ this DodoDbContext database,
+ IReadOnlyList