diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
new file mode 100644
index 0000000..bd86614
--- /dev/null
+++ b/.config/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-stryker": {
+ "version": "5.0.0",
+ "commands": [
+ "dotnet-stryker"
+ ],
+ "rollForward": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 7b68e30..7a6ce13 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,6 @@ TestResults/
.DS_Store
src/notes.txt
+
+# Stryker.NET mutation-testing output
+StrykerOutput/
diff --git a/Switchboard.slnx b/Switchboard.slnx
index 63b89cb..f64dd76 100644
--- a/Switchboard.slnx
+++ b/Switchboard.slnx
@@ -3,6 +3,7 @@
+
diff --git a/stryker-config.json b/stryker-config.json
new file mode 100644
index 0000000..a7e60bb
--- /dev/null
+++ b/stryker-config.json
@@ -0,0 +1,10 @@
+{
+ "stryker-config": {
+ "project": "Switchboard.csproj",
+ "test-projects": [ "tests/Switchboard.Tests/Switchboard.Tests.csproj" ],
+ "target-framework": "net10.0",
+ "configuration": "Release",
+ "mutate": [ "**/*.cs" ],
+ "reporters": [ "progress", "cleartext", "html", "json" ]
+ }
+}
diff --git a/tests/Switchboard.IntegrationTests/AppDbContext.cs b/tests/Switchboard.IntegrationTests/AppDbContext.cs
new file mode 100644
index 0000000..20d07f2
--- /dev/null
+++ b/tests/Switchboard.IntegrationTests/AppDbContext.cs
@@ -0,0 +1,48 @@
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+
+namespace Switchboard.IntegrationTests;
+
+public sealed class Note
+{
+ public int Id { get; set; }
+
+ public string Text { get; set; } = "";
+
+ public string? CreatedBy { get; set; }
+}
+
+public sealed class AuditEntry
+{
+ public int Id { get; set; }
+
+ public string Action { get; set; } = "";
+}
+
+/// The application's DbContext, registered scoped with AddDbContext as a Blazor Server app would.
+public sealed class AppDbContext : DbContext
+{
+ public AppDbContext(DbContextOptions options)
+ : base(options)
+ {
+ }
+
+ public DbSet Notes => Set();
+
+ public DbSet AuditEntries => Set();
+
+ /// Set once the owning DI scope has disposed this instance.
+ public bool IsDisposed { get; private set; }
+
+ public override void Dispose()
+ {
+ base.Dispose();
+ IsDisposed = true;
+ }
+
+ public override async ValueTask DisposeAsync()
+ {
+ await base.DisposeAsync();
+ IsDisposed = true;
+ }
+}
diff --git a/tests/Switchboard.IntegrationTests/CommandGate.cs b/tests/Switchboard.IntegrationTests/CommandGate.cs
new file mode 100644
index 0000000..9216e3a
--- /dev/null
+++ b/tests/Switchboard.IntegrationTests/CommandGate.cs
@@ -0,0 +1,82 @@
+using System;
+using System.Data.Common;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+
+namespace Switchboard.IntegrationTests;
+
+///
+/// Holds one database command just before it executes, so a dispatch is provably mid-operation — inside EF Core's
+/// critical section for that DbContext — while the test starts another one. Microsoft.Data.Sqlite completes its
+/// async calls synchronously, so without this two "concurrent" dispatches would never actually overlap.
+///
+///
+/// it, start the first dispatch, await , start the second, then .
+/// Only the first matching command after arming is held; every other command passes straight through.
+///
+public sealed class CommandGate
+{
+ // Guards against a hang if a test is wrong; never part of what a test asserts.
+ private static readonly TimeSpan HangGuard = TimeSpan.FromSeconds(30);
+
+ private readonly TaskCompletionSource _held = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private readonly TaskCompletionSource _released = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private string? _commandPrefix;
+ private int _armed;
+
+ /// Completes, with the command's text, once a command is being held.
+ public Task Held => _held.Task.WaitAsync(HangGuard);
+
+ /// Holds the next command whose SQL starts with (e.g. INSERT, SELECT).
+ public void Arm(string commandPrefix)
+ {
+ _commandPrefix = commandPrefix;
+ Volatile.Write(ref _armed, 1);
+ }
+
+ /// Lets the held command execute.
+ public void Release() => _released.TrySetResult();
+
+ internal async ValueTask WaitIfArmedAsync(DbCommand command, CancellationToken cancellationToken)
+ {
+ if (Volatile.Read(ref _armed) == 0 ||
+ !command.CommandText.TrimStart().StartsWith(_commandPrefix!, StringComparison.OrdinalIgnoreCase) ||
+ Interlocked.CompareExchange(ref _armed, 0, 1) != 1)
+ {
+ return;
+ }
+
+ _held.TrySetResult(command.CommandText);
+ await _released.Task.WaitAsync(HangGuard, cancellationToken);
+ }
+}
+
+/// Routes every async command through the .
+public sealed class GatingInterceptor : DbCommandInterceptor
+{
+ private readonly CommandGate _gate;
+
+ public GatingInterceptor(CommandGate gate) => _gate = gate;
+
+ public override async ValueTask> ReaderExecutingAsync(
+ DbCommand command, CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default)
+ {
+ await _gate.WaitIfArmedAsync(command, cancellationToken);
+ return result;
+ }
+
+ public override async ValueTask> NonQueryExecutingAsync(
+ DbCommand command, CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default)
+ {
+ await _gate.WaitIfArmedAsync(command, cancellationToken);
+ return result;
+ }
+
+ public override async ValueTask> ScalarExecutingAsync(
+ DbCommand command, CommandEventData eventData, InterceptionResult