diff --git a/App.xaml.cs b/App.xaml.cs
index 7a0d16197..f70562c4d 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -6,6 +6,7 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
+using ArIED61850Tester.Services;
namespace ArIED61850Tester;
@@ -22,6 +23,20 @@ protected override void OnStartup(StartupEventArgs e)
WindowsApplicationIdentity.Apply();
base.OnStartup(e);
+ if (SclSafeTrialCommand.IsRequested(e.Args))
+ {
+ var trial = SclSafeTrialRunner.RunAsync(e.Args, CancellationToken.None)
+ .GetAwaiter()
+ .GetResult();
+ MessageBox.Show(
+ $"{trial.Message}\n\nEvidence: {trial.EvidencePath}",
+ trial.IsSuccess ? "SCL Safe Trial — PASS" : "SCL Safe Trial — NOT PROVEN",
+ MessageBoxButton.OK,
+ trial.IsSuccess ? MessageBoxImage.Information : MessageBoxImage.Warning);
+ Shutdown(trial.ExitCode);
+ return;
+ }
+
// P2 installs one calm industrial visual system before StartupUri materializes.
// Existing XAML keeps its semantic resource keys while the overlay replaces
// glare-heavy white/blue surfaces with Blue Steel + Light Greige equivalents.
diff --git a/Services/Iec61850ConnectionPathPolicy.cs b/Services/Iec61850ConnectionPathPolicy.cs
new file mode 100644
index 000000000..dc3f9281f
--- /dev/null
+++ b/Services/Iec61850ConnectionPathPolicy.cs
@@ -0,0 +1,29 @@
+using ArIED61850Tester.Models;
+
+namespace ArIED61850Tester.Services;
+
+public enum Iec61850ConnectionPath
+{
+ FullDiscovery,
+ CachedLiveModel,
+ SclAssisted
+}
+
+///
+/// Pure routing policy. SCL design authority is distinct from a saved live-discovery
+/// cache: Play/Connect uses the SCL-assisted path, while Re-scan remains an explicit
+/// caller of full discovery.
+///
+public static class Iec61850ConnectionPathPolicy
+{
+ public static Iec61850ConnectionPath SelectForFastConnect(Iec61850MonitorDevice device)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+
+ if (device.HasSclDesignModel)
+ return Iec61850ConnectionPath.SclAssisted;
+ if (device.HasDiscoveryCache && device.Signals.Count > 0)
+ return Iec61850ConnectionPath.CachedLiveModel;
+ return Iec61850ConnectionPath.FullDiscovery;
+ }
+}
diff --git a/Services/NativeIec61850Client.SclAssisted.cs b/Services/NativeIec61850Client.SclAssisted.cs
new file mode 100644
index 000000000..99b582974
--- /dev/null
+++ b/Services/NativeIec61850Client.SclAssisted.cs
@@ -0,0 +1,251 @@
+using System.Diagnostics;
+using ArMms = AR.Iec61850.Mms;
+using ArScl = AR.Iec61850.Scl;
+using AR.Iec61850.Discovery;
+
+namespace ArIED61850Tester.Services;
+
+public sealed class SclAssistedClientConnectResult
+{
+ public bool IsSuccess { get; init; }
+ public SclAssistedConnectionPreparation Preparation { get; init; } = new();
+ public ArScl.SclAssistedMmsOnlineResult? Online { get; init; }
+ public ArMms.InitialFcReadExecutionResult? InitialRead { get; init; }
+ public IReadOnlyList Warnings { get; init; } = Array.Empty();
+ public TimeSpan AssociationValidationDuration { get; init; }
+ public TimeSpan InitialReadDuration { get; init; }
+ public TimeSpan TotalDuration { get; init; }
+ public string Message { get; init; } = string.Empty;
+}
+
+public sealed partial class NativeIec61850Client
+{
+ public Task ConnectUsingSclAsync(
+ string sclXml,
+ string iedName,
+ string accessPointName,
+ string host,
+ int port,
+ CancellationToken cancellationToken)
+ => ConnectUsingSclAsync(
+ sclXml,
+ iedName,
+ accessPointName,
+ host,
+ port,
+ ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead,
+ cancellationToken);
+
+ ///
+ /// Opens the ARIEC61850 SCL-assisted online path: exact SCL association identity,
+ /// Domain/VMD reconciliation, then bounded sequential FC-root initial Reads.
+ /// The explicit batch-size argument supports controlled interoperability trials;
+ /// it never enables discovery or a silent automatic fallback.
+ ///
+ public async Task ConnectUsingSclAsync(
+ string sclXml,
+ string iedName,
+ string accessPointName,
+ string host,
+ int port,
+ int maximumVariableReferencesPerRead,
+ CancellationToken cancellationToken)
+ {
+ var totalWatch = Stopwatch.StartNew();
+ var associationDuration = TimeSpan.Zero;
+ var initialReadDuration = TimeSpan.Zero;
+
+ await DisposeControlSessionsAsync().ConfigureAwait(false);
+ LastErrorMessage = string.Empty;
+ LastConnectionFailureKind = string.Empty;
+ LastConnectionTechnicalSummary = string.Empty;
+ LastDiscoverySummary = string.Empty;
+ _lastDiscovery = null;
+ _liveModel = null;
+ LastReportInventory = new NativeReportInventory();
+ _reportMonitorSessions.Clear();
+ _reportMonitorCoverage.Clear();
+ ResetSemanticReportProjectionContext();
+ Interlocked.Exchange(ref _engineCompatibilityWarningIssued, 0);
+ DetectedIdentity = new Iec61850DeviceIdentity();
+ _host = host?.Trim() ?? string.Empty;
+ _port = port <= 0 ? 102 : port;
+
+ var preparation = SclAssistedConnectionPreparationBuilder.Build(
+ sclXml,
+ iedName,
+ accessPointName,
+ _host,
+ _port,
+ maximumVariableReferencesPerRead);
+ if (!preparation.IsSuccess ||
+ preparation.AssociationPlan is null ||
+ preparation.InitialReadDesign is null ||
+ preparation.InitialReadPlan is null)
+ {
+ LastConnectionFailureKind = "SCL_PLAN_INVALID";
+ LastErrorMessage = preparation.Errors.Count == 0
+ ? "SCL-assisted connection preparation failed."
+ : string.Join(" | ", preparation.Errors);
+ LastConnectionTechnicalSummary = LastErrorMessage;
+ totalWatch.Stop();
+ return new SclAssistedClientConnectResult
+ {
+ Preparation = preparation,
+ Warnings = preparation.Warnings,
+ TotalDuration = totalWatch.Elapsed,
+ Message = LastErrorMessage
+ };
+ }
+
+ try
+ {
+ var associationWatch = Stopwatch.StartNew();
+ var online = await _session.ConnectSclAssistedAsync(
+ preparation.AssociationPlan,
+ preparation.DomainInventory,
+ TimeSpan.FromSeconds(8),
+ cancellationToken).ConfigureAwait(false);
+ associationWatch.Stop();
+ associationDuration = associationWatch.Elapsed;
+
+ if (!online.IsCompatible)
+ {
+ LastConnectionFailureKind = online.Status.ToString();
+ LastErrorMessage = online.Message;
+ LastConnectionTechnicalSummary = online.Domains?.Summary ?? online.Message;
+ await _session.DisposeAsync().ConfigureAwait(false);
+ totalWatch.Stop();
+ return new SclAssistedClientConnectResult
+ {
+ Preparation = preparation,
+ Online = online,
+ Warnings = preparation.Warnings,
+ AssociationValidationDuration = associationDuration,
+ TotalDuration = totalWatch.Elapsed,
+ Message = LastErrorMessage
+ };
+ }
+
+ var readWatch = Stopwatch.StartNew();
+ var initialRead = await _session.ExecuteInitialFcReadPlanAsync(
+ preparation.InitialReadPlan,
+ TimeSpan.FromSeconds(5),
+ cancellationToken).ConfigureAwait(false);
+ readWatch.Stop();
+ initialReadDuration = readWatch.Elapsed;
+
+ if (initialRead.Status is ArMms.InitialFcReadExecutionStatus.InvalidPlan
+ or ArMms.InitialFcReadExecutionStatus.SessionNotReady
+ or ArMms.InitialFcReadExecutionStatus.TimedOut
+ or ArMms.InitialFcReadExecutionStatus.TransportFailure)
+ {
+ LastConnectionFailureKind = $"INITIAL_FC_READ_{initialRead.Status}";
+ LastErrorMessage = initialRead.Message;
+ LastConnectionTechnicalSummary = initialRead.Message;
+ await _session.DisposeAsync().ConfigureAwait(false);
+ totalWatch.Stop();
+ return new SclAssistedClientConnectResult
+ {
+ Preparation = preparation,
+ Online = online,
+ InitialRead = initialRead,
+ Warnings = preparation.Warnings,
+ AssociationValidationDuration = associationDuration,
+ InitialReadDuration = initialReadDuration,
+ TotalDuration = totalWatch.Elapsed,
+ Message = LastErrorMessage
+ };
+ }
+
+ // Seed a deliberately minimal discovery context from trusted SCL authority.
+ // Existing reporting code therefore sees that a model context exists and will
+ // not invoke DiscoverAsync behind the SCL-assisted connection path. Empty RCB
+ // inventory means reporting must prove only what it can, otherwise polling is
+ // used by the existing runtime; Re-scan remains the explicit full-discovery path.
+ var reconciledDomains = online.Domains?.MatchedDomains
+ ?? preparation.DomainInventory.ExpectedDomains;
+ var domainVariables = reconciledDomains
+ .Distinct(StringComparer.Ordinal)
+ .ToDictionary(
+ domain => domain,
+ _ => (IReadOnlyList)Array.Empty(),
+ StringComparer.Ordinal);
+
+ _lastDiscovery = new ArMms.MmsDiscoveryResult
+ {
+ Snapshot = new ArMms.MmsDiscoverySnapshot
+ {
+ DomainVariables = domainVariables,
+ DomainVariableLists = new Dictionary>(StringComparer.Ordinal)
+ },
+ ReportInventory = new ArMms.MmsReportInventory(),
+ IedDirectory = new ArMms.MmsIedModelDirectory(Array.Empty()),
+ Summary = "Trusted SCL authority: domain validation and bounded initial FC-root snapshot completed; full live discovery intentionally skipped."
+ };
+ _liveModel = preparation.InitialReadDesign.Model;
+ LastReportInventory = new NativeReportInventory();
+
+ var projectionErrors = initialRead.Batches
+ .Sum(batch => batch.Projections.Sum(projection => projection.Errors.Count));
+ var extraDomains = online.Domains?.ExtraObservedDomains.Count ?? 0;
+ var partial = initialRead.Status == ArMms.InitialFcReadExecutionStatus.Partial;
+ LastDiscoverySummary =
+ $"SCL-assisted MMS: domains={reconciledDomains.Count}, extraOnlineDomains={extraDomains}, " +
+ $"FC-roots={initialRead.Plan.Targets.Count}, successfulReads={initialRead.SuccessfulTargetCount}, " +
+ $"failedReads={initialRead.FailedTargetCount}, projectedLeaves={initialRead.ProjectedLeafCount}, " +
+ $"projectionErrors={projectionErrors}, maxVariablesPerRead={initialRead.Plan.MaximumVariableReferencesPerRead}, fullDiscovery=skipped.";
+ LastConnectionFailureKind = string.Empty;
+ LastConnectionTechnicalSummary = online.Domains?.Summary ?? online.Message;
+ LastErrorMessage = partial
+ ? "SCL-assisted association is healthy, but one or more initial FC-root values could not be read or projected. The trusted SCL model was preserved."
+ : string.Empty;
+
+ var warnings = preparation.Warnings
+ .Concat(extraDomains > 0
+ ? new[] { $"IED exposes {extraDomains} extra online MMS domain(s); they remain evidence only and do not mutate the SCL model." }
+ : Array.Empty())
+ .Concat(partial ? new[] { LastErrorMessage } : Array.Empty())
+ .Where(message => !string.IsNullOrWhiteSpace(message))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ totalWatch.Stop();
+ return new SclAssistedClientConnectResult
+ {
+ IsSuccess = true,
+ Preparation = preparation,
+ Online = online,
+ InitialRead = initialRead,
+ Warnings = warnings,
+ AssociationValidationDuration = associationDuration,
+ InitialReadDuration = initialReadDuration,
+ TotalDuration = totalWatch.Elapsed,
+ Message = LastDiscoverySummary
+ };
+ }
+ catch (OperationCanceledException)
+ {
+ totalWatch.Stop();
+ await _session.DisposeAsync().ConfigureAwait(false);
+ throw;
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ totalWatch.Stop();
+ LastConnectionFailureKind = "SCL_ASSISTED_RUNTIME_FAILURE";
+ LastErrorMessage = $"SCL-assisted MMS connection failed: {ex.GetType().Name}: {ex.Message}";
+ LastConnectionTechnicalSummary = LastErrorMessage;
+ await _session.DisposeAsync().ConfigureAwait(false);
+ return new SclAssistedClientConnectResult
+ {
+ Preparation = preparation,
+ Warnings = preparation.Warnings,
+ AssociationValidationDuration = associationDuration,
+ InitialReadDuration = initialReadDuration,
+ TotalDuration = totalWatch.Elapsed,
+ Message = LastErrorMessage
+ };
+ }
+ }
+}
diff --git a/Services/SclAssistedConnectionPreparation.cs b/Services/SclAssistedConnectionPreparation.cs
new file mode 100644
index 000000000..48c22bb05
--- /dev/null
+++ b/Services/SclAssistedConnectionPreparation.cs
@@ -0,0 +1,189 @@
+using System.Xml;
+using ArMms = AR.Iec61850.Mms;
+using ArScl = AR.Iec61850.Scl;
+
+namespace ArIED61850Tester.Services;
+
+public sealed class SclAssistedConnectionPreparation
+{
+ public ArScl.SclAssistedMmsAssociationPlan? AssociationPlan { get; init; }
+ public ArScl.SclMmsDomainInventory DomainInventory { get; init; } = new();
+ public ArScl.SclInitialFcReadDesign? InitialReadDesign { get; init; }
+ public ArMms.InitialFcReadPlan? InitialReadPlan { get; init; }
+ public IReadOnlyList Errors { get; init; } = Array.Empty();
+ public IReadOnlyList Warnings { get; init; } = Array.Empty();
+ public bool IsSuccess =>
+ Errors.Count == 0 &&
+ AssociationPlan is not null &&
+ DomainInventory.IsSuccess &&
+ InitialReadDesign?.IsSuccess == true &&
+ InitialReadPlan?.IsValid == true;
+}
+
+///
+/// Pure Step-5 orchestration preparation. It converts one trusted SCL IED/AccessPoint
+/// plus the operator-bound TCP endpoint into the exact ARIEC61850 association/domain/
+/// initial-read contracts. It performs no socket I/O and never falls back to discovery.
+///
+public static class SclAssistedConnectionPreparationBuilder
+{
+ public static SclAssistedConnectionPreparation Build(
+ string sclXml,
+ string iedName,
+ string accessPointName,
+ string host,
+ int port,
+ int maximumVariableReferencesPerRead = ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead)
+ {
+ var errors = new List();
+ var warnings = new List();
+ var normalizedHost = (host ?? string.Empty).Trim();
+ var normalizedIed = (iedName ?? string.Empty).Trim();
+ var normalizedAccessPoint = (accessPointName ?? string.Empty).Trim();
+ var normalizedPort = port <= 0 ? 102 : port;
+
+ if (string.IsNullOrWhiteSpace(sclXml))
+ errors.Add("SCL XML is empty.");
+ if (string.IsNullOrWhiteSpace(normalizedIed))
+ errors.Add("An exact SCL IED name is required.");
+ if (string.IsNullOrWhiteSpace(normalizedAccessPoint))
+ errors.Add("An exact SCL AccessPoint name is required.");
+ if (string.IsNullOrWhiteSpace(normalizedHost))
+ errors.Add("A TCP endpoint is required before SCL-assisted connect.");
+ if (normalizedPort is < 1 or > 65535)
+ errors.Add($"TCP port must be in 1..65535; received {normalizedPort}.");
+ if (maximumVariableReferencesPerRead is < 1 or > ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead)
+ {
+ errors.Add(
+ $"Initial Read batch size must be in 1..{ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead}; received {maximumVariableReferencesPerRead}.");
+ }
+
+ if (errors.Count > 0)
+ return Fail(errors, warnings);
+
+ ArScl.SclMmsAssociationProfileSet profileSet;
+ try
+ {
+ profileSet = ArScl.SclMmsAssociationProfileReader.Read(sclXml);
+ }
+ catch (XmlException ex)
+ {
+ errors.Add($"SCL XML is malformed: {ex.Message}");
+ return Fail(errors, warnings);
+ }
+ catch (Exception ex) when (ex is InvalidDataException or ArgumentException or InvalidOperationException)
+ {
+ errors.Add($"SCL MMS communication profile could not be read: {ex.GetType().Name}: {ex.Message}");
+ return Fail(errors, warnings);
+ }
+
+ warnings.AddRange(profileSet.Warnings);
+ var matches = profileSet.AccessPoints
+ .Where(profile =>
+ string.Equals(profile.IedName, normalizedIed, StringComparison.Ordinal) &&
+ string.Equals(profile.AccessPointName, normalizedAccessPoint, StringComparison.Ordinal))
+ .ToArray();
+
+ if (matches.Length != 1)
+ {
+ errors.Add(matches.Length == 0
+ ? $"SCL Communication has no exact ConnectedAP for IED '{normalizedIed}' / AccessPoint '{normalizedAccessPoint}'."
+ : $"SCL Communication has {matches.Length} exact ConnectedAP entries for IED '{normalizedIed}' / AccessPoint '{normalizedAccessPoint}'; association identity is ambiguous.");
+ return Fail(errors, warnings);
+ }
+
+ var sclRemote = matches[0];
+ var sclHost = (sclRemote.Endpoint.IpAddress ?? string.Empty).Trim();
+ if (string.IsNullOrWhiteSpace(sclHost))
+ {
+ warnings.Add($"SCL ConnectedAP has no IP address; using the explicit endpoint binding '{normalizedHost}'.");
+ }
+ else if (!string.Equals(sclHost, normalizedHost, StringComparison.OrdinalIgnoreCase))
+ {
+ warnings.Add($"SCL IP '{sclHost}' differs from the explicit endpoint binding '{normalizedHost}'; TCP uses the explicit binding while OSI association identity remains SCL-derived.");
+ }
+
+ // TCP endpoint binding is an application-level choice. Preserve every called-side
+ // OSI identity value from SCL and change only the network endpoint presented to
+ // the pure ARIEC association planner.
+ var effectiveRemote = new ArScl.SclMmsAccessPoint
+ {
+ IedName = sclRemote.IedName,
+ AccessPointName = sclRemote.AccessPointName,
+ SubNetworkName = sclRemote.SubNetworkName,
+ SubNetworkType = sclRemote.SubNetworkType,
+ Endpoint = new ArScl.SclMmsEndpoint
+ {
+ IpAddress = normalizedHost,
+ IpSubnet = sclRemote.Endpoint.IpSubnet,
+ IpGateway = sclRemote.Endpoint.IpGateway
+ },
+ Association = sclRemote.Association,
+ Parameters = sclRemote.Parameters
+ };
+
+ var association = ArScl.SclAssistedMmsAssociationPlanBuilder.BuildExact(
+ effectiveRemote,
+ ArScl.MmsLocalAssociationProfile.ExistingRuntimeDefault);
+ warnings.AddRange(association.Warnings);
+ if (!association.IsSuccess || association.Plan is null)
+ {
+ errors.AddRange(association.Errors);
+ return Fail(errors, warnings);
+ }
+
+ var runtimePlan = new ArScl.SclAssistedMmsAssociationPlan
+ {
+ Host = normalizedHost,
+ Port = normalizedPort,
+ IedName = association.Plan.IedName,
+ AccessPointName = association.Plan.AccessPointName,
+ LocalProfileName = association.Plan.LocalProfileName,
+ Cotp = association.Plan.Cotp,
+ Association = association.Plan.Association,
+ CotpConnectRequest = association.Plan.CotpConnectRequest,
+ SessionPresentationAcseMmsRequest = association.Plan.SessionPresentationAcseMmsRequest
+ };
+
+ var domains = ArScl.SclMmsDomainInventoryReader.Read(sclXml, normalizedIed, normalizedAccessPoint);
+ warnings.AddRange(domains.Warnings);
+ if (!domains.IsSuccess)
+ errors.AddRange(domains.Errors);
+
+ var design = ArScl.SclInitialFcReadDesignBuilder.Read(sclXml, normalizedIed, normalizedAccessPoint);
+ warnings.AddRange(design.Warnings);
+ if (!design.IsSuccess)
+ errors.AddRange(design.Errors);
+
+ ArMms.InitialFcReadPlan? initialReadPlan = null;
+ if (design.IsSuccess && domains.IsSuccess)
+ {
+ initialReadPlan = ArMms.InitialFcReadPlanner.FromSclModel(
+ design.Model,
+ domains.ExpectedDomains,
+ maximumVariableReferencesPerRead);
+ warnings.AddRange(initialReadPlan.Warnings);
+ if (!initialReadPlan.IsValid)
+ errors.AddRange(initialReadPlan.Errors);
+ }
+
+ return new SclAssistedConnectionPreparation
+ {
+ AssociationPlan = runtimePlan,
+ DomainInventory = domains,
+ InitialReadDesign = design,
+ InitialReadPlan = initialReadPlan,
+ Errors = errors.Distinct(StringComparer.Ordinal).ToArray(),
+ Warnings = warnings.Distinct(StringComparer.Ordinal).ToArray()
+ };
+ }
+
+ private static SclAssistedConnectionPreparation Fail(
+ IReadOnlyCollection errors,
+ IReadOnlyCollection warnings)
+ => new()
+ {
+ Errors = errors.Where(message => !string.IsNullOrWhiteSpace(message)).Distinct(StringComparer.Ordinal).ToArray(),
+ Warnings = warnings.Where(message => !string.IsNullOrWhiteSpace(message)).Distinct(StringComparer.Ordinal).ToArray()
+ };
+}
diff --git a/Services/SclSafeTrialGlobalUsings.cs b/Services/SclSafeTrialGlobalUsings.cs
new file mode 100644
index 000000000..e662eefc9
--- /dev/null
+++ b/Services/SclSafeTrialGlobalUsings.cs
@@ -0,0 +1 @@
+global using ArIED61850Tester.Models;
diff --git a/Services/SclSafeTrialRunner.cs b/Services/SclSafeTrialRunner.cs
new file mode 100644
index 000000000..4b8490600
--- /dev/null
+++ b/Services/SclSafeTrialRunner.cs
@@ -0,0 +1,288 @@
+using System.Diagnostics;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Xml.Linq;
+using ArMms = AR.Iec61850.Mms;
+
+namespace ArIED61850Tester.Services;
+
+public sealed record SclSafeTrialCommand(
+ string SclPath,
+ string IedName,
+ string AccessPointName,
+ string Host,
+ int Port,
+ int MaximumVariableReferencesPerRead,
+ string EvidencePath)
+{
+ public const string Switch = "--scl-safe-trial";
+ public const string SingleReferenceSwitch = "--scl-safe-trial-single";
+
+ public static bool IsRequested(IReadOnlyList args)
+ => args.Any(argument =>
+ string.Equals(argument, Switch, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(argument, SingleReferenceSwitch, StringComparison.OrdinalIgnoreCase));
+
+ public static bool TryParse(IReadOnlyList args, out SclSafeTrialCommand? command, out string error)
+ {
+ command = null;
+ error = string.Empty;
+ if (args.Count == 0 ||
+ (!string.Equals(args[0], Switch, StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(args[0], SingleReferenceSwitch, StringComparison.OrdinalIgnoreCase)))
+ {
+ error = $"Expected {Switch} or {SingleReferenceSwitch} as the first argument.";
+ return false;
+ }
+
+ if (args.Count < 5)
+ {
+ error =
+ $"Usage: ARSAS.exe {Switch} [port] [evidence JSON path]. " +
+ $"Use {SingleReferenceSwitch} with the same arguments for a controlled one-variable-per-Read interoperability trial.";
+ return false;
+ }
+
+ var sclPath = Path.GetFullPath(args[1]);
+ var iedName = (args[2] ?? string.Empty).Trim();
+ var accessPointName = (args[3] ?? string.Empty).Trim();
+ var host = (args[4] ?? string.Empty).Trim();
+ var port = 102;
+ if (args.Count >= 6 && (!int.TryParse(args[5], out port) || port is < 1 or > 65535))
+ {
+ error = $"Invalid MMS TCP port '{args[5]}'; expected 1..65535.";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(iedName) || string.IsNullOrWhiteSpace(accessPointName) || string.IsNullOrWhiteSpace(host))
+ {
+ error = "IED name, AccessPoint name and host/IP are required.";
+ return false;
+ }
+
+ var maximumVariableReferencesPerRead = string.Equals(
+ args[0],
+ SingleReferenceSwitch,
+ StringComparison.OrdinalIgnoreCase)
+ ? 1
+ : ArMms.MmsReadBatchCodec.MaximumVariableReferencesPerRead;
+
+ var evidencePath = args.Count >= 7 && !string.IsNullOrWhiteSpace(args[6])
+ ? Path.GetFullPath(args[6])
+ : Path.Combine(
+ Path.GetTempPath(),
+ $"ARSAS-SCL-Trial-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{maximumVariableReferencesPerRead}ref-{Guid.NewGuid():N}.json");
+
+ command = new SclSafeTrialCommand(
+ sclPath,
+ iedName,
+ accessPointName,
+ host,
+ port,
+ maximumVariableReferencesPerRead,
+ evidencePath);
+ return true;
+ }
+}
+
+public sealed record SclSafeTrialRunResult(
+ int ExitCode,
+ bool IsSuccess,
+ string Message,
+ string EvidencePath);
+
+///
+/// Read-only laboratory entry point for physically validating the SCL-assisted MMS path.
+/// It performs association, Domain/VMD reconciliation and bounded initial FC-root Reads only.
+/// The process exits immediately afterwards, so reporting, control, writes and hidden full
+/// discovery cannot be entered by this trial path.
+///
+public static class SclSafeTrialRunner
+{
+ public static async Task RunAsync(
+ IReadOnlyList args,
+ CancellationToken cancellationToken = default)
+ {
+ if (!SclSafeTrialCommand.TryParse(args, out var command, out var parseError) || command is null)
+ return await WriteInputFailureAsync(args, parseError, cancellationToken).ConfigureAwait(false);
+
+ var stopwatch = Stopwatch.StartNew();
+ string sourceSha256 = string.Empty;
+ SclAssistedClientConnectResult? result = null;
+ Iec61850DeviceDiagnosticSnapshot? diagnostic = null;
+ string message;
+ var exitCode = 0;
+
+ try
+ {
+ if (!File.Exists(command.SclPath))
+ throw new FileNotFoundException("SCL/CID source file was not found.", command.SclPath);
+
+ var sourceBytes = await File.ReadAllBytesAsync(command.SclPath, cancellationToken).ConfigureAwait(false);
+ sourceSha256 = Convert.ToHexString(SHA256.HashData(sourceBytes)).ToLowerInvariant();
+
+ // Let an XML parser honor the document encoding and normalize only the in-memory
+ // representation passed to the pure SCL planners. The evidence hash remains over
+ // the exact source bytes selected by the operator.
+ var document = XDocument.Load(command.SclPath, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo);
+ var sclXml = document.ToString(SaveOptions.DisableFormatting);
+
+ var client = new NativeIec61850Client();
+ result = await client.ConnectUsingSclAsync(
+ sclXml,
+ command.IedName,
+ command.AccessPointName,
+ command.Host,
+ command.Port,
+ command.MaximumVariableReferencesPerRead,
+ cancellationToken).ConfigureAwait(false);
+
+ diagnostic = client.CaptureDiagnosticSnapshot("SCL safe trial");
+ message = result.Message;
+ exitCode = result.IsSuccess ? 0 : 31;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ message = "SCL safe trial was cancelled by the operator.";
+ exitCode = 32;
+ }
+ catch (Exception ex)
+ {
+ message = $"SCL safe trial failed before completion: {ex.GetType().Name}: {ex.Message}";
+ exitCode = 33;
+ }
+ finally
+ {
+ stopwatch.Stop();
+ }
+
+ var evidence = new
+ {
+ schema = "arsas-scl-safe-trial-v2",
+ capturedAtUtc = DateTimeOffset.UtcNow,
+ safety = new
+ {
+ readOnly = true,
+ fullDiscoveryAllowed = false,
+ writesAllowed = false,
+ controlAllowed = false,
+ reportEnableAllowed = false,
+ dynamicDataSetAllowed = false,
+ automaticReadFallbackAllowed = false
+ },
+ source = new
+ {
+ path = command.SclPath,
+ sha256 = sourceSha256,
+ iedName = command.IedName,
+ accessPointName = command.AccessPointName,
+ host = command.Host,
+ port = command.Port
+ },
+ trialMode = new
+ {
+ maximumVariableReferencesPerRead = command.MaximumVariableReferencesPerRead,
+ label = command.MaximumVariableReferencesPerRead == 1
+ ? "single-reference-control"
+ : "bounded-multi-reference"
+ },
+ timing = new
+ {
+ processTotalMilliseconds = stopwatch.Elapsed.TotalMilliseconds,
+ connectionTotalMilliseconds = result?.TotalDuration.TotalMilliseconds ?? 0d,
+ associationAndDomainValidationMilliseconds = result?.AssociationValidationDuration.TotalMilliseconds ?? 0d,
+ initialReadMilliseconds = result?.InitialReadDuration.TotalMilliseconds ?? 0d
+ },
+ result = new
+ {
+ success = result?.IsSuccess == true,
+ exitCode,
+ message,
+ warnings = result?.Warnings ?? Array.Empty(),
+ preparationErrors = result?.Preparation.Errors ?? Array.Empty(),
+ preparationWarnings = result?.Preparation.Warnings ?? Array.Empty(),
+ association = result?.Online is null ? null : new
+ {
+ status = result.Online.Status.ToString(),
+ result.Online.AssociationSucceeded,
+ result.Online.DomainInventorySucceeded,
+ result.Online.SessionRemainsOpen,
+ result.Online.Message
+ },
+ domains = result?.Online?.Domains is null ? null : new
+ {
+ expected = result.Online.Domains.ExpectedDomains,
+ observed = result.Online.Domains.ObservedDomains,
+ matched = result.Online.Domains.MatchedDomains,
+ missing = result.Online.Domains.MissingExpectedDomains,
+ extra = result.Online.Domains.ExtraObservedDomains,
+ result.Online.Domains.IsCompatible,
+ result.Online.Domains.IsExactMatch,
+ result.Online.Domains.Summary
+ },
+ initialRead = result?.InitialRead is null ? null : new
+ {
+ status = result.InitialRead.Status.ToString(),
+ result.InitialRead.Message,
+ result.InitialRead.SuccessfulTargetCount,
+ result.InitialRead.FailedTargetCount,
+ result.InitialRead.ProjectedLeafCount,
+ maximumVariableReferencesPerRead = result.InitialRead.Plan.MaximumVariableReferencesPerRead,
+ maximumOutstandingReads = result.InitialRead.Plan.MaximumOutstandingReads,
+ targetCount = result.InitialRead.Plan.Targets.Count,
+ batchCount = result.InitialRead.Plan.Batches.Count,
+ batches = result.InitialRead.Batches.Select(batch => new
+ {
+ batch.BatchIndex,
+ targetCount = batch.Targets.Count,
+ references = batch.Targets.Select(target => target.MmsReference).ToArray(),
+ successCount = batch.Read.Results.Count(item => item.IsSuccess),
+ failureCount = batch.Read.Results.Count(item => !item.IsSuccess),
+ projectionErrorCount = batch.Projections.Sum(item => item.Errors.Count)
+ }).ToArray()
+ },
+ diagnostic
+ }
+ };
+
+ await WriteEvidenceAsync(command.EvidencePath, evidence, cancellationToken).ConfigureAwait(false);
+ return new SclSafeTrialRunResult(exitCode, result?.IsSuccess == true, message, command.EvidencePath);
+ }
+
+ private static async Task WriteInputFailureAsync(
+ IReadOnlyList args,
+ string message,
+ CancellationToken cancellationToken)
+ {
+ var path = Path.Combine(
+ Path.GetTempPath(),
+ $"ARSAS-SCL-Trial-Invalid-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.json");
+ var evidence = new
+ {
+ schema = "arsas-scl-safe-trial-v2",
+ capturedAtUtc = DateTimeOffset.UtcNow,
+ success = false,
+ exitCode = 30,
+ message,
+ arguments = args.ToArray()
+ };
+ await WriteEvidenceAsync(path, evidence, cancellationToken).ConfigureAwait(false);
+ return new SclSafeTrialRunResult(30, false, message, path);
+ }
+
+ private static async Task WriteEvidenceAsync(
+ string path,
+ object evidence,
+ CancellationToken cancellationToken)
+ {
+ var directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrWhiteSpace(directory))
+ Directory.CreateDirectory(directory);
+
+ var json = JsonSerializer.Serialize(evidence, new JsonSerializerOptions
+ {
+ WriteIndented = true
+ });
+ await File.WriteAllTextAsync(path, json, cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/Services/VerifiedSclSourceLoader.cs b/Services/VerifiedSclSourceLoader.cs
new file mode 100644
index 000000000..2ca0d3808
--- /dev/null
+++ b/Services/VerifiedSclSourceLoader.cs
@@ -0,0 +1,57 @@
+using System.Security.Cryptography;
+using System.Xml.Linq;
+
+namespace ArIED61850Tester.Services;
+
+public sealed record VerifiedSclSource(
+ string FullPath,
+ string Sha256,
+ string Xml);
+
+///
+/// Loads exactly the source bytes previously accepted by the SCL workspace. A missing
+/// file or SHA-256 mismatch is a hard stop: online SCL-assisted connect must never use
+/// a silently edited design file or fall back to live discovery without the operator.
+///
+public static class VerifiedSclSourceLoader
+{
+ public static async Task LoadAsync(
+ string sourcePath,
+ string expectedSha256,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(sourcePath))
+ throw new InvalidDataException("SCL source path is empty. Re-open the trusted SCL/CID before connecting.");
+ if (string.IsNullOrWhiteSpace(expectedSha256))
+ throw new InvalidDataException("SCL source SHA-256 is missing. Re-open the trusted SCL/CID before connecting.");
+
+ var fullPath = Path.GetFullPath(sourcePath);
+ if (!File.Exists(fullPath))
+ throw new FileNotFoundException("The trusted SCL/CID source file is no longer available. Re-open it before connecting.", fullPath);
+
+ var bytes = await File.ReadAllBytesAsync(fullPath, cancellationToken).ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+ var actualSha256 = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
+ var expected = NormalizeSha256(expectedSha256);
+ if (!string.Equals(actualSha256, expected, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException(
+ $"Trusted SCL/CID changed after import. Expected SHA-256 {expected}, actual {actualSha256}. Re-open the file so the design authority is explicit.");
+ }
+
+ using var stream = new MemoryStream(bytes, writable: false);
+ var document = XDocument.Load(stream, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo);
+ return new VerifiedSclSource(
+ fullPath,
+ actualSha256,
+ document.ToString(SaveOptions.DisableFormatting));
+ }
+
+ private static string NormalizeSha256(string value)
+ {
+ var normalized = value.Trim().Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant();
+ if (normalized.Length != 64 || normalized.Any(character => !Uri.IsHexDigit(character)))
+ throw new InvalidDataException($"Invalid SCL SHA-256 evidence '{value}'.");
+ return normalized;
+ }
+}
diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json
index 2d50a2390..0d234e355 100644
--- a/engines/ARIEC61850.lock.json
+++ b/engines/ARIEC61850.lock.json
@@ -2,7 +2,12 @@
"schemaVersion": 1,
"repository": "masarray/ARIEC61850",
"ref": "main",
- "commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9",
- "sourcePullRequest": 111,
- "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is attempted before generic structured-value heuristics so TotPF and similar members publish exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values. P1 hardening at 0d7525bd330900917fb9f6d15a46059dc3d7a70a also makes semantic expansion return the resolved authoritative member identity and replaces generic output by report-value position after semantic success, so an InformationReport that omits MemberReference but resolves uniquely through static DataSet index cannot leak unrooted projected-mx-pair leaves alongside exact semantic values. Physical BRCB compatibility hardening at 11ab2304482600c19ba979f4fc9021ddb46b9af9 adds a client-compatible persistent activation wrapper: when ResvTms is exposed it attempts an explicit 60-second BRCB reservation with implicit-RptEna fallback, keeps cleanup/release deterministic, and requests GI only after the persistent report session is registered."
+ "commit": "3027659c04a4088a5ee45d9c917e1ed342a91776",
+ "sourcePullRequest": 125,
+ "purpose": "Field-trial pin for the SCL-assisted MMS path. This exact convergence commit starts from the ARSAS field-proven engine recorded in fieldProvenBaseline, preserves its reporting/control and semantic-projection behavior, and adds only the reviewed SCL-assisted Steps 1-4 surface plus fail-closed association-address hardening and canonical branch CI. The ACSE encoder is the exact golden-byte-tested Step-2 implementation; new AE/duplicate-selector validation is enforced before encoding. Build SDK selection is smart across development machines: minimum .NET SDK 8.0.100 with global.json rollForward=latestMajor, so an installed compatible .NET 8 SDK can be used on one machine while a newer installed SDK such as .NET 10 can build the same net8.0/net8.0-windows targets on another machine. The first physical trial is intentionally read-only: SCL-derived association identity, Domain/VMD reconciliation, and bounded sequential initial FC-root Reads only. Full live discovery, writes, control, RCB enable/GI, and dynamic DataSet mutation are not part of the safe-trial entry point. PR #125 is CI/evidence only and must not be merged to ARIEC main; this immutable SHA is the trial authority until physical IED evidence is reviewed.",
+ "fieldProvenBaseline": {
+ "commit": "11ab2304482600c19ba979f4fc9021ddb46b9af9",
+ "sourcePullRequest": 111,
+ "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is attempted before generic structured-value heuristics so TotPF and similar members publish exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values. P1 hardening at 0d7525bd330900917fb9f6d15a46059dc3d7a70a also makes semantic expansion return the resolved authoritative member identity and replaces generic output by report-value position after semantic success, so an InformationReport that omits MemberReference but resolves uniquely through static DataSet index cannot leak unrooted projected-mx-pair leaves alongside exact semantic values. Physical BRCB compatibility hardening at 11ab2304482600c19ba979f4fc9021ddb46b9af9 adds a client-compatible persistent activation wrapper: when ResvTms is exposed it attempts an explicit 60-second BRCB reservation with implicit-RptEna fallback, keeps cleanup/release deterministic, and requests GI only after the persistent report session is registered."
+ }
}
diff --git a/global.json b/global.json
index 391ba3c2a..d07970ac2 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "8.0.100",
- "rollForward": "latestFeature"
+ "rollForward": "latestMajor"
}
}
diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs
index a8662812b..ab82b5bfe 100644
--- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs
+++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs
@@ -19,11 +19,14 @@ public void EngineLock_PreservesExactG1FieldProvenAncestryAcrossReviewedPinAdvan
json.GetProperty("sourcePullRequest").GetInt32() >= 95,
"A reviewed post-G2.4 engine pin must retain the field-proven G1/G1.1 ancestry contract.");
- var purpose = json.GetProperty("purpose").GetString() ?? string.Empty;
+ var baseline = json.GetProperty("fieldProvenBaseline");
+ Assert.Equal("11ab2304482600c19ba979f4fc9021ddb46b9af9", baseline.GetProperty("commit").GetString());
+ Assert.Equal(111, baseline.GetProperty("sourcePullRequest").GetInt32());
+ var purpose = baseline.GetProperty("purpose").GetString() ?? string.Empty;
- // Engine consumers may advance the immutable pin for a proven missing capability,
- // but the field-proven G1/G2.3/P0/P1 ancestry and all reporting/control safety
- // statements must remain explicit in the lock provenance.
+ // Engine consumers may advance the immutable trial pin for a proven missing capability,
+ // but the structured field-proven baseline must retain the G1/G2.3/P0/P1 ancestry and
+ // all reporting/control safety statements verbatim.
Assert.Contains("a18e550d07f7bbe4ff7753c180b02615075f6292", purpose, StringComparison.OrdinalIgnoreCase);
Assert.Contains("signed primitive constraints", purpose, StringComparison.OrdinalIgnoreCase);
Assert.Contains("ordered SBO/SBOw-to-Operate wire evidence", purpose, StringComparison.OrdinalIgnoreCase);
@@ -151,4 +154,4 @@ private static string RepoRoot()
}
throw new DirectoryNotFoundException("ARSAS repository root not found.");
}
-}
+}
\ No newline at end of file
diff --git a/tests/ARSAS.Tests/P0Build1888RecoveryRegressionTests.cs b/tests/ARSAS.Tests/P0Build1888RecoveryRegressionTests.cs
index 4b12550a1..d4a1abeee 100644
--- a/tests/ARSAS.Tests/P0Build1888RecoveryRegressionTests.cs
+++ b/tests/ARSAS.Tests/P0Build1888RecoveryRegressionTests.cs
@@ -5,12 +5,18 @@ namespace ARSAS.Tests;
public sealed class P0Build1888RecoveryRegressionTests
{
[Fact]
- public void P0_KeepsExactArIec61850GoldenPin()
+ public void P0_KeepsExactArIec61850GoldenBaselineAcrossReviewedTrialPin()
{
using var document = JsonDocument.Parse(File.ReadAllText(FindRepoFile("engines/ARIEC61850.lock.json")));
+ var root = document.RootElement;
+ Assert.Matches("^[0-9a-f]{40}$", root.GetProperty("commit").GetString() ?? string.Empty);
+ Assert.True(root.GetProperty("sourcePullRequest").GetInt32() >= 125);
+
+ var baseline = root.GetProperty("fieldProvenBaseline");
Assert.Equal(
"11ab2304482600c19ba979f4fc9021ddb46b9af9",
- document.RootElement.GetProperty("commit").GetString());
+ baseline.GetProperty("commit").GetString());
+ Assert.Equal(111, baseline.GetProperty("sourcePullRequest").GetInt32());
}
[Fact]
diff --git a/tests/ARSAS.Tests/SclConnectionAuthorityTests.cs b/tests/ARSAS.Tests/SclConnectionAuthorityTests.cs
new file mode 100644
index 000000000..6d5a02346
--- /dev/null
+++ b/tests/ARSAS.Tests/SclConnectionAuthorityTests.cs
@@ -0,0 +1,89 @@
+using System.Security.Cryptography;
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+public sealed class SclConnectionAuthorityTests
+{
+ [Fact]
+ public void ConnectionPolicy_PrefersSclAuthorityOverDiscoveryCache()
+ {
+ var device = new Iec61850MonitorDevice
+ {
+ SclSourceSha256 = new string('a', 64),
+ HasDiscoveryCache = true
+ };
+ device.Signals.Add(new SignalDefinition { Name = "stVal" });
+
+ Assert.Equal(
+ Iec61850ConnectionPath.SclAssisted,
+ Iec61850ConnectionPathPolicy.SelectForFastConnect(device));
+ }
+
+ [Fact]
+ public void ConnectionPolicy_UsesCachedLiveModelOnlyWithoutSclAuthority()
+ {
+ var device = new Iec61850MonitorDevice { HasDiscoveryCache = true };
+ device.Signals.Add(new SignalDefinition { Name = "stVal" });
+
+ Assert.Equal(
+ Iec61850ConnectionPath.CachedLiveModel,
+ Iec61850ConnectionPathPolicy.SelectForFastConnect(device));
+ }
+
+ [Fact]
+ public void ConnectionPolicy_RequiresFullDiscoveryWithoutTrustedModel()
+ {
+ var device = new Iec61850MonitorDevice();
+
+ Assert.Equal(
+ Iec61850ConnectionPath.FullDiscovery,
+ Iec61850ConnectionPathPolicy.SelectForFastConnect(device));
+ }
+
+ [Fact]
+ public async Task VerifiedLoader_AcceptsExactImportedSourceBytes()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"arsas-scl-{Guid.NewGuid():N}.cid");
+ const string xml = "";
+ try
+ {
+ await File.WriteAllTextAsync(path, xml);
+ var bytes = await File.ReadAllBytesAsync(path);
+ var sha = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
+
+ var verified = await VerifiedSclSourceLoader.LoadAsync(path, sha);
+
+ Assert.Equal(sha, verified.Sha256);
+ Assert.Equal(Path.GetFullPath(path), verified.FullPath);
+ Assert.Contains("IED01", verified.Xml, StringComparison.Ordinal);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public async Task VerifiedLoader_RejectsSourceChangedAfterImport()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"arsas-scl-{Guid.NewGuid():N}.cid");
+ try
+ {
+ await File.WriteAllTextAsync(path, "");
+ var original = await File.ReadAllBytesAsync(path);
+ var expectedSha = Convert.ToHexString(SHA256.HashData(original)).ToLowerInvariant();
+ await File.WriteAllTextAsync(path, "");
+
+ var error = await Assert.ThrowsAsync(() =>
+ VerifiedSclSourceLoader.LoadAsync(path, expectedSha));
+
+ Assert.Contains("changed after import", error.Message, StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+}
diff --git a/tests/ARSAS.Tests/SclSafeTrialRunnerTests.cs b/tests/ARSAS.Tests/SclSafeTrialRunnerTests.cs
new file mode 100644
index 000000000..5e23a1e9a
--- /dev/null
+++ b/tests/ARSAS.Tests/SclSafeTrialRunnerTests.cs
@@ -0,0 +1,98 @@
+using AR.Iec61850.Mms;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+public sealed class SclSafeTrialRunnerTests
+{
+ [Fact]
+ public void CommandParser_UsesExplicitSclIdentityAndBoundedBatchDefault()
+ {
+ var source = Path.Combine(Path.GetTempPath(), "trial.cid");
+ var args = new[]
+ {
+ SclSafeTrialCommand.Switch,
+ source,
+ "IED01",
+ "AP1",
+ "192.0.2.10"
+ };
+
+ Assert.True(SclSafeTrialCommand.TryParse(args, out var command, out var error), error);
+ Assert.NotNull(command);
+ Assert.Equal(Path.GetFullPath(source), command!.SclPath);
+ Assert.Equal("IED01", command.IedName);
+ Assert.Equal("AP1", command.AccessPointName);
+ Assert.Equal("192.0.2.10", command.Host);
+ Assert.Equal(102, command.Port);
+ Assert.Equal(MmsReadBatchCodec.MaximumVariableReferencesPerRead, command.MaximumVariableReferencesPerRead);
+ Assert.EndsWith(".json", command.EvidencePath, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void CommandParser_SingleReferenceMode_UsesExactlyOneVariablePerRead()
+ {
+ var args = new[]
+ {
+ SclSafeTrialCommand.SingleReferenceSwitch,
+ "trial.cid",
+ "IED01",
+ "AP1",
+ "192.0.2.10"
+ };
+
+ Assert.True(SclSafeTrialCommand.TryParse(args, out var command, out var error), error);
+ Assert.NotNull(command);
+ Assert.Equal(1, command!.MaximumVariableReferencesPerRead);
+ }
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("65536")]
+ [InlineData("not-a-port")]
+ public void CommandParser_RejectsInvalidMmsPort(string port)
+ {
+ var args = new[]
+ {
+ SclSafeTrialCommand.Switch,
+ "trial.cid",
+ "IED01",
+ "AP1",
+ "192.0.2.10",
+ port
+ };
+
+ Assert.False(SclSafeTrialCommand.TryParse(args, out var command, out var error));
+ Assert.Null(command);
+ Assert.Contains("port", error, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void TrialRunner_SourceContract_HasNoDiscoveryWriteControlOrReportingEntryPoint()
+ {
+ var source = File.ReadAllText(FindRepoFile("Services/SclSafeTrialRunner.cs"));
+
+ Assert.Contains("ConnectUsingSclAsync", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("DiscoverAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("DiscoverSignalsAsync", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("WriteAsync", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("Operate", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("StartReportMonitor", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.Ordinal);
+ }
+
+ private static string FindRepoFile(string relativePath)
+ {
+ DirectoryInfo? directory = new(AppContext.BaseDirectory);
+ while (directory != null)
+ {
+ var candidate = Path.Combine(directory.FullName, relativePath);
+ if (File.Exists(candidate))
+ return candidate;
+ directory = directory.Parent;
+ }
+
+ throw new FileNotFoundException(
+ $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'.");
+ }
+}