diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 62fd762..42b71f9 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -63,6 +63,7 @@
+
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/ServiceLevelCalculator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/ServiceLevelCalculator.cs
new file mode 100644
index 0000000..e2fff26
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/ServiceLevelCalculator.cs
@@ -0,0 +1,40 @@
+using Akka.Cluster;
+
+namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
+
+public readonly record struct NodeHealthInputs(
+ MemberStatus MemberState,
+ bool DbReachable,
+ bool OpcUaProbeOk,
+ bool Stale,
+ bool IsDriverRoleLeader);
+
+///
+/// Pure ServiceLevel computation per design §6. Output range 0–255, where higher = "more
+/// authoritative." The OPC UA SDK exposes this as the node's ServiceLevel Variable and
+/// redundant clients use it to pick which server to subscribe to.
+///
+/// Tiering:
+/// - Member not Up/Joining: 0 (cluster cannot trust this node).
+/// - DB reachable + OPC UA probe ok + not stale: 240 (full service).
+/// - Stale config (DB reachable or not, OPC UA probe state ignored): 100 or 200 depending on DB.
+/// - +10 bonus when this node holds the role-leader lease for the "driver" role.
+///
+public static class ServiceLevelCalculator
+{
+ public static byte Compute(NodeHealthInputs h)
+ {
+ if (h.MemberState is not (MemberStatus.Up or MemberStatus.Joining))
+ return 0;
+
+ var basis = (h.DbReachable, h.OpcUaProbeOk, h.Stale) switch
+ {
+ (true, true, false) => 240,
+ (true, _, true) => 200,
+ (false, _, true) => 100,
+ _ => 0,
+ };
+
+ return (byte)Math.Clamp(basis + (h.IsDriverRoleLeader ? 10 : 0), 0, 255);
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
index 2ec6399..d30b3e2 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj
@@ -11,7 +11,10 @@
+
+
+
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Harness/ControlPlaneTestHarness.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Harness/ControlPlaneTestHarness.cs
new file mode 100644
index 0000000..68b662f
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Harness/ControlPlaneTestHarness.cs
@@ -0,0 +1,54 @@
+using Akka.TestKit.Xunit2;
+using Microsoft.EntityFrameworkCore;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+
+namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
+
+///
+/// Akka TestKit fixture for ControlPlane actor tests. Provides:
+/// - A test ActorSystem (xunit2 TestKit) wired with PubSub/Cluster extensions.
+/// - A fresh in-memory per harness instance.
+/// - An the actors can hold.
+///
+/// One harness per test fact — InMemory provider gives strong isolation when the database
+/// name is unique (random Guid).
+///
+public abstract class ControlPlaneActorTestBase : TestKit
+{
+ protected static string AkkaTestHocon => @"
+akka {
+ loglevel = ""WARNING""
+ actor {
+ provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster""
+ }
+ remote.dot-netty.tcp {
+ hostname = ""127.0.0.1""
+ port = 0
+ }
+ cluster {
+ seed-nodes = []
+ roles = [""admin""]
+ min-nr-of-members = 1
+ run-coordinated-shutdown-when-down = off
+ }
+}";
+
+ protected ControlPlaneActorTestBase() : base(AkkaTestHocon) { }
+
+ protected static IDbContextFactory NewInMemoryDbFactory(string? dbName = null)
+ {
+ dbName ??= Guid.NewGuid().ToString("N");
+ return new InMemoryConfigDbFactory(dbName);
+ }
+
+ private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory
+ {
+ public OtOpcUaConfigDbContext CreateDbContext()
+ {
+ var opts = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(dbName)
+ .Options;
+ return new OtOpcUaConfigDbContext(opts);
+ }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ServiceLevelCalculatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ServiceLevelCalculatorTests.cs
new file mode 100644
index 0000000..53047cb
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ServiceLevelCalculatorTests.cs
@@ -0,0 +1,70 @@
+using Akka.Cluster;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
+
+namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
+
+public sealed class ServiceLevelCalculatorTests
+{
+ [Theory]
+ [InlineData(MemberStatus.Down)]
+ [InlineData(MemberStatus.Removed)]
+ [InlineData(MemberStatus.Exiting)]
+ [InlineData(MemberStatus.Leaving)]
+ public void NotUp_returns_zero(MemberStatus status)
+ {
+ var sl = ServiceLevelCalculator.Compute(new(status,
+ DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: true));
+ sl.ShouldBe((byte)0);
+ }
+
+ [Fact]
+ public void Fully_healthy_non_leader_returns_240()
+ {
+ var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
+ DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: false));
+ sl.ShouldBe((byte)240);
+ }
+
+ [Fact]
+ public void Fully_healthy_role_leader_returns_250()
+ {
+ var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
+ DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: true));
+ sl.ShouldBe((byte)250);
+ }
+
+ [Fact]
+ public void Db_reachable_but_stale_returns_200()
+ {
+ var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
+ DbReachable: true, OpcUaProbeOk: true, Stale: true, IsDriverRoleLeader: false));
+ sl.ShouldBe((byte)200);
+ }
+
+ [Fact]
+ public void Db_unreachable_and_stale_returns_100()
+ {
+ var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
+ DbReachable: false, OpcUaProbeOk: false, Stale: true, IsDriverRoleLeader: false));
+ sl.ShouldBe((byte)100);
+ }
+
+ [Fact]
+ public void Opcua_probe_fail_when_not_stale_returns_zero()
+ {
+ // (DbReachable=true, OpcUaProbeOk=false, Stale=false) falls through to the catch-all 0.
+ var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
+ DbReachable: true, OpcUaProbeOk: false, Stale: false, IsDriverRoleLeader: false));
+ sl.ShouldBe((byte)0);
+ }
+
+ [Fact]
+ public void Joining_member_is_treated_like_Up_for_grading()
+ {
+ var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Joining,
+ DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: false));
+ sl.ShouldBe((byte)240);
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj
new file mode 100644
index 0000000..4d9522f
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj
@@ -0,0 +1,33 @@
+
+
+
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.ControlPlane.Tests
+ true
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+