diff --git a/scripts/validation/opcua-scripted-alarm-baseline/README.md b/scripts/validation/opcua-scripted-alarm-baseline/README.md
new file mode 100644
index 00000000..3188ae6e
--- /dev/null
+++ b/scripts/validation/opcua-scripted-alarm-baseline/README.md
@@ -0,0 +1,35 @@
+# Independent scripted-alarm scope baseline
+
+This SDK 1.5.378.182-only source probe records the existing opaque-ID behavior
+tracked by [#797](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/797).
+It is deliberately outside the candidate solution and is not a runtime adapter.
+
+Use a clean, separate checkout at original application commit
+`83ed880e4471344deaf008da9bd028c7e170bdc7`. Confirm its package pins before
+running. Copy **only** `ScriptedScopeProbe.cs` temporarily into that checkout's
+`tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/`, refusing to
+overwrite an existing file. Run:
+
+```sh
+dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests \
+ -c Release --filter FullyQualifiedName~BaselineScriptedScopeProbe \
+ --logger trx --results-directory /absolute/fresh-baseline-evidence
+```
+
+Preserve the test exit and run the baseline checkout's
+`scripts/ci/assert-not-all-skipped.sh` with `MIN_EXECUTED=1` against the fresh TRX.
+Remove only the temporary probe you copied after the run, keeping the source
+and TRX evidence here/outside the checkout. Verify baseline tracked files remain
+unchanged. Never load this assembly or old SDK into the candidate process.
+
+The real session has no application-wide roles. The production-valid opaque
+scripted ID is denied by an Equipment grant, routes nothing, and is then allowed
+by an explicit Cluster grant. A passing probe pins **both** outcomes; it does not
+claim narrow equipment authorization works. The independent candidate counterpart
+is `Sdk2OpaqueScriptedScopeBaselineTests`.
+
+Do not make the ID slash-qualified to get a positive result: the current UI
+rejects `/`, and that would hide the production scope-resolution defect. A future
+fix requires reviewed ownership/scope binding without changing opaque IDs or
+widening permissions. This upgrade preserves the baseline denial and links the
+separate defect.
diff --git a/scripts/validation/scadabridge-sdk-compatibility/Directory.Build.props b/scripts/validation/scadabridge-sdk-compatibility/Directory.Build.props
new file mode 100644
index 00000000..4af490d1
--- /dev/null
+++ b/scripts/validation/scadabridge-sdk-compatibility/Directory.Build.props
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable enable
+ true
+ false
+
+
diff --git a/scripts/validation/scadabridge-sdk-compatibility/Directory.Packages.props b/scripts/validation/scadabridge-sdk-compatibility/Directory.Packages.props
new file mode 100644
index 00000000..802827ef
--- /dev/null
+++ b/scripts/validation/scadabridge-sdk-compatibility/Directory.Packages.props
@@ -0,0 +1 @@
+false
diff --git a/scripts/validation/scadabridge-sdk-compatibility/Probe.csproj b/scripts/validation/scadabridge-sdk-compatibility/Probe.csproj
new file mode 100644
index 00000000..3aeca76a
--- /dev/null
+++ b/scripts/validation/scadabridge-sdk-compatibility/Probe.csproj
@@ -0,0 +1,9 @@
+
+ Exe
+
+
+
+
+
+
+
diff --git a/scripts/validation/scadabridge-sdk-compatibility/Program.cs b/scripts/validation/scadabridge-sdk-compatibility/Program.cs
new file mode 100644
index 00000000..aa119432
--- /dev/null
+++ b/scripts/validation/scadabridge-sdk-compatibility/Program.cs
@@ -0,0 +1,129 @@
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Text.Json;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
+using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
+using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
+
+if (args.Length != 3) throw new ArgumentException("Usage: Probe ");
+var endpoint = args[0];
+var mode = Enum.Parse(args[1]);
+var root = Path.GetFullPath(args[2]);
+if (Directory.Exists(root)) throw new IOException("Refusing to reuse an evidence/PKI directory.");
+Directory.CreateDirectory(root);
+using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(2));
+var ct = deadline.Token;
+var cases = new List();
+var failures = 0;
+var options = new OpcUaGlobalOptions
+{
+ ApplicationName = "ScadaBridge-SDK2-compatibility",
+ TrustedIssuerStorePath = Path.Combine(root, "pki", "issuers"),
+ TrustedPeerStorePath = Path.Combine(root, "pki", "trusted"),
+ RejectedCertificateStorePath = Path.Combine(root, "pki", "rejected"),
+};
+// Populate only this probe's trust list; never change the consumer's existing stores.
+var trustSource = Environment.GetEnvironmentVariable("SCADABRIDGE_PROBE_SERVER_CERTS");
+if (!string.IsNullOrEmpty(trustSource))
+{
+ var destination = Path.Combine(options.TrustedPeerStorePath, "certs");
+ Directory.CreateDirectory(destination);
+ foreach (var cert in Directory.GetFiles(trustSource, "*.der")) File.Copy(cert, Path.Combine(destination, Path.GetFileName(cert)));
+}
+var factory = new RealOpcUaClientFactory(options, NullLoggerFactory.Instance);
+await using var adapter = new OpcUaDataConnection(factory, NullLogger.Instance);
+var config = new OpcUaEndpointConfig
+{
+ EndpointUrl = endpoint, SecurityMode = mode, AutoAcceptUntrustedCerts = false,
+ PublishingIntervalMs = 100, SamplingIntervalMs = 100,
+ UserIdentity = new OpcUaUserIdentityConfig
+ {
+ TokenType = OpcUaUserTokenType.UsernamePassword, Username = "apikey",
+ Password = Environment.GetEnvironmentVariable("OPCUA_MATRIX_PASSWORD") ?? throw new InvalidOperationException("Missing task credential"),
+ },
+};
+static void Require(bool value, string reason) { if (!value) throw new InvalidOperationException(reason); }
+async Task Check(string name, Func action)
+{
+ try { await action(); cases.Add(new { name, status = "passed" }); }
+ catch (Exception e) { failures++; cases.Add(new { name, status = "failed", error = e.ToString() }); }
+}
+async Task Wait(Func predicate)
+{
+ for (var i = 0; i < 200; i++) { if (predicate()) return; await Task.Delay(50, ct); }
+ Require(false, "No expected consumer notification within 10 seconds.");
+}
+const string raw = "nsu=https://zb.com/otopcua/raw;s=Matrix/dev/";
+const string uns = "nsu=https://zb.com/otopcua/uns;s=Eq/";
+await Check("actual-adapter-connect-api-key", () => adapter.ConnectAsync(OpcUaEndpointConfigSerializer.ToFlatDict(config), ct));
+if (failures == 0)
+{
+ foreach (var prefix in new[] { raw, uns })
+ {
+ await Check($"scalar-round-trip-{prefix}", async () =>
+ {
+ var write = await adapter.WriteAsync(prefix + "Scalar", 412, ct);
+ Require(write.Success, write.ErrorMessage ?? "Scalar write denied");
+ var read = await adapter.ReadAsync(prefix + "Scalar", ct);
+ Require(read.Success && read.Value?.Value is int i && i == 412, "Scalar value/type changed");
+ });
+ await Check($"array-round-trip-{prefix}", async () =>
+ {
+ var write = await adapter.WriteAsync(prefix + "Vector", new[] { 31, 32, 33 }, ct);
+ Require(write.Success, write.ErrorMessage ?? "Array write denied");
+ var read = await adapter.ReadAsync(prefix + "Vector", ct);
+ Require(read.Success && read.Value?.Value is int[] a && a.SequenceEqual(new[] { 31, 32, 33 }), "Array value/type changed");
+ });
+ }
+ await Check("actual-adapter-durable-browse", async () =>
+ {
+ var page = await adapter.BrowseChildrenAsync("nsu=https://zb.com/otopcua/raw;s=Matrix/dev", cancellationToken: ct);
+ Require(page.Children.Count(x => x.DisplayName is "Scalar" or "Vector") == 2, "Missing durable raw variable children");
+ Require(page.Children.Any(x => x.DisplayName == "Alarm"), "Missing native alarm under its raw parent");
+ var projected = await adapter.BrowseChildrenAsync("nsu=https://zb.com/otopcua/uns;s=Eq", cancellationToken: ct);
+ Require(projected.Children.Any(x => x.DisplayName == "Vector"), "Missing UNS variable child");
+ });
+ await Check("actual-adapter-monitor-quality-time", async () =>
+ {
+ var values = new ConcurrentQueue();
+ var handle = await adapter.SubscribeAsync(raw + "Scalar", (_, value) => values.Enqueue(value), ct);
+ Require((await adapter.WriteAsync(raw + "Scalar", 713, ct)).Success, "Monitored write denied");
+ await Wait(() => values.Any(x => x.Value is int i && i == 713));
+ var actual = values.Last(x => x.Value is int i && i == 713);
+ Require(actual.Timestamp > new DateTimeOffset(2026, 10, 1, 0, 0, 0, TimeSpan.Zero), "Missing source timestamp");
+ Require(actual.Quality.ToString() == "Good", "Quality was not Good");
+ await adapter.UnsubscribeAsync(handle, ct);
+ });
+ await Check("actual-adapter-alarm-filter-refresh", async () =>
+ {
+ var alarms = new ConcurrentQueue();
+ var handle = await adapter.SubscribeAlarmsAsync("i=2253", null, alarms.Enqueue, ct);
+ await Wait(() => alarms.Any(x => x.Kind == AlarmTransitionKind.SnapshotComplete)
+ && alarms.Any(x => x.Message == "matrix-alarm" && x.SourceReference.Contains("Alarm", StringComparison.Ordinal)));
+ await adapter.UnsubscribeAlarmsAsync(handle, ct);
+ });
+ await Check("actual-adapter-disconnect-reconnect", async () =>
+ {
+ await adapter.DisconnectAsync(ct);
+ await adapter.ConnectAsync(OpcUaEndpointConfigSerializer.ToFlatDict(config), ct);
+ Require((await adapter.ReadAsync(raw + "Scalar", ct)).Success, "Consumer failed after reconnect");
+ await adapter.DisconnectAsync(ct);
+ });
+}
+var sdkAssembly = typeof(Opc.Ua.NodeId).Assembly;
+var result = new
+{
+ consumer = typeof(OpcUaDataConnection).Assembly.GetName().Version?.ToString(),
+ sdk = FileVersionInfo.GetVersionInfo(sdkAssembly.Location).ProductVersion,
+ process = Environment.ProcessId, mode = mode.ToString(), autoAccept = false,
+ endpoint, cases, failures,
+ notImplementedByConsumer = new[] { "HistoryRead/CP", "Part9 acknowledgement methods" },
+};
+File.WriteAllText(Path.Combine(root, "result.json"), JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
+Console.WriteLine($"Consumer probe: {cases.Count - failures} passed, {failures} failed; {Path.Combine(root, "result.json")}");
+return failures == 0 ? 0 : 1;
diff --git a/scripts/validation/scadabridge-sdk-compatibility/README.md b/scripts/validation/scadabridge-sdk-compatibility/README.md
new file mode 100644
index 00000000..3b3eb4c5
--- /dev/null
+++ b/scripts/validation/scadabridge-sdk-compatibility/README.md
@@ -0,0 +1,46 @@
+# Actual ScadaBridge consumer compatibility
+
+This isolated process references the real ScadaBridge `OpcUaDataConnection` and
+`RealOpcUaClient`, with their own dependency graph. It never loads the candidate
+SDK into that process. The verified consumer source is
+`0e89966da670e289bd875166295bb0e85d8cf8b2`, pinned to SDK 1.5.378.106.
+
+Build with an explicit clean consumer checkout:
+
+```sh
+dotnet build scripts/validation/scadabridge-sdk-compatibility/Probe.csproj \
+ -c Release -p:ScadaBridgeSourceRoot=/absolute/ScadaBridge
+```
+
+First build the separate application hosts with the parent
+`scripts/e2e/test-opcua-sdk-compatibility.ps1` harness. Then choose a new output
+directory and pass its original and candidate `ProcessHost.dll` paths:
+
+```sh
+python3 scripts/validation/scadabridge-sdk-compatibility/run.py \
+ --baseline-host /absolute/matrix/build-baseline/bin/Release/net10.0/ProcessHost.dll \
+ --candidate-host /absolute/matrix/build-candidate/bin/Release/net10.0/ProcessHost.dll \
+ --probe scripts/validation/scadabridge-sdk-compatibility/bin/Release/net10.0/Probe.dll \
+ --results /absolute/fresh-consumer-evidence
+```
+
+The helper runs four separate consumer/server pairs: each application version
+with None and SignAndEncrypt. None is enabled only in its own test fixture;
+the ordinary compatibility host remains strict SignAndEncrypt. The task key is
+generated per process pair, issuer/trusted/rejected stores are task-local, and
+server public certificates are explicitly copied. Autoaccept remains false.
+Never commit an entire output directory: it contains the fixture's private PKI.
+
+The consumed subset covers API-key activation, raw/UNS scalar and Int32[]
+roundtrips, durable hierarchical browse including the native alarm, data quality
+and timestamps, alarm filter/ConditionRefresh and disconnect/reconnect. History
+and Part 9 acknowledgment are not exposed by the inspected consumer interfaces;
+the parent Shared/CLI matrix covers those application services instead.
+
+ScadaBridge#121 is a reproduced preexisting limitation: SignAndEncrypt fails
+against **both** original and candidate servers because the consumer does not
+configure an application certificate. A matching baseline failure is evidence,
+not a secure-channel pass. The runner retains the nonzero result and each actual
+failure; it does not loosen trust or change expected results to return success.
+The current None subset and the missing secure identity must be reported
+separately. No ScadaBridge production source or user PKI is modified.
diff --git a/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/baseline-None.json b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/baseline-None.json
new file mode 100644
index 00000000..24e67677
--- /dev/null
+++ b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/baseline-None.json
@@ -0,0 +1,51 @@
+{
+ "consumer": "1.0.0.0",
+ "sdk": "1.5.378.106+99044fdbb638087d62f1e16580e9f73bfda364e2",
+ "process": 93934,
+ "mode": "None",
+ "autoAccept": false,
+ "endpoint": "opc.tcp://127.0.0.1:51314/OtOpcUa",
+ "cases": [
+ {
+ "name": "actual-adapter-connect-api-key",
+ "status": "passed"
+ },
+ {
+ "name": "scalar-round-trip-nsu=https://zb.com/otopcua/raw;s=Matrix/dev/",
+ "status": "passed"
+ },
+ {
+ "name": "array-round-trip-nsu=https://zb.com/otopcua/raw;s=Matrix/dev/",
+ "status": "passed"
+ },
+ {
+ "name": "scalar-round-trip-nsu=https://zb.com/otopcua/uns;s=Eq/",
+ "status": "passed"
+ },
+ {
+ "name": "array-round-trip-nsu=https://zb.com/otopcua/uns;s=Eq/",
+ "status": "passed"
+ },
+ {
+ "name": "actual-adapter-durable-browse",
+ "status": "passed"
+ },
+ {
+ "name": "actual-adapter-monitor-quality-time",
+ "status": "passed"
+ },
+ {
+ "name": "actual-adapter-alarm-filter-refresh",
+ "status": "passed"
+ },
+ {
+ "name": "actual-adapter-disconnect-reconnect",
+ "status": "passed"
+ }
+ ],
+ "failures": 0,
+ "notImplementedByConsumer": [
+ "HistoryRead/CP",
+ "Part9 acknowledgement methods"
+ ]
+}
diff --git a/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/baseline-SignAndEncrypt.json b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/baseline-SignAndEncrypt.json
new file mode 100644
index 00000000..36c5e57d
--- /dev/null
+++ b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/baseline-SignAndEncrypt.json
@@ -0,0 +1,20 @@
+{
+ "consumer": "1.0.0.0",
+ "sdk": "1.5.378.106+99044fdbb638087d62f1e16580e9f73bfda364e2",
+ "process": 93939,
+ "mode": "SignAndEncrypt",
+ "autoAccept": false,
+ "endpoint": "opc.tcp://127.0.0.1:51358/OtOpcUa",
+ "cases": [
+ {
+ "name": "actual-adapter-connect-api-key",
+ "status": "failed",
+ "error": "ApplicationCertificate for the security profile http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 cannot be found.\n[80890000] 'ApplicationCertificate for the security profile http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 cannot be found.'"
+ }
+ ],
+ "failures": 1,
+ "notImplementedByConsumer": [
+ "HistoryRead/CP",
+ "Part9 acknowledgement methods"
+ ]
+}
diff --git a/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/candidate-None.json b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/candidate-None.json
new file mode 100644
index 00000000..4634c6ab
--- /dev/null
+++ b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/candidate-None.json
@@ -0,0 +1,51 @@
+{
+ "consumer": "1.0.0.0",
+ "sdk": "1.5.378.106+99044fdbb638087d62f1e16580e9f73bfda364e2",
+ "process": 93941,
+ "mode": "None",
+ "autoAccept": false,
+ "endpoint": "opc.tcp://127.0.0.1:51387/OtOpcUa",
+ "cases": [
+ {
+ "name": "actual-adapter-connect-api-key",
+ "status": "passed"
+ },
+ {
+ "name": "scalar-round-trip-nsu=https://zb.com/otopcua/raw;s=Matrix/dev/",
+ "status": "passed"
+ },
+ {
+ "name": "array-round-trip-nsu=https://zb.com/otopcua/raw;s=Matrix/dev/",
+ "status": "passed"
+ },
+ {
+ "name": "scalar-round-trip-nsu=https://zb.com/otopcua/uns;s=Eq/",
+ "status": "passed"
+ },
+ {
+ "name": "array-round-trip-nsu=https://zb.com/otopcua/uns;s=Eq/",
+ "status": "passed"
+ },
+ {
+ "name": "actual-adapter-durable-browse",
+ "status": "passed"
+ },
+ {
+ "name": "actual-adapter-monitor-quality-time",
+ "status": "passed"
+ },
+ {
+ "name": "actual-adapter-alarm-filter-refresh",
+ "status": "passed"
+ },
+ {
+ "name": "actual-adapter-disconnect-reconnect",
+ "status": "passed"
+ }
+ ],
+ "failures": 0,
+ "notImplementedByConsumer": [
+ "HistoryRead/CP",
+ "Part9 acknowledgement methods"
+ ]
+}
diff --git a/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/candidate-SignAndEncrypt.json b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/candidate-SignAndEncrypt.json
new file mode 100644
index 00000000..738c13e2
--- /dev/null
+++ b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/candidate-SignAndEncrypt.json
@@ -0,0 +1,20 @@
+{
+ "consumer": "1.0.0.0",
+ "sdk": "1.5.378.106+99044fdbb638087d62f1e16580e9f73bfda364e2",
+ "process": 93943,
+ "mode": "SignAndEncrypt",
+ "autoAccept": false,
+ "endpoint": "opc.tcp://127.0.0.1:51455/OtOpcUa",
+ "cases": [
+ {
+ "name": "actual-adapter-connect-api-key",
+ "status": "failed",
+ "error": "ApplicationCertificate for the security profile http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 cannot be found.\n[80890000] 'ApplicationCertificate for the security profile http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 cannot be found.'"
+ }
+ ],
+ "failures": 1,
+ "notImplementedByConsumer": [
+ "HistoryRead/CP",
+ "Part9 acknowledgement methods"
+ ]
+}
diff --git a/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/receipt.json b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/receipt.json
new file mode 100644
index 00000000..bc2522da
--- /dev/null
+++ b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/receipt.json
@@ -0,0 +1,98 @@
+{
+ "artifacts": {
+ "baseline_host": {
+ "path": "/Users/dohertj2/.codex/worktrees/opcua2-cross-version/OtOpcUa/artifacts/cross-version/run-10/build-baseline/bin/Release/net10.0/ProcessHost.dll",
+ "sha256": "841f83aeb3a32d49c06518a98963743d720015bba41ae3138649126ce3a6af1c"
+ },
+ "candidate_host": {
+ "path": "/Users/dohertj2/.codex/worktrees/opcua2-cross-version/OtOpcUa/artifacts/cross-version/run-10/build-candidate/bin/Release/net10.0/ProcessHost.dll",
+ "sha256": "e025fc21ac3559c3796ec639f0ef7b9c3026ccbbdaa4783e14b8930eae3a2043"
+ },
+ "probe": {
+ "path": "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/scripts/validation/scadabridge-sdk-compatibility/bin/Release/net10.0/Probe.dll",
+ "sha256": "913ef3e35ffac8a54567e5d4df6f934572043249eaa6a4e0f829efd07b9012d7"
+ }
+ },
+ "legs": [
+ {
+ "server": "baseline",
+ "mode": "None",
+ "serverCommand": [
+ "dotnet",
+ "/Users/dohertj2/.codex/worktrees/opcua2-cross-version/OtOpcUa/artifacts/cross-version/run-10/build-baseline/bin/Release/net10.0/ProcessHost.dll",
+ "server",
+ "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/baseline-None/server"
+ ],
+ "command": [
+ "dotnet",
+ "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/scripts/validation/scadabridge-sdk-compatibility/bin/Release/net10.0/Probe.dll",
+ "opc.tcp://127.0.0.1:51314/OtOpcUa",
+ "None",
+ "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/baseline-None/consumer"
+ ],
+ "exit": 0,
+ "result": "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/baseline-None/consumer/result.json",
+ "serverExit": 0
+ },
+ {
+ "server": "baseline",
+ "mode": "SignAndEncrypt",
+ "serverCommand": [
+ "dotnet",
+ "/Users/dohertj2/.codex/worktrees/opcua2-cross-version/OtOpcUa/artifacts/cross-version/run-10/build-baseline/bin/Release/net10.0/ProcessHost.dll",
+ "server",
+ "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/baseline-SignAndEncrypt/server"
+ ],
+ "command": [
+ "dotnet",
+ "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/scripts/validation/scadabridge-sdk-compatibility/bin/Release/net10.0/Probe.dll",
+ "opc.tcp://127.0.0.1:51358/OtOpcUa",
+ "SignAndEncrypt",
+ "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/baseline-SignAndEncrypt/consumer"
+ ],
+ "exit": 1,
+ "result": "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/baseline-SignAndEncrypt/consumer/result.json",
+ "serverExit": 0
+ },
+ {
+ "server": "candidate",
+ "mode": "None",
+ "serverCommand": [
+ "dotnet",
+ "/Users/dohertj2/.codex/worktrees/opcua2-cross-version/OtOpcUa/artifacts/cross-version/run-10/build-candidate/bin/Release/net10.0/ProcessHost.dll",
+ "server",
+ "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/candidate-None/server"
+ ],
+ "command": [
+ "dotnet",
+ "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/scripts/validation/scadabridge-sdk-compatibility/bin/Release/net10.0/Probe.dll",
+ "opc.tcp://127.0.0.1:51387/OtOpcUa",
+ "None",
+ "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/candidate-None/consumer"
+ ],
+ "exit": 0,
+ "result": "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/candidate-None/consumer/result.json",
+ "serverExit": 0
+ },
+ {
+ "server": "candidate",
+ "mode": "SignAndEncrypt",
+ "serverCommand": [
+ "dotnet",
+ "/Users/dohertj2/.codex/worktrees/opcua2-cross-version/OtOpcUa/artifacts/cross-version/run-10/build-candidate/bin/Release/net10.0/ProcessHost.dll",
+ "server",
+ "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/candidate-SignAndEncrypt/server"
+ ],
+ "command": [
+ "dotnet",
+ "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/scripts/validation/scadabridge-sdk-compatibility/bin/Release/net10.0/Probe.dll",
+ "opc.tcp://127.0.0.1:51455/OtOpcUa",
+ "SignAndEncrypt",
+ "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/candidate-SignAndEncrypt/consumer"
+ ],
+ "exit": 1,
+ "result": "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/candidate-SignAndEncrypt/consumer/result.json",
+ "serverExit": 0
+ }
+ ]
+}
diff --git a/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/summary.json b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/summary.json
new file mode 100644
index 00000000..0c329bc8
--- /dev/null
+++ b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/summary.json
@@ -0,0 +1,11 @@
+{
+ "consumerSource": "0e89966da670e289bd875166295bb0e85d8cf8b2",
+ "consumerSdk": "1.5.378.106",
+ "baselineServerSdk": "1.5.378.182",
+ "candidateServerSdk": "2.0.1",
+ "noneCasesPassed": 18,
+ "secureConnectionFailures": 2,
+ "samePreexistingFailure": "BadConfigurationError: ApplicationCertificate not configured",
+ "issue": "https://gitea.dohertylan.com/dohertj2/ScadaBridge/issues/121",
+ "releaseApproval": false
+}
diff --git a/scripts/validation/scadabridge-sdk-compatibility/run.py b/scripts/validation/scadabridge-sdk-compatibility/run.py
new file mode 100644
index 00000000..86ac0c0b
--- /dev/null
+++ b/scripts/validation/scadabridge-sdk-compatibility/run.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+"""Run the real ScadaBridge consumer in separate processes against both application versions."""
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import secrets
+import subprocess
+import time
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--baseline-host", type=Path, required=True)
+ parser.add_argument("--candidate-host", type=Path, required=True)
+ parser.add_argument("--probe", type=Path, required=True)
+ parser.add_argument("--results", type=Path, required=True)
+ args = parser.parse_args()
+ root = args.results.resolve()
+ root.mkdir(parents=True, exist_ok=False)
+ artifacts = {name: path.resolve() for name, path in vars(args).items() if name != "results"}
+ for path in artifacts.values():
+ if not path.is_file():
+ raise FileNotFoundError(path)
+ receipt = {
+ "artifacts": {name: {"path": str(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
+ for name, path in artifacts.items()},
+ "legs": [],
+ }
+ for version in ("baseline", "candidate"):
+ for mode in ("None", "SignAndEncrypt"):
+ leg = root / f"{version}-{mode}"
+ leg.mkdir()
+ server_root = leg / "server"
+ env = os.environ.copy()
+ env["OPCUA_MATRIX_PASSWORD"] = "otua_matrix_" + secrets.token_urlsafe(32)
+ if mode == "None":
+ env["OPCUA_MATRIX_SERVER_SECURITY"] = "none"
+ else:
+ env.pop("OPCUA_MATRIX_SERVER_SECURITY", None)
+ server_command = ["dotnet", str(artifacts[f"{version}_host"]), "server", str(server_root)]
+ result = {"server": version, "mode": mode, "serverCommand": server_command}
+ with (leg / "server.log").open("w") as server_log:
+ server = subprocess.Popen(server_command, env=env, stdout=server_log, stderr=subprocess.STDOUT)
+ try:
+ until = time.monotonic() + 40
+ while not (server_root / "endpoint.txt").is_file():
+ if server.poll() is not None or time.monotonic() >= until:
+ raise RuntimeError(f"Server failed to become ready; inspect {leg / 'server.log'}")
+ time.sleep(0.1)
+ endpoint = (server_root / "endpoint.txt").read_text().strip()
+ env["SCADABRIDGE_PROBE_SERVER_CERTS"] = str(server_root / "server-pki" / "own" / "certs")
+ command = ["dotnet", str(artifacts["probe"]), endpoint, mode, str(leg / "consumer")]
+ with (leg / "consumer.log").open("w") as log:
+ probe = subprocess.run(command, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=150)
+ result.update(command=command, exit=probe.returncode,
+ result=str(leg / "consumer" / "result.json"))
+ finally:
+ server_root.mkdir(exist_ok=True)
+ (server_root / "stop").touch()
+ try:
+ server.wait(timeout=15)
+ except subprocess.TimeoutExpired:
+ server.terminate()
+ server.wait(timeout=10)
+ result["serverExit"] = server.returncode
+ receipt["legs"].append(result)
+ (root / "receipt.json").write_text(json.dumps(receipt, indent=2))
+ # A pre-existing consumer limitation is evidence to investigate, never converted into a passing leg.
+ return int(any(leg["exit"] != 0 or leg["serverExit"] != 0 for leg in receipt["legs"]))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())