diff --git a/scripts/validation/pair-gate/PairGate.csproj b/scripts/validation/pair-gate/PairGate.csproj
new file mode 100644
index 000000000..3d1ec3d8d
--- /dev/null
+++ b/scripts/validation/pair-gate/PairGate.csproj
@@ -0,0 +1,11 @@
+
+
+
+ Exe
+ false
+
+
+
+
+
+
diff --git a/scripts/validation/pair-gate/Program.cs b/scripts/validation/pair-gate/Program.cs
new file mode 100644
index 000000000..d4b843ff8
--- /dev/null
+++ b/scripts/validation/pair-gate/Program.cs
@@ -0,0 +1,199 @@
+// lmxopcua#801 pair-behaviour gate helper — LOCAL docker-dev rig only (never a wonder box).
+//
+// Mints/revokes API keys through the same IApiKeyAdminService the AdminUI /apikeys page calls, edits
+// NodeAcl grants in the rig ConfigDb, and holds real OPC UA sessions (Client.Shared, the CLI's own
+// client) open against one node so revocation/expiry can be observed on an ALREADY-OPEN session.
+// Credentials are written to a 0600 file and never printed; output names key ids only.
+using System.Diagnostics;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Opc.Ua;
+using ZB.MOM.WW.OtOpcUa.Client.Shared;
+using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Core.Authorization;
+
+var configDb = Environment.GetEnvironmentVariable("PAIRGATE_CONFIGDB")
+ ?? "Server=localhost,14330;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;";
+// The docker-dev rig's committed dev pepper (docker-dev/docker-compose.yml x-apikeys-env).
+var pepper = Environment.GetEnvironmentVariable("PAIRGATE_PEPPER") ?? "docker-dev-apikey-pepper-not-for-production";
+var pkiRoot = Environment.GetEnvironmentVariable("PAIRGATE_PKI")
+ ?? Path.Combine(Path.GetTempPath(), "otopcua-pairgate-pki");
+
+if (args.Length == 0)
+{
+ Console.Error.WriteLine("""
+ usage:
+ mint [expiresUtcIso] mint via IApiKeyAdminService; writes /.key (0600)
+ set-enabled IApiKeyAdminService.SetEnabledAsync (AdminUI Revoke / Re-enable)
+ grant
+ ungrant
+ probe <@credFile|anon> [writeValue] one fresh session: activate, read + write the probe tag
+ watch <@credFile|anon> ...
+ ONE session held open; JSON lines: data changes + a read/write every 3 s
+ server ServiceLevel (i=2267) and ServerArray (i=2254)
+ """);
+ return 2;
+}
+
+var dbOptions = new DbContextOptionsBuilder().UseSqlServer(configDb).Options;
+IDbContextFactory factory = new PooledDbContextFactory(dbOptions);
+
+switch (args[0])
+{
+ case "mint":
+ {
+ DateTime? expires = args.Length > 4
+ ? DateTime.Parse(args[4], null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal)
+ : null;
+ var presented = await new ApiKeyAdminService(factory, pepper).CreateAsync(args[2], args[3], expires, "lmxopcua#801 pair gate", default);
+ Directory.CreateDirectory(args[1]);
+ var path = Path.Combine(args[1], args[2] + ".key");
+ await File.WriteAllTextAsync(path, presented);
+ if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ var keyId = presented.Split('_')[1];
+ Emit(new { op = "mint", name = args[2], principal = args[3], keyId, expiresUtc = expires, credFile = path });
+ return 0;
+ }
+ case "set-enabled":
+ await new ApiKeyAdminService(factory, pepper).SetEnabledAsync(args[1], bool.Parse(args[2]), default);
+ Emit(new { op = "set-enabled", keyId = args[1], enabled = bool.Parse(args[2]) });
+ return 0;
+ case "grant":
+ {
+ await using var db = await factory.CreateDbContextAsync();
+ db.NodeAcls.Add(new NodeAcl
+ {
+ NodeAclRowId = Guid.NewGuid(),
+ NodeAclId = args[1],
+ Principal = args[2],
+ ClusterId = args[3],
+ ScopeKind = Enum.Parse(args[4]),
+ ScopeId = args[5] == "-" ? null : args[5],
+ PermissionFlags = (NodePermissions)int.Parse(args[6]),
+ Notes = "lmxopcua#801 pair gate",
+ });
+ await db.SaveChangesAsync();
+ Emit(new { op = "grant", nodeAclId = args[1], principal = args[2], clusterId = args[3], scopeKind = args[4], scopeId = args[5], flags = int.Parse(args[6]) });
+ return 0;
+ }
+ case "ungrant":
+ {
+ await using var db = await factory.CreateDbContextAsync();
+ var n = await db.NodeAcls.Where(a => a.NodeAclId == args[1]).ExecuteDeleteAsync();
+ Emit(new { op = "ungrant", nodeAclId = args[1], rows = n });
+ return 0;
+ }
+ case "probe":
+ return await ProbeAsync(args[1], args[2], args.Length > 3 ? args[3] : null);
+ case "watch":
+ return await WatchAsync(args[1], args[2], int.Parse(args[3]), args[4], args[5..]);
+ case "server":
+ return await ServerAsync(args[1]);
+ default:
+ Console.Error.WriteLine($"unknown command {args[0]}");
+ return 2;
+}
+
+ConnectionSettings Settings(string url, string cred)
+{
+ var s = new ConnectionSettings
+ {
+ EndpointUrl = url,
+ SecurityMode = SecurityMode.None,
+ SessionTimeoutSeconds = 60,
+ AutoAcceptCertificates = true,
+ CertificateStorePath = pkiRoot,
+ };
+ if (cred != "anon")
+ {
+ s.Username = "apikey";
+ s.Password = File.ReadAllText(cred.TrimStart('@')).Trim();
+ }
+ return s;
+}
+
+string Who(string cred) => cred == "anon" ? "anonymous" : Path.GetFileNameWithoutExtension(cred.TrimStart('@'));
+
+async Task ProbeAsync(string url, string cred, string? writeValue)
+{
+ var client = new OpcUaClientServiceFactory().Create();
+ var probeTag = Environment.GetEnvironmentVariable("PAIRGATE_TAG") ?? "ns=2;s=pymodbus/plc/HR200";
+ try
+ {
+ await client.ConnectAsync(Settings(url, cred));
+ }
+ catch (Exception ex)
+ {
+ Emit(new { op = "probe", url, who = Who(cred), activated = false, error = Short(ex) });
+ return 1;
+ }
+ var read = await client.ReadValueAsync(NodeId.Parse(probeTag));
+ string? write = null;
+ if (writeValue is not null)
+ write = (await client.WriteValueAsync(NodeId.Parse(probeTag), ushort.Parse(writeValue))).ToString();
+ Emit(new { op = "probe", url, who = Who(cred), activated = true, read = read.StatusCode.ToString(), value = read.WrappedValue.ToString(), write });
+ await client.DisconnectAsync();
+ return 0;
+}
+
+async Task WatchAsync(string url, string cred, int seconds, string writeTag, string[] subscribeTags)
+{
+ var client = new OpcUaClientServiceFactory().Create();
+ var who = Who(cred);
+ client.DataChanged += (_, e) => Emit(new { op = "data", url, who, node = e.NodeId, status = e.Value.StatusCode.ToString(), value = e.Value.WrappedValue.ToString() });
+ client.ConnectionStateChanged += (_, e) => Emit(new { op = "conn", url, who, from = e.OldState.ToString(), to = e.NewState.ToString(), endpoint = e.EndpointUrl });
+ try
+ {
+ await client.ConnectAsync(Settings(url, cred));
+ }
+ catch (Exception ex)
+ {
+ Emit(new { op = "watch-start", url, who, activated = false, error = Short(ex) });
+ return 1;
+ }
+ Emit(new { op = "watch-start", url, who, activated = true });
+ foreach (var t in subscribeTags) await client.SubscribeAsync(NodeId.Parse(t), 500);
+ var sw = Stopwatch.StartNew();
+ ushort v = 100;
+ while (sw.Elapsed < TimeSpan.FromSeconds(seconds))
+ {
+ try
+ {
+ var r = await client.ReadValueAsync(NodeId.Parse(writeTag));
+ var w = await client.WriteValueAsync(NodeId.Parse(writeTag), v++);
+ Emit(new { op = "rw", url, who, read = r.StatusCode.ToString(), write = w.ToString(), connected = client.IsConnected });
+ }
+ catch (Exception ex)
+ {
+ Emit(new { op = "rw", url, who, error = Short(ex), connected = client.IsConnected });
+ }
+ await Task.Delay(3000);
+ }
+ await client.DisconnectAsync();
+ Emit(new { op = "watch-end", url, who });
+ return 0;
+}
+
+async Task ServerAsync(string url)
+{
+ var client = new OpcUaClientServiceFactory().Create();
+ await client.ConnectAsync(Settings(url, "anon"));
+ var sl = await client.ReadValueAsync(VariableIds.Server_ServiceLevel);
+ var sa = await client.ReadValueAsync(VariableIds.Server_ServerArray);
+ var arr = sa.WrappedValue.ToString();
+ Emit(new { op = "server", url, serviceLevel = sl.WrappedValue.ToString(), slSource = sl.SourceTimestamp.ToString(), serverArray = arr });
+ await client.DisconnectAsync();
+ return 0;
+}
+
+static string Short(Exception ex) => ex.GetBaseException().Message.Split('\n')[0];
+
+static void Emit(object o)
+{
+ var line = JsonSerializer.Serialize(o);
+ lock (typeof(Console)) Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff} {line}");
+}
diff --git a/scripts/validation/pair-gate/README.md b/scripts/validation/pair-gate/README.md
new file mode 100644
index 000000000..4f649299d
--- /dev/null
+++ b/scripts/validation/pair-gate/README.md
@@ -0,0 +1,35 @@
+# Pair-behaviour gate helper (lmxopcua#801)
+
+`PairGate` is a small console tool for the **local docker-dev rig only**. Never point it at a wonder box.
+It drives the redundancy and revocation half of the #801 SDK 2 live gate against the central pair
+(central-1 `opc.tcp://localhost:4840`, central-2 `:4841`):
+
+- **`mint` / `set-enabled`** call the same `IApiKeyAdminService` that the AdminUI `/apikeys` page uses
+ (Create key, Revoke / Re-enable). The rig's committed dev pepper is the default. Override it with
+ `PAIRGATE_PEPPER` and the ConfigDb with `PAIRGATE_CONFIGDB`. The credential goes to `/.key`
+ (mode 0600) and is never printed. Output names the key id only.
+- **`grant` / `ungrant`** add or remove a `NodeAcl` row in the rig ConfigDb.
+- **`probe`** opens a fresh session (a new activation), then reads and writes the probe tag
+ (`PAIRGATE_TAG`, default the writable `ns=2;s=pymodbus/plc/HR200`).
+- **`watch`** holds **one** session open for N seconds. It subscribes to the given tags and reads and writes
+ every 3 s. Output is JSON lines, so a revocation or expiry can be seen landing on an
+ **already-open** session.
+- **`server`** reads `Server.ServiceLevel` (`i=2267`) and `Server.ServerArray` (`i=2254`).
+
+DB edits only reach the nodes' data-plane snapshot through a deployment (`docker-dev/deploy.sh c1.log &
+$g watch opc.tcp://localhost:4841 @keys/k-revoke.key 600 ns=2\;s=pymodbus/plc/HR200 ns=2\;s=pymodbus/plc/HR100 > c2.log &
+$g set-enabled false # new activations refused within StoreCacheSeconds
+./docker-dev/deploy.sh