diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs
new file mode 100644
index 00000000..859031b6
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs
@@ -0,0 +1,188 @@
+using System.Collections.Concurrent;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using Akka.Actor;
+using Akka.TestKit.Xunit2;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+using ZB.MOM.WW.ScadaBridge.ManagementService;
+using ZB.MOM.WW.ScadaBridge.Security;
+
+namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
+
+///
+/// arch-review UA2: dispatch-coverage guard. Every command type registered in
+/// must be reachable by a real dispatch arm
+/// in . A command with no arm falls through to the
+/// registry's default case, which throws
+/// ("Unknown command type"). This test proves that signature never occurs for any
+/// registered command (bar the documented exclusion list), so a newly-added command
+/// that someone forgot to wire up fails CI here rather than at runtime.
+///
+///
+/// Mechanism: for every registered command type we send a
+/// instance (all fields default —
+/// we never execute the handler for real) inside an envelope carrying ALL roles, so
+/// the authorization gate always passes. The actor runs on a service provider whose
+/// scoped throws a sentinel exception, so
+/// any handler that touches the provider faults with the sentinel (a pass — the arm
+/// exists). Only a command with NO arm faults with .
+/// A recording logger captures the exception maps in
+/// its fault path, keyed by correlation id; commands that answer without ever
+/// touching the provider (or throw some other fault) simply are not
+/// and therefore pass.
+///
+public class DispatchCoverageTests : TestKit, IDisposable
+{
+ ///
+ /// Commands that are registered in (so they
+ /// carry a stable wire name across the management boundary) but are intentionally
+ /// NOT dispatched by because they are serviced by a
+ /// different actor path. Each entry is a deliberate design decision, not a missing arm.
+ ///
+ private static readonly HashSet Excluded = new()
+ {
+ // The two-step "ResolveRoles + command" flow is retired — the HTTP endpoint
+ // resolves roles itself and sends a single envelope. Leaving a handler would
+ // expose role-mapping data to a raw ClusterClient sender with no role gate.
+ // See ManagementActor.DispatchCommand (the comment above the default arm, ~line 472).
+ typeof(ResolveRolesCommand),
+
+ // Site-routed, not a central ManagementActor command. Backs the CentralUI
+ // "Test Bindings" dialog, which sends it straight through
+ // CommunicationService.ReadTagValuesAsync (in-process) to the site's
+ // DataConnectionManagerActor / DataConnectionActor — never through the
+ // ManagementActor dispatch switch. It lives in the Messages.Management
+ // namespace (hence auto-registered) only to earn a stable wire name for the
+ // cross-cluster boundary; see ReadTagValuesCommandRegistryTests. A
+ // ManagementActor arm would be the wrong transport for this command.
+ typeof(ReadTagValuesCommand),
+ };
+
+ private static IEnumerable RegisteredCommandTypes() =>
+ typeof(ManagementEnvelope).Assembly.GetTypes()
+ .Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
+ && t.Name.EndsWith("Command", StringComparison.Ordinal)
+ && !t.IsAbstract);
+
+ void IDisposable.Dispose() => Shutdown();
+
+ [Fact]
+ public void EveryRegisteredCommand_ReachesADispatchArm()
+ {
+ var logger = new RecordingLogger();
+ var actor = CreateSentinelActor(logger);
+
+ var commandTypes = RegisteredCommandTypes()
+ .Where(t => !Excluded.Contains(t))
+ .OrderBy(t => t.Name, StringComparer.Ordinal)
+ .ToList();
+
+ // Sanity: the exclusion list must reference real, registered command types.
+ foreach (var excluded in Excluded)
+ {
+ Assert.Contains(excluded, RegisteredCommandTypes());
+ }
+
+ var noDispatchArm = new List();
+
+ foreach (var type in commandTypes)
+ {
+ var command = RuntimeHelpers.GetUninitializedObject(type);
+ var correlationId = Guid.NewGuid().ToString("N");
+ var envelope = new ManagementEnvelope(
+ new AuthenticatedUser("dispatch-probe", "Dispatch Probe", Roles.All, Array.Empty()),
+ command, correlationId);
+
+ actor.Tell(envelope);
+
+ // Barrier: every command produces exactly one response (Success / Error /
+ // Unauthorized). By the time it arrives, MapFault has already logged any
+ // fault, so the recording logger is populated for this correlation id.
+ ExpectMsg