diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8d93af08e..19f5fa241 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -75,19 +75,21 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -124,13 +126,13 @@
provider that breaks the "managed only" rule, which is why it is opt-in per instance. -->
-
+
-
-
-
+
+
+
+ net10.0enableenabletruefalse
+
diff --git a/scripts/validation/opcua-aggregate-baseline/Directory.Packages.props b/scripts/validation/opcua-aggregate-baseline/Directory.Packages.props
new file mode 100644
index 000000000..802827efc
--- /dev/null
+++ b/scripts/validation/opcua-aggregate-baseline/Directory.Packages.props
@@ -0,0 +1 @@
+false
diff --git a/scripts/validation/opcua-aggregate-baseline/Program.cs b/scripts/validation/opcua-aggregate-baseline/Program.cs
new file mode 100644
index 000000000..b7d53347f
--- /dev/null
+++ b/scripts/validation/opcua-aggregate-baseline/Program.cs
@@ -0,0 +1,242 @@
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Opc.Ua;
+using Opc.Ua.Server;
+
+if (args.Length != 1) throw new ArgumentException("Pass a fresh output JSON path.");
+if (File.Exists(args[0])) throw new IOException("Refusing to overwrite existing baseline evidence.");
+var epoch = new DateTime(2026, 10, 1, 8, 0, 0, DateTimeKind.Utc).AddTicks(1234567);
+var telemetry = DefaultTelemetry.Create(_ => { });
+var offsets = new double[] { -10, 0, 5, 15, 25, 35, 50, 80 };
+var data = new Dictionary
+{
+ ["double"] = offsets.Select((s, i) => new Sample(s, "Double", (10 + i * 7 % 13).ToString(CultureInfo.InvariantCulture), StatusCodes.Good)).ToArray(),
+ ["uncertain"] = offsets.Select((s, i) => new Sample(s, i == 4 ? "Null" : "Double", i == 4 ? null : i == 2 ? "100" : (10 + i * 7 % 13).ToString(CultureInfo.InvariantCulture), i == 2 ? StatusCodes.UncertainDataSubNormal : i == 4 ? StatusCodes.BadNoCommunication : StatusCodes.Good)).ToArray(),
+ ["integer"] = offsets.Select((s, i) => new Sample(s, "Int64", (i * 7 - 10).ToString(CultureInfo.InvariantCulture), i == 3 ? StatusCodes.UncertainDataSubNormal : StatusCodes.Good)).ToArray(),
+ ["boolean"] = offsets.Select((s, i) => new Sample(s, "Boolean", (i % 2 == 0).ToString(), i == 2 ? StatusCodes.UncertainDataSubNormal : StatusCodes.Good)).ToArray(),
+ ["sparse"] = [new(-10, "Double", "10", StatusCodes.Good), new(7, "Double", "20", StatusCodes.Good), new(18, "Null", null, StatusCodes.BadNoCommunication), new(45, "Double", "30", StatusCodes.Good)],
+ ["bad"] = offsets.Select(s => new Sample(s, "Null", null, StatusCodes.BadNoCommunication)).ToArray(),
+ ["empty"] = [],
+};
+var names = new string[]
+{
+ "AggregateFunction_Interpolative",
+ "AggregateFunction_Average",
+ "AggregateFunction_TimeAverage",
+ "AggregateFunction_TimeAverage2",
+ "AggregateFunction_Total",
+ "AggregateFunction_Total2",
+ "AggregateFunction_Minimum",
+ "AggregateFunction_Maximum",
+ "AggregateFunction_MinimumActualTime",
+ "AggregateFunction_MaximumActualTime",
+ "AggregateFunction_Range",
+ "AggregateFunction_Minimum2",
+ "AggregateFunction_Maximum2",
+ "AggregateFunction_MinimumActualTime2",
+ "AggregateFunction_MaximumActualTime2",
+ "AggregateFunction_Range2",
+ "AggregateFunction_Count",
+ "AggregateFunction_AnnotationCount",
+ "AggregateFunction_DurationInStateZero",
+ "AggregateFunction_DurationInStateNonZero",
+ "AggregateFunction_NumberOfTransitions",
+ "AggregateFunction_Start",
+ "AggregateFunction_End",
+ "AggregateFunction_Delta",
+ "AggregateFunction_StartBound",
+ "AggregateFunction_EndBound",
+ "AggregateFunction_DeltaBounds",
+ "AggregateFunction_DurationGood",
+ "AggregateFunction_DurationBad",
+ "AggregateFunction_PercentGood",
+ "AggregateFunction_PercentBad",
+ "AggregateFunction_WorstQuality",
+ "AggregateFunction_WorstQuality2",
+ "AggregateFunction_StandardDeviationPopulation",
+ "AggregateFunction_VariancePopulation",
+ "AggregateFunction_StandardDeviationSample",
+ "AggregateFunction_VarianceSample",
+};
+var vectors = new List();
+// Preserve the original seven-dataset grid, input ordering and expected values exactly.
+foreach (var name in names)
+foreach (var dataset in data)
+foreach (var reverse in new[] { false, true })
+foreach (var stepped in new[] { false, true })
+foreach (var uncertainAsBad in new[] { false, true })
+foreach (var incremental in new[] { false, true })
+ Run(name, dataset.Key, reverse, stepped, uncertainAsBad, incremental);
+
+var edges = new Dictionary
+{
+ ["edge-sbyte"] = Typed("SByte", ["-128", "-127", "-1", "0", "1", "126", "127", "2"]),
+ ["edge-byte"] = Typed("Byte", ["0", "1", "127", "128", "254", "255", "3", "2"]),
+ ["edge-int16"] = Typed("Int16", ["-32768", "-32767", "-1", "0", "1", "32766", "32767", "2"]),
+ ["edge-uint16"] = Typed("UInt16", ["0", "1", "32767", "32768", "65534", "65535", "3", "2"]),
+ ["edge-int32"] = Typed("Int32", ["-2147483648", "-2147483647", "-1", "0", "1", "2147483646", "2147483647", "2"]),
+ ["edge-uint32"] = Typed("UInt32", ["0", "1", "2147483647", "2147483648", "4294967294", "4294967295", "3", "2"]),
+ ["edge-int64"] = Typed("Int64", ["-9223372036854775808", "-9007199254740993", "-1", "0", "1", "9007199254740993", "9223372036854775806", "9223372036854775807"]),
+ ["edge-uint64"] = Typed("UInt64", ["0", "1", "9007199254740991", "9007199254740993", "9223372036854775807", "9223372036854775808", "18446744073709551614", "18446744073709551615"]),
+ ["edge-float"] = Typed("Single", ["-3.4028235E+38", "1E-45", "-0", "1.5", "3.4028235E+38", "NaN", "Infinity", "-Infinity"]),
+ ["edge-double"] = Typed("Double", ["-1.5", "-0", "0.5", "1.5", "NaN", "Infinity", "-Infinity", "1.7976931348623157E+308"]),
+ // Interpolative/Delta casts back to the earlier sample's type: fractional, negative,
+ // overflowing and nonfinite Double values must exercise the original SDK conversion.
+ ["edge-mixed-conversion"] =
+ [
+ new(-10, "Byte", "0", StatusCodes.Good), new(0, "Byte", "1", StatusCodes.Good),
+ new(5, "Double", "2.5", StatusCodes.Good), new(15, "SByte", "-128", StatusCodes.Good),
+ new(25, "Double", "255.5", StatusCodes.Good), new(35, "Single", "3.4028235E+38", StatusCodes.Good),
+ new(50, "Double", "Infinity", StatusCodes.Good), new(80, "UInt64", "18446744073709551615", StatusCodes.Good),
+ ],
+};
+foreach (var dataset in edges) data.Add(dataset.Key, dataset.Value);
+foreach (var name in names)
+foreach (var dataset in edges)
+foreach (var reverse in new[] { false, true })
+foreach (var incremental in new[] { false, true })
+ Run(name, dataset.Key, reverse, false, false, incremental, Settings(false));
+
+// Ties include an identical source timestamp+picoseconds pair; input order is stable.
+// Distinct server ticks and picoseconds must survive raw-result selection independently.
+data.Add("edge-duplicate-picoseconds",
+[
+ Pico(-10, "1", 3, 19, 7), Pico(0, "2", 1, 23, 11), Pico(0, "3", 9999, 29, 13),
+ Pico(5, "4", 7, 31, 17), Pico(5, "5", 7, 37, 19), Pico(20, "6", 2, 41, 23),
+ Pico(20, "7", 9000, 43, 29), Pico(40, "8", 5, 47, 31), Pico(80, "9", 11, 53, 37),
+]);
+foreach (var name in names)
+foreach (var reverse in new[] { false, true })
+foreach (var incremental in new[] { false, true })
+ Run(name, "edge-duplicate-picoseconds", reverse, false, false, incremental, Settings(false));
+
+var configurations = new[]
+{
+ new AggregateSettings(false, 0, 0, false, false),
+ new AggregateSettings(false, 1, 99, true, false),
+ new AggregateSettings(true, 50, 50, false, false),
+ new AggregateSettings(true, 100, 0, true, false),
+};
+var configuredNames = new[]
+{
+ "AggregateFunction_Interpolative", "AggregateFunction_TimeAverage", "AggregateFunction_TimeAverage2",
+ "AggregateFunction_Total", "AggregateFunction_Total2", "AggregateFunction_PercentGood", "AggregateFunction_PercentBad",
+};
+foreach (var name in configuredNames)
+foreach (var dataset in new[] { "uncertain", "sparse" })
+foreach (var config in configurations)
+foreach (var reverse in new[] { false, true })
+foreach (var incremental in new[] { false, true })
+ Run(name, dataset, reverse, false, config.TreatUncertainAsBad, incremental, config);
+
+var assembly = typeof(Aggregators).Assembly.Location;
+var assemblySha256 = Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(assembly)));
+Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(args[0]))!);
+// One vector per line keeps golden changes reviewable without pretty-printing every value.
+using (var writer = new StreamWriter(new FileStream(args[0], FileMode.CreateNew, FileAccess.Write)))
+{
+ writer.WriteLine("{");
+ writer.WriteLine($" \"sdk\":\"1.5.378.182\",\"assemblySha256\":\"{assemblySha256}\",");
+ writer.WriteLine($" \"epochTicks\":{epoch.Ticks},\"intervalMilliseconds\":20000,");
+ writer.WriteLine($" \"data\":{JsonSerializer.Serialize(data)},");
+ writer.WriteLine(" \"vectors\":[");
+ for (var i = 0; i < vectors.Count; i++)
+ writer.WriteLine(" " + JsonSerializer.Serialize(vectors[i]) + (i + 1 < vectors.Count ? "," : ""));
+ writer.WriteLine(" ]");
+ writer.WriteLine("}");
+}
+Console.WriteLine($"Generated {vectors.Count} vectors ({vectors.Count - 4144} focused additions) for {names.Length} aggregate IDs; baseline assembly SHA256 {assemblySha256}.");
+
+Sample[] Typed(string type, string[] values) => offsets.Zip(values, (s, value) => new Sample(s, type, value, StatusCodes.Good)).ToArray();
+static Sample Pico(double seconds, string value, ushort source, ushort server, long serverOffset) =>
+ new(seconds, "Int32", value, StatusCodes.Good)
+ { SourcePicoseconds = source, ServerPicoseconds = server, ServerOffsetTicks = serverOffset };
+static AggregateSettings Settings(bool uncertainAsBad) => new(uncertainAsBad, 100, 100, false, false);
+
+void Run(string name, string dataset, bool reverse, bool stepped, bool uncertainAsBad, bool incremental,
+ AggregateSettings? settings = null)
+{
+ var id = (NodeId)typeof(ObjectIds).GetField(name)!.GetValue(null)!;
+ var start = epoch.AddSeconds(reverse ? 70 : 0);
+ var end = epoch.AddSeconds(reverse ? 0 : 70);
+ var cfg = settings ?? Settings(uncertainAsBad);
+ var config = new AggregateConfiguration
+ {
+ TreatUncertainAsBad = cfg.TreatUncertainAsBad, PercentDataBad = cfg.PercentDataBad,
+ PercentDataGood = cfg.PercentDataGood, UseSlopedExtrapolation = cfg.UseSlopedExtrapolation,
+ UseServerCapabilitiesDefaults = cfg.UseServerCapabilitiesDefaults,
+ };
+ var calculator = Aggregators.CreateStandardCalculator(id, start, end, 20_000, stepped, config, telemetry);
+ var output = new List();
+ var accepted = new List();
+ string? error = null;
+ try
+ {
+ var samples = data[dataset].OrderBy(x => x.Seconds);
+ foreach (var sample in reverse ? samples.Reverse() : samples)
+ {
+ var time = epoch.AddSeconds(sample.Seconds);
+ var value = new DataValue(new Variant(Payload(sample)!), sample.Status, time,
+ time.AddTicks(sample.ServerOffsetTicks ?? 0))
+ {
+ SourcePicoseconds = sample.SourcePicoseconds ?? 0,
+ ServerPicoseconds = sample.ServerPicoseconds ?? 0,
+ };
+ accepted.Add(calculator.QueueRawValue(value));
+ if (incremental) Drain(false);
+ }
+ Drain(true);
+ }
+ catch (Exception ex) { error = ex.GetType().FullName; }
+ vectors.Add(new(name, dataset, reverse, stepped, uncertainAsBad, incremental, output.ToArray(), error)
+ { Configuration = settings, QueueAccepted = settings is null ? null : accepted.ToArray() });
+ void Drain(bool partial)
+ {
+ for (var guard = 0; guard < 100; guard++)
+ {
+ var value = calculator.GetProcessedValue(partial);
+ if (value is null) return;
+ output.Add(new(partial, value.Value?.GetType().Name, Format(value.Value), value.StatusCode.Code, value.SourceTimestamp.Ticks, value.ServerTimestamp.Ticks, value.SourcePicoseconds, value.ServerPicoseconds));
+ }
+ throw new InvalidOperationException("Baseline calculator did not terminate.");
+ }
+}
+
+static object? Payload(Sample sample) => sample.Type switch
+{
+ "SByte" => sbyte.Parse(sample.Payload!, CultureInfo.InvariantCulture),
+ "Byte" => byte.Parse(sample.Payload!, CultureInfo.InvariantCulture),
+ "Int16" => short.Parse(sample.Payload!, CultureInfo.InvariantCulture),
+ "UInt16" => ushort.Parse(sample.Payload!, CultureInfo.InvariantCulture),
+ "Int32" => int.Parse(sample.Payload!, CultureInfo.InvariantCulture),
+ "UInt32" => uint.Parse(sample.Payload!, CultureInfo.InvariantCulture),
+ "Int64" => long.Parse(sample.Payload!, CultureInfo.InvariantCulture),
+ "UInt64" => ulong.Parse(sample.Payload!, CultureInfo.InvariantCulture),
+ "Single" => float.Parse(sample.Payload!, CultureInfo.InvariantCulture),
+ "Double" => double.Parse(sample.Payload!, CultureInfo.InvariantCulture),
+ "Boolean" => bool.Parse(sample.Payload!),
+ _ => null,
+};
+static string? Format(object? value) => value switch
+{
+ null => null,
+ double number => number.ToString("R", CultureInfo.InvariantCulture),
+ float number => number.ToString("R", CultureInfo.InvariantCulture),
+ IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
+ _ => value.ToString(),
+};
+record Sample(double Seconds, string Type, string? Payload, uint Status)
+{
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ushort? SourcePicoseconds { get; init; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ushort? ServerPicoseconds { get; init; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public long? ServerOffsetTicks { get; init; }
+}
+record Value(bool PartialRequested, string? Type, string? Payload, uint Status, long SourceTicks, long ServerTicks, ushort SourcePicoseconds, ushort ServerPicoseconds);
+record AggregateSettings(bool TreatUncertainAsBad, byte PercentDataBad, byte PercentDataGood, bool UseSlopedExtrapolation, bool UseServerCapabilitiesDefaults);
+record Vector(string Aggregate, string DataSet, bool Reverse, bool Stepped, bool UncertainAsBad, bool Incremental, Value[] Output, string? Exception)
+{
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public AggregateSettings? Configuration { get; init; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool[]? QueueAccepted { get; init; }
+}
diff --git a/scripts/validation/opcua-aggregate-baseline/README.md b/scripts/validation/opcua-aggregate-baseline/README.md
new file mode 100644
index 000000000..1eb8c8675
--- /dev/null
+++ b/scripts/validation/opcua-aggregate-baseline/README.md
@@ -0,0 +1,56 @@
+# Original SDK aggregate oracle
+
+This standalone .NET 10 process generates aggregate expectations from the published
+`OPCFoundation.NetStandard.Opc.Ua.Server` **1.5.378.182** package. Its local build and
+package properties isolate it from the application's SDK 2 graph. It is outside the
+solution; never add its baseline SDK assemblies to a candidate test or production process.
+Expectations must come from this original SDK, never from the compatibility calculators.
+
+Run from the repository root with a fresh output path:
+
+```bash
+dotnet run --project scripts/validation/opcua-aggregate-baseline/Baseline.csproj \
+ -c Release -- /tmp/opcua-original-aggregates-new.json
+```
+
+An existing output path is refused. The JSON records the original aggregate assembly's
+SHA-256, epoch and processing interval. The expected assembly hash is
+`d8d9706dea577858325730cd89896826ed82f7bfcf9503ccd0260311663a69f4`.
+Output uses compact metadata and data, followed by one vector per line for reviewable diffs.
+
+The first **4,144 vectors** retain the original seven datasets and complete grid over 37
+aggregate IDs, forward/reverse reads, stepped/sloped interpolation, uncertain-as-bad and
+incremental/final processing. Their parsed objects and the seven original input datasets
+are unchanged. The request window is epoch +0 through +70 seconds, reversed for reverse
+vectors, with a 20,000 ms processing interval.
+
+The next **2,000 focused vectors** avoid expanding every dataset into the complete grid:
+
+| Cases | Coverage |
+|---:|---|
+| 1,628 | Eleven numeric datasets across all 37 IDs, both directions and both processing modes: all eight integer widths, Single, Double and mixed-type conversion. Includes narrow limits, Int64/UInt64 values around 2^53 and 2^63 and their full ranges, fractional interpolation, signed zero, nonfinite values and overflow. |
+| 148 | Duplicate timestamp ties across all 37 IDs, both directions and processing modes; nonzero source/server picoseconds, independent server timestamp offsets and an exact source timestamp/picosecond tie. |
+| 224 | Interpolative, TimeAverage/2, Total/2 and PercentGood/Bad over uncertain and sparse data, both directions and processing modes. Four explicit profiles cover PercentDataBad/Good 0/0, 1/99, 50/50 and 100/0, both uncertainty policies and sloped extrapolation. |
+
+New vectors use sloped interpolation (`Stepped=false`); their `Configuration` object records
+all five effective aggregate settings. `UseServerCapabilitiesDefaults=false` records the
+resolved configuration passed directly to the calculator. Existing vectors retain their
+original fields: omitted configuration means the vector's uncertainty policy, 100/100
+thresholds, and both extrapolation and server-capability defaults disabled.
+
+New samples may carry `SourcePicoseconds`, `ServerPicoseconds` and `ServerOffsetTicks`;
+absent fields mean zero. The server timestamp is epoch + `Seconds` + `ServerOffsetTicks`.
+Samples are sorted stably by `Seconds`, then the whole sequence is reversed for a reverse
+read, preserving a fully specified tie order. `QueueAccepted` records each attempted
+queue result through the point of any exception.
+
+Each output retains its CLR type, round-trip value spelling, complete numeric status,
+source/server timestamp ticks, source/server picoseconds, and whether it came from an
+incremental (`PartialRequested=false`) or final (`true`) drain. `Exception` retains the
+exact original exception type and any outputs already emitted. NaN, infinities and signed
+zero are stored as strings so JSON does not change their numeric representation.
+
+When importing fresh evidence into the candidate fixture, first compare the original
+4,144 parsed vectors, seven datasets and metadata with the previous oracle. Investigate
+candidate mismatches; do not regenerate expectations from candidate output or adjust
+original exception types to make the comparison pass.
diff --git a/scripts/validation/opcua-client-lifecycle/.gitignore b/scripts/validation/opcua-client-lifecycle/.gitignore
new file mode 100644
index 000000000..c18dd8d83
--- /dev/null
+++ b/scripts/validation/opcua-client-lifecycle/.gitignore
@@ -0,0 +1 @@
+__pycache__/
diff --git a/scripts/validation/opcua-client-lifecycle/Lifecycle.cs b/scripts/validation/opcua-client-lifecycle/Lifecycle.cs
new file mode 100644
index 000000000..b182006b7
--- /dev/null
+++ b/scripts/validation/opcua-client-lifecycle/Lifecycle.cs
@@ -0,0 +1,249 @@
+#pragma warning disable OTOPCUA0001
+using System.Collections;
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text.Json;
+using Opc.Ua;
+using Opc.Ua.Configuration;
+using ZB.MOM.WW.OtOpcUa.Client.Shared;
+using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
+using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer;
+using WriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
+using SharedSecurityMode = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.SecurityMode;
+
+internal static partial class ProcessHost
+{
+ static object ServerCounts(OtOpcUaSdkServer server)
+ {
+ var sessions = server.CurrentInstance.SessionManager.GetSessions();
+ var subs = server.CurrentInstance.SubscriptionManager.GetSubscriptions();
+ return new { utc = DateTime.UtcNow, sessions = sessions.Count, subscriptions = subs.Count,
+ items = subs.Sum(s => s.MonitoredItemCount), serverProcess = Environment.ProcessId };
+ }
+}
+
+internal static class Lifecycle
+{
+ static string Root = "";
+ static string Password => Environment.GetEnvironmentVariable("OPCUA_MATRIX_PASSWORD") ?? throw new InvalidOperationException("Missing fixture credential");
+ static readonly JsonSerializerOptions Json = new() { WriteIndented = true };
+ static int managerChecks, sharedPasses, driverPasses, browserPasses;
+ static readonly List Weak = [];
+ static void Require(bool pass, string message) { if (!pass) throw new InvalidOperationException(message); }
+ static object? FieldOrNull(object owner, string name)
+ {
+ var field = owner.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)
+ ?? throw new InvalidOperationException("Diagnostic layout changed: " + owner.GetType().FullName + "." + name);
+ return field.GetValue(owner);
+ }
+ static object Field(object owner, string name) => FieldOrNull(owner, name) ?? throw new InvalidOperationException("Diagnostic owner null: " + name);
+ static void Empty(object owner, string name) => Require(Field(owner, name) is ICollection items && items.Count == 0, "Retained collection: " + name);
+ static bool Disposed(object manager)
+ {
+ Require(manager.GetType().FullName == "Opc.Ua.CertificateManager", "Unexpected SDK certificate manager type");
+ Require(manager.GetType().Assembly.GetName().Version?.ToString() == "2.0.1.0", "Diagnostic requires SDK 2.0.1.0");
+ var field = manager.GetType().GetField("m_disposed", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly);
+ Require(field?.FieldType == typeof(bool), "SDK 2.0.1 CertificateManager.m_disposed bool layout changed");
+ return (bool)field!.GetValue(manager)!;
+ }
+ static object SharedManager(OpcUaClientService shared) => Field(Field(shared, "_session"), "_certificateManager");
+ static object AppManager(object owner) => ((ApplicationInstance)Field(owner, "_ownedApplication")).ApplicationConfiguration!.CertificateManager;
+ static void CheckManager(object manager, bool disposed)
+ {
+ Require(Disposed(manager) == disposed, "Certificate manager disposal observation mismatch");
+ if (disposed) managerChecks++;
+ }
+ static ConnectionSettings Settings(string endpoint, string? password = null) => new()
+ {
+ EndpointUrl = endpoint, Username = "apikey", Password = password ?? Password,
+ SecurityMode = SharedSecurityMode.SignAndEncrypt, AutoAcceptCertificates = false,
+ CertificateStorePath = Path.Combine(Root, "data", "shared-pki"), SessionTimeoutSeconds = 30
+ };
+ static OpcUaClientDriverOptions Options(string endpoint) => new()
+ {
+ EndpointUrl = endpoint, SecurityPolicy = OpcUaSecurityPolicy.Basic256Sha256,
+ SecurityMode = OpcUaSecurityMode.SignAndEncrypt, AuthType = OpcUaAuthType.Username,
+ Username = "apikey", Password = Password, AutoAcceptCertificates = false,
+ SessionName = "ClientLifecycle", ApplicationUri = "urn:localhost:ClientLifecycle",
+ RawTags = [new("scalar", "{\"nodeId\":\"nsu=https://zb.com/otopcua/raw;s=Matrix/dev/Scalar\"}", true),
+ new("vector", "{\"nodeId\":\"nsu=https://zb.com/otopcua/raw;s=Matrix/dev/Vector\"}", true)]
+ };
+ static async Task Wait(Func condition, CancellationToken ct)
+ {
+ while (!condition()) await Task.Delay(25, ct);
+ }
+ static object? Clr(DataValue value) => ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(value.WrappedValue);
+ static async Task Prepare()
+ {
+ // Public connection failures create and release the same production applications as a live attempt.
+ // No production private method is invoked and no SDK state is written by diagnostics.
+ using var ct = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ using (var shared = new OpcUaClientService())
+ { try { await shared.ConnectAsync(Settings("opc.tcp://127.0.0.1:1"), ct.Token); } catch { } }
+ await using (var driver = new OpcUaClientDriver(Options("opc.tcp://127.0.0.1:1"), "lifecycle"))
+ { try { await driver.InitializeAsync("{}", ct.Token); } catch { } }
+ var browser = new OpcUaClientDriverBrowser();
+ try { await using var session = await browser.OpenAsync(JsonSerializer.Serialize(Options("opc.tcp://127.0.0.1:1")), ct.Token); } catch { }
+ foreach (var folder in new[] { "data/shared-pki", "data/OtOpcUa/pki", "data/OtOpcUa/adminui-browse-pki" })
+ Require(Directory.GetFiles(Path.Combine(Root, folder, "own", "certs")).Length == 1, "Expected one reusable application DER certificate: " + folder);
+ }
+ static async Task Cycle(string endpoint, int cycle)
+ {
+ using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ var ct = budget.Token;
+ using var shared = new OpcUaClientService(); Weak.Add(new(shared));
+ await using var driver = new OpcUaClientDriver(Options(endpoint), "lifecycle"); Weak.Add(new(driver));
+ var browser = new OpcUaClientDriverBrowser(); Weak.Add(new(browser));
+ // Two connections on the same Shared service and two initializations on the same driver.
+ for (var pass = 0; pass < 2; pass++)
+ {
+ var seen = new ConcurrentQueue();
+ EventHandler handler = (_, e) => { if (Clr(e.Value) is int n) seen.Enqueue(n); };
+ shared.DataChanged += handler;
+ var info = await shared.ConnectAsync(Settings(endpoint), ct);
+ Require(info.SecurityMode == "SignAndEncrypt" && info.SecurityPolicyUri == SecurityPolicies.Basic256Sha256, "Shared policy downgrade");
+ object? manager = SharedManager(shared); CheckManager(manager, false);
+ var nss = (string[])Clr(await shared.ReadValueAsync(VariableIds.Server_NamespaceArray, ct))!;
+ var ns = (ushort)Array.IndexOf(nss, "https://zb.com/otopcua/raw"); Require(ns > 0, "Raw namespace missing");
+ var scalar = new NodeId("Matrix/dev/Scalar", ns); var vector = new NodeId("Matrix/dev/Vector", ns);
+ await shared.SubscribeAsync(scalar, 100, ct); await shared.SubscribeAsync(vector, 100, ct);
+ var target = cycle * 10 + pass;
+ Require(StatusCode.IsGood(await shared.WriteValueAsync(scalar, target, ct)), "Shared scalar write failed");
+ Require(StatusCode.IsGood(await shared.WriteValueAsync(vector, new[] { target, 2, 3 }, ct)), "Shared vector write failed");
+ await Wait(() => seen.Contains(target), ct);
+ Require(Equals(Clr(await shared.ReadValueAsync(scalar, ct)), target), "Shared scalar roundtrip mismatch");
+ Require(((int[])Clr(await shared.ReadValueAsync(vector, ct))!).SequenceEqual(new[] { target, 2, 3 }), "Shared vector mismatch");
+ Require((await shared.BrowseAsync(new NodeId("Matrix/dev", ns), ct)).Count > 0, "Shared browse empty");
+ await shared.UnsubscribeAsync(scalar, ct); await shared.UnsubscribeAsync(vector, ct);
+ await shared.DisconnectAsync(ct);
+ Require(!shared.IsConnected && shared.CurrentConnectionInfo is null, "Shared public connection state retained");
+ Require(FieldOrNull(shared, "_session") is null, "Shared session retained"); Empty(shared, "_activeDataSubscriptions");
+ CheckManager(manager, true); manager = null; shared.DataChanged -= handler; sharedPasses++;
+
+ if (pass == 0) await driver.InitializeAsync("{}", ct);
+ else await driver.ReinitializeAsync("{}", ct);
+ Require(driver.GetHealth().State == DriverState.Healthy, "Driver initialize health");
+ manager = AppManager(driver); CheckManager(manager, false);
+ var values = new ConcurrentQueue();
+ EventHandler driverHandler = (_, e) => { if (e.Snapshot.Value is int n) values.Enqueue(n); };
+ driver.OnDataChange += driverHandler;
+ var sub = await driver.SubscribeAsync(["scalar", "vector"], TimeSpan.FromMilliseconds(100), ct);
+ target++;
+ var writes = await driver.WriteAsync([new WriteRequest("scalar", target), new WriteRequest("vector", new[] { target, 4, 5 })], ct);
+ Require(writes.Count == 2 && writes.All(w => w.StatusCode == 0), "Driver write failed");
+ await Wait(() => values.Contains(target), ct);
+ var reads = await driver.ReadAsync(["scalar", "vector"], ct);
+ Require(reads.Count == 2 && reads.All(r => r.StatusCode == 0) && Equals(reads[0].Value, target)
+ && ((int[])reads[1].Value!).SequenceEqual(new[] { target, 4, 5 }), "Driver roundtrip mismatch");
+ await driver.UnsubscribeAsync(sub, ct); sub = null!;
+ // Reinitialize a live connection with an active replacement subscription, as the watchdog does.
+ sub = await driver.SubscribeAsync(["scalar", "vector"], TimeSpan.FromMilliseconds(100), ct);
+ await driver.ReinitializeAsync("{}", ct); sub = null!;
+ CheckManager(manager, true); manager = null;
+ manager = AppManager(driver); CheckManager(manager, false);
+ Require(driver.GetHealth().State == DriverState.Healthy, "Live reinitialize failed");
+ var reinitialized = await driver.ReadAsync(["scalar"], ct);
+ Require(reinitialized.Count == 1 && reinitialized[0].StatusCode == 0 && Equals(reinitialized[0].Value, target), "Live reinitialize read failed");
+ await driver.ShutdownAsync(ct); driver.OnDataChange -= driverHandler;
+ Require(driver.GetHealth().State == DriverState.Unknown, "Driver shutdown public state retained");
+ Require(FieldOrNull(driver, "k__BackingField") is null, "Driver session retained");
+ Empty(driver, "_subscriptions"); Empty(driver, "_alarmSubscriptions");
+ CheckManager(manager, true); manager = null; driverPasses++;
+
+ var browse = await browser.OpenAsync(JsonSerializer.Serialize(Options(endpoint)), ct); Weak.Add(new(browse));
+ manager = AppManager(browse); CheckManager(manager, false);
+ Require((await browse.RootAsync(ct)).Count > 0, "Browser root empty");
+ await browse.DisposeAsync().AsTask().WaitAsync(ct); CheckManager(manager, true); manager = null; browserPasses++;
+ }
+ }
+ static object Resources(int cycle, double elapsed)
+ {
+ GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
+ var survivors = Weak.Count(w => w.IsAlive); Weak.RemoveAll(w => !w.IsAlive);
+ Require(survivors == 0, "Disposed production client/driver/browser object retained after full GC");
+ var fd = Directory.GetFiles("/proc/self/fd");
+ var owned = 0; var pki = 0;
+ foreach (var path in fd)
+ {
+ var target = new FileInfo(path).LinkTarget;
+ if (target is null || !Path.IsPathFullyQualified(target)) continue;
+ target = Path.GetFullPath(target);
+ if (target.StartsWith(Path.GetFullPath(Root) + Path.DirectorySeparatorChar, StringComparison.Ordinal)) owned++;
+ if (target.StartsWith(Path.Combine(Path.GetFullPath(Root), "data") + Path.DirectorySeparatorChar, StringComparison.Ordinal)) pki++;
+ }
+ using var p = Process.GetCurrentProcess();
+ return new { cycle, elapsedSeconds = elapsed, utc = DateTime.UtcNow, p.WorkingSet64, p.PrivateMemorySize64,
+ managedBytes = GC.GetTotalMemory(false), allocatedBytes = GC.GetTotalAllocatedBytes(),
+ gc = new[] { GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2) },
+ fdCount = fd.Length, ownedRootFdCount = owned, ownedPkiFdCount = pki, survivingProductionObjects = survivors,
+ managerDisposalsVerified = managerChecks, sharedPasses, driverPasses, browserPasses };
+ }
+ static async Task Run(string endpoint, int seconds, bool full)
+ {
+ Require(!full || seconds >= 7200, "Full soak requires >=7200 requested seconds");
+ // Deliberate owned-manager positive control proves the exact-tag read-only diagnostic sees both states.
+ var control = CertificateManagerFactory.Create(new SecurityConfiguration(), DefaultTelemetry.Create(_ => { }));
+ Require(!Disposed(control), "Disposal diagnostic control false-state failure");
+ await control.DisposeAsync(); Require(Disposed(control), "Disposal diagnostic control true-state failure");
+ control = null!;
+ var originalCerts = CertificateHashes();
+ var elapsed = Stopwatch.StartNew(); var cycle = 0;
+ File.AppendAllText(Path.Combine(Root, "resources.jsonl"), JsonSerializer.Serialize(Resources(0, 0)) + "\n");
+ while (elapsed.Elapsed.TotalSeconds < seconds)
+ {
+ await Cycle(endpoint, ++cycle).WaitAsync(TimeSpan.FromSeconds(65));
+ // The async cycle has returned, dropping its strong manager/session/item/event references.
+ await Task.Delay(300);
+ var serverPath = Path.Combine(Root, "server", "live-resources.json");
+ var settledOk = false;
+ for (var attempt = 0; attempt < 30; attempt++)
+ {
+ using var settled = JsonDocument.Parse(await File.ReadAllTextAsync(serverPath));
+ settledOk = new[] { "sessions", "subscriptions", "items" }.All(name => settled.RootElement.GetProperty(name).GetInt32() == 0);
+ if (settledOk) break;
+ await Task.Delay(100);
+ }
+ Require(settledOk, "Server sessions/subscriptions/items retained after bounded settling");
+ if (cycle % 10 == 0 || elapsed.Elapsed.TotalSeconds >= seconds)
+ File.AppendAllText(Path.Combine(Root, "resources.jsonl"), JsonSerializer.Serialize(Resources(cycle, elapsed.Elapsed.TotalSeconds)) + "\n");
+ }
+ Require(elapsed.Elapsed.TotalSeconds >= seconds && (!full || elapsed.Elapsed.TotalSeconds >= 7200), "Real elapsed duration gate failed");
+ Require(managerChecks == cycle * 8 && sharedPasses == cycle * 2 && driverPasses == cycle * 2 && browserPasses == cycle * 2, "Incomplete lifecycle accounting");
+ Require(CertificateHashes().SequenceEqual(originalCerts), "Application certificate unexpectedly rotated or PKI changed");
+ var summary = new { status = "passed", diagnosticPositiveControl = "exact SDK2 manager false-before/true-after DisposeAsync", applicationCertificateHashes = originalCerts, requestedSeconds = seconds, elapsedSeconds = elapsed.Elapsed.TotalSeconds,
+ fullTwoHourLifecycle = full && elapsed.Elapsed.TotalSeconds >= 7200, cycles = cycle, sharedPasses, driverPasses, browserPasses,
+ managerDisposalsVerified = managerChecks, finalResources = Resources(cycle, elapsed.Elapsed.TotalSeconds),
+ scope = "Production Shared connect/disconnect/reconnect; production driver Initialize/Reinitialize/Shutdown/dispose; production browser open/browse/dispose; scalar and Int32[3] read/write/subscribe/unsubscribe; strict mutual trusted DER/Basic256Sha256 SignAndEncrypt/API-key fixture. No network stalls, revocation, surgical edits/rebuild, CP abandonment or Windows/AVEVA/performance G4 claim." };
+ File.WriteAllText(Path.Combine(Root, "summary.json"), JsonSerializer.Serialize(summary, Json));
+ }
+ static string[] CertificateHashes() => Directory.GetFiles(Path.Combine(Root, "data"), "*.der", SearchOption.AllDirectories)
+ .Where(p => p.Contains(Path.DirectorySeparatorChar + "own" + Path.DirectorySeparatorChar + "certs" + Path.DirectorySeparatorChar, StringComparison.Ordinal))
+ .Order().Select(p => Path.GetRelativePath(Root, p) + ":" + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(p))).ToLowerInvariant()).ToArray();
+ public static async Task Main(string[] args)
+ {
+ if (args[0] == "server") return await ProcessHost.HelperMain(args);
+ Root = Path.GetFullPath(args[1]); Directory.CreateDirectory(Root);
+ try
+ {
+ var local = Path.GetFullPath(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
+ Require(local == Path.Combine(Root, "data"), "XDG task PKI isolation failed");
+ if (args[0] == "prepare") { await Prepare(); return 0; }
+ Require(args[0] == "run", "Unknown mode");
+ await Run(args[2], int.Parse(args[3]), args[4] == "full");
+ var sdk = AppDomain.CurrentDomain.GetAssemblies().Where(a => a.GetName().Name!.StartsWith("Opc.Ua", StringComparison.Ordinal))
+ .Select(a => new { name = a.GetName().Name, version = a.GetName().Version?.ToString(), sha256 = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(a.Location))).ToLowerInvariant() });
+ File.WriteAllText(Path.Combine(Root, "sdk-assemblies.json"), JsonSerializer.Serialize(sdk, Json));
+ return 0;
+ }
+ catch (Exception error)
+ {
+ File.WriteAllText(Path.Combine(Root, "failure.txt"), error.ToString().Replace(Password, ""));
+ return 1;
+ }
+ }
+}
diff --git a/scripts/validation/opcua-client-lifecycle/Lifecycle.csproj b/scripts/validation/opcua-client-lifecycle/Lifecycle.csproj
new file mode 100644
index 000000000..a7018f6a9
--- /dev/null
+++ b/scripts/validation/opcua-client-lifecycle/Lifecycle.csproj
@@ -0,0 +1,9 @@
+
+ExeLifecycleSDK2
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/scripts/validation/opcua-client-lifecycle/ProcessHost.cs b/scripts/validation/opcua-client-lifecycle/ProcessHost.cs
new file mode 100644
index 000000000..2a7322055
--- /dev/null
+++ b/scripts/validation/opcua-client-lifecycle/ProcessHost.cs
@@ -0,0 +1,360 @@
+// Copy of compatibility ProcessHost at 7b353d7b; lifecycle-owned changes only.
+#pragma warning disable OTOPCUA0001 // This wire harness deliberately exercises public driver capabilities directly.
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Sockets;
+using System.Reflection;
+using System.Text.Json;
+using Microsoft.Extensions.Logging.Abstractions;
+using Opc.Ua;
+using Opc.Ua.Client;
+using ZB.MOM.WW.OtOpcUa.Client.Shared;
+using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Authorization;
+using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
+using AlarmEventArgs = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.AlarmEventArgs;
+using WriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
+using SharedSecurityMode = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.SecurityMode;
+using CoreHistory = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
+
+internal static partial class ProcessHost
+{
+ static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc);
+ const string RawUri = "https://zb.com/otopcua/raw", UnsUri = "https://zb.com/otopcua/uns";
+ const string Folder = "Matrix/dev", Scalar = Folder + "/Scalar", Vector = Folder + "/Vector", Alarm = Folder + "/Alarm";
+ static readonly CancellationToken Ct = new CancellationTokenSource(TimeSpan.FromSeconds(int.Parse(Environment.GetEnvironmentVariable("OPCUA_LIFECYCLE_SERVER_SECONDS") ?? "240"))).Token;
+ static string Password => Environment.GetEnvironmentVariable("OPCUA_MATRIX_PASSWORD") ?? throw new InvalidOperationException("Missing ephemeral credential");
+ static readonly List