diff --git a/DynamicReportQualificationResultWindow.G24C.cs b/DynamicReportQualificationResultWindow.G24C.cs
new file mode 100644
index 000000000..52d1d8ec3
--- /dev/null
+++ b/DynamicReportQualificationResultWindow.G24C.cs
@@ -0,0 +1,92 @@
+using System.Text;
+using ArIED61850Tester.Services;
+
+namespace ArIED61850Tester;
+
+internal partial class DynamicReportQualificationResultWindow
+{
+ internal DynamicReportQualificationResultWindow(DynamicReportCleanupClosureCommissioningResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ InitializeComponent();
+
+ Title = "G2.4-C Fresh Association Cleanup Closure Evidence";
+ HeaderText.Text = "G2.4-C Fresh Association Cleanup Closure";
+ SummaryText.Text = result.Summary;
+ StateText.Text = result.IsSuccess
+ ? "G2.4 Cleanup Closed"
+ : result.IsBlocked
+ ? "Blocked"
+ : "Cleanup closure not proven";
+ EvidenceTextBox.Text = BuildG24CEvidence(result);
+
+ if (result.IsSuccess)
+ SetPassBadge();
+ }
+
+ private static string BuildG24CEvidence(DynamicReportCleanupClosureCommissioningResult result)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("ARSAS G2.4-C FRESH ASSOCIATION CLEANUP CLOSURE EVIDENCE");
+ builder.AppendLine(new string('=', 72));
+ builder.AppendLine($"Result: {result.Summary}");
+ builder.AppendLine($"Blocked: {result.IsBlocked}");
+ builder.AppendLine($"G2.4-C success: {result.IsSuccess}");
+
+ if (result.Identity is not null)
+ {
+ builder.AppendLine();
+ builder.AppendLine("IED IDENTITY");
+ builder.AppendLine($"Stable identity: {result.Identity.StableIdentityKey}");
+ builder.AppendLine($"Model fingerprint: {result.Identity.ModelFingerprint}");
+ builder.AppendLine($"Model: {result.Identity.Model}");
+ builder.AppendLine($"Firmware: {result.Identity.FirmwareRevision}");
+ builder.AppendLine($"Profile revision: {result.Identity.ProfileRevision}");
+ }
+
+ builder.AppendLine();
+ builder.AppendLine("G2.4 PROVEN TARGET");
+ builder.AppendLine($"Input profile state: {result.InputProfile?.State.ToString() ?? "-"}");
+ builder.AppendLine($"URCB: {G24CTextOrDash(result.RcbReference)}");
+ builder.AppendLine($"Previous temporary DataSet: {G24CTextOrDash(result.TemporaryDataSetReference)}");
+
+ if (result.FreshRcbSnapshot is not null)
+ {
+ var snapshot = result.FreshRcbSnapshot;
+ builder.AppendLine();
+ builder.AppendLine("FRESH-ASSOCIATION RCB CLOSURE");
+ builder.AppendLine($"Availability: {snapshot.Availability}");
+ builder.AppendLine($"DatSet probe: {snapshot.DataSetProbeState}");
+ builder.AppendLine($"DatSet: {G24CTextOrDash(snapshot.DataSetReference)}");
+ builder.AppendLine($"RptEna: {G24CTextOrDash(snapshot.EnabledState)}");
+ builder.AppendLine($"Resv: {G24CTextOrDash(snapshot.ReservationState)}");
+ builder.AppendLine($"Owner: {G24CTextOrDash(snapshot.Owner)}");
+ builder.AppendLine($"Reservation time: {G24CTextOrDash(snapshot.ReservationTimeSeconds)}");
+ builder.AppendLine($"TrgOps read-only: {G24CTextOrDash(snapshot.TriggerOptions)}");
+ builder.AppendLine($"OptFlds read-only: {G24CTextOrDash(snapshot.OptionalFields)}");
+ builder.AppendLine($"RptID: {G24CTextOrDash(snapshot.ReportId)}");
+ }
+
+ builder.AppendLine();
+ builder.AppendLine("TEMPORARY DATASET CLOSURE");
+ builder.AppendLine($"Absent from fresh NamedVariableList: {result.TemporaryDataSetAbsentFromNameList}");
+ builder.AppendLine($"Direct DataSet directory absent: {result.TemporaryDataSetDirectoryAbsent}");
+ builder.AppendLine($"Fresh association healthy: {result.AssociationHealthy}");
+
+ builder.AppendLine();
+ builder.AppendLine("WIRE / SAFETY EVIDENCE");
+ foreach (var line in result.EvidenceLines)
+ builder.AppendLine(line);
+
+ builder.AppendLine();
+ builder.AppendLine("SAFETY STATE");
+ builder.AppendLine("G2.4-C is read-only and performs zero MMS writes or DataSet mutations.");
+ builder.AppendLine("G2.4-C does not alter the persisted InformationReportProven profile.");
+ builder.AppendLine("G2.4 Cleanup Closed != ProductionEligible.");
+ builder.AppendLine("Production automatic dynamic reporting remains OFF until G2.5/G2.6 acceptance gates pass.");
+ return builder.ToString();
+ }
+
+ private static string G24CTextOrDash(string? value)
+ => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim();
+}
diff --git a/DynamicReportQualificationResultWindow.P1.cs b/DynamicReportQualificationResultWindow.P1.cs
new file mode 100644
index 000000000..a3808d9a5
--- /dev/null
+++ b/DynamicReportQualificationResultWindow.P1.cs
@@ -0,0 +1,88 @@
+using System.Text;
+using ArIED61850Tester.Services;
+
+namespace ArIED61850Tester;
+
+internal partial class DynamicReportQualificationResultWindow
+{
+ internal DynamicReportQualificationResultWindow(DynamicReportOptionalFieldsProbeCommissioningResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ InitializeComponent();
+
+ Title = "P1 Isolated OptFlds Micro-Probe Evidence";
+ HeaderText.Text = "P1 Isolated OptFlds Micro-Probe";
+ SummaryText.Text = result.Summary;
+ StateText.Text = result.IsSuccess
+ ? "P1 OptFlds Proven"
+ : result.IsBlocked
+ ? "Blocked"
+ : result.CleanupSucceeded
+ ? "Not proven / restored"
+ : "Restore unproven";
+ EvidenceTextBox.Text = BuildP1Evidence(result);
+
+ if (result.IsSuccess)
+ SetPassBadge();
+ }
+
+ private static string BuildP1Evidence(DynamicReportOptionalFieldsProbeCommissioningResult result)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("ARSAS P1 ISOLATED OPTFLDS MICRO-PROBE EVIDENCE");
+ builder.AppendLine(new string('=', 62));
+ builder.AppendLine($"Result: {result.Summary}");
+ builder.AppendLine($"Blocked: {result.IsBlocked}");
+ builder.AppendLine($"P1 success: {result.IsSuccess}");
+ builder.AppendLine($"Restore proven: {result.CleanupSucceeded}");
+
+ if (result.Identity is not null)
+ {
+ builder.AppendLine();
+ builder.AppendLine("IED IDENTITY");
+ builder.AppendLine($"Stable identity: {result.Identity.StableIdentityKey}");
+ builder.AppendLine($"Model fingerprint: {result.Identity.ModelFingerprint}");
+ builder.AppendLine($"Model: {result.Identity.Model}");
+ builder.AppendLine($"Firmware: {result.Identity.FirmwareRevision}");
+ builder.AppendLine($"Profile revision: {result.Identity.ProfileRevision}");
+ }
+
+ builder.AppendLine();
+ builder.AppendLine("P1 TARGET");
+ builder.AppendLine($"Input profile state: {result.InputProfile?.State.ToString() ?? "-"}");
+ builder.AppendLine($"URCB: {P1TextOrDash(result.RcbReference)}");
+ builder.AppendLine("Requested optional fields: reason-for-inclusion + data-set-name");
+ builder.AppendLine("Correct canonical OptFlds raw target: 061800");
+
+ if (result.Probe is not null)
+ {
+ builder.AppendLine();
+ builder.AppendLine("OPTFLDS READ / WRITE / RESTORE");
+ builder.AppendLine($"Original raw: {P1TextOrDash(result.Probe.OriginalRaw)}");
+ builder.AppendLine($"Requested raw: {P1TextOrDash(result.Probe.RequestedRaw)}");
+ builder.AppendLine($"Readback raw: {P1TextOrDash(result.Probe.ReadbackRaw)}");
+ builder.AppendLine($"Restore readback raw: {P1TextOrDash(result.Probe.RestoreReadbackRaw)}");
+ builder.AppendLine($"Requested semantic match: {result.Probe.RequestedComparison?.IsSemanticMatch.ToString() ?? "-"}");
+ builder.AppendLine($"Requested raw exact: {result.Probe.RequestedComparison?.IsRawExact.ToString() ?? "-"}");
+ builder.AppendLine($"Requested padding-only diff: {result.Probe.RequestedComparison?.PaddingOnlyDifference.ToString() ?? "-"}");
+ builder.AppendLine($"Restore semantic match: {result.Probe.RestoreComparison?.IsSemanticMatch.ToString() ?? "-"}");
+ builder.AppendLine($"Restore raw exact: {result.Probe.RestoreComparison?.IsRawExact.ToString() ?? "-"}");
+ builder.AppendLine($"Restore padding-only diff: {result.Probe.RestoreComparison?.PaddingOnlyDifference.ToString() ?? "-"}");
+ }
+
+ builder.AppendLine();
+ builder.AppendLine("WIRE / SAFETY EVIDENCE");
+ foreach (var line in result.EvidenceLines)
+ builder.AppendLine(line);
+
+ builder.AppendLine();
+ builder.AppendLine("SAFETY STATE");
+ builder.AppendLine("P1 writes OptFlds only on one forced-live proven-free URCB and immediately restores it.");
+ builder.AppendLine("No TrgOps, DatSet, Resv, RptEna, GI or dynamic DataSet service is used by P1.");
+ builder.AppendLine("P1 does not advance the G2 profile and production automatic dynamic reporting remains OFF.");
+ return builder.ToString();
+ }
+
+ private static string P1TextOrDash(string? value)
+ => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim();
+}
diff --git a/DynamicReportQualificationResultWindow.xaml b/DynamicReportQualificationResultWindow.xaml
index 1dc5e4ca0..da6cf2c4b 100644
--- a/DynamicReportQualificationResultWindow.xaml
+++ b/DynamicReportQualificationResultWindow.xaml
@@ -2,7 +2,7 @@
x:ClassModifier="internal"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
- Title="G2 Dynamic Reporting Qualification Evidence"
+ Title="G2 Dynamic Reporting Commissioning Evidence"
Width="980" Height="720" MinWidth="760" MinHeight="520"
WindowStartupLocation="CenterOwner"
Background="#F4F7FB"
@@ -23,12 +23,12 @@
-
+
-
+
@@ -52,7 +52,7 @@
-
diff --git a/DynamicReportQualificationResultWindow.xaml.cs b/DynamicReportQualificationResultWindow.xaml.cs
index f47817566..f7bca4a1e 100644
--- a/DynamicReportQualificationResultWindow.xaml.cs
+++ b/DynamicReportQualificationResultWindow.xaml.cs
@@ -11,21 +11,64 @@ internal DynamicReportQualificationResultWindow(DynamicReportQualificationCommis
ArgumentNullException.ThrowIfNull(result);
InitializeComponent();
+ Title = "G2.3 Dynamic Reporting Qualification Evidence";
+ HeaderText.Text = "G2.3 Dynamic Reporting Qualification";
SummaryText.Text = result.Summary;
StateText.Text = result.SavedProfile?.State.ToString()
?? result.Coordinator?.Assessment.State.ToString()
?? (result.IsBlocked ? "Blocked" : "Not qualified");
- EvidenceTextBox.Text = BuildEvidence(result);
+ EvidenceTextBox.Text = BuildG23Evidence(result);
if (result.IsSuccess && result.SavedProfile is not null)
- {
- StateBadge.Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(236, 253, 243));
- StateBadge.BorderBrush = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(171, 225, 193));
- StateText.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(6, 118, 71));
- }
+ SetPassBadge();
+ }
+
+ internal DynamicReportQualificationResultWindow(DynamicReportTriggerOptionsProbeCommissioningResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ InitializeComponent();
+
+ Title = "P0 Isolated TrgOps Micro-Probe Evidence";
+ HeaderText.Text = "P0 Isolated TrgOps Micro-Probe";
+ SummaryText.Text = result.Summary;
+ StateText.Text = result.IsSuccess
+ ? "P0 TrgOps Proven"
+ : result.IsBlocked
+ ? "Blocked"
+ : result.CleanupSucceeded
+ ? "Not proven / restored"
+ : "Restore unproven";
+ EvidenceTextBox.Text = BuildP0Evidence(result);
+
+ if (result.IsSuccess)
+ SetPassBadge();
+ }
+
+ internal DynamicReportQualificationResultWindow(DynamicReportActivationCommissioningResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ InitializeComponent();
+
+ Title = "G2.4 One-URCB InformationReport Proof Evidence";
+ HeaderText.Text = "G2.4 One-URCB InformationReport Proof";
+ SummaryText.Text = result.Summary;
+ StateText.Text = result.SavedProfile?.State.ToString()
+ ?? result.InputProfile?.State.ToString()
+ ?? (result.IsBlocked ? "Blocked" : "Not proven");
+ EvidenceTextBox.Text = BuildG24Evidence(result);
+
+ if (result.IsSuccess && result.SavedProfile is not null)
+ SetPassBadge();
}
- private static string BuildEvidence(DynamicReportQualificationCommissioningResult result)
+ private void SetPassBadge()
+ {
+ StateBadge.Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(236, 253, 243));
+ StateBadge.BorderBrush = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(171, 225, 193));
+ StateText.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(6, 118, 71));
+ }
+
+ private static string BuildG23Evidence(DynamicReportQualificationCommissioningResult result)
{
var builder = new StringBuilder();
builder.AppendLine("ARSAS G2 DYNAMIC REPORTING QUALIFICATION EVIDENCE");
@@ -95,6 +138,150 @@ private static string BuildEvidence(DynamicReportQualificationCommissioningResul
return builder.ToString();
}
+ private static string BuildP0Evidence(DynamicReportTriggerOptionsProbeCommissioningResult result)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("ARSAS P0 ISOLATED TRGOPS MICRO-PROBE EVIDENCE");
+ builder.AppendLine(new string('=', 62));
+ builder.AppendLine($"Result: {result.Summary}");
+ builder.AppendLine($"Blocked: {result.IsBlocked}");
+ builder.AppendLine($"P0 success: {result.IsSuccess}");
+ builder.AppendLine($"Restore proven: {result.CleanupSucceeded}");
+
+ if (result.Identity is not null)
+ {
+ builder.AppendLine();
+ builder.AppendLine("IED IDENTITY");
+ builder.AppendLine($"Stable identity: {result.Identity.StableIdentityKey}");
+ builder.AppendLine($"Model fingerprint: {result.Identity.ModelFingerprint}");
+ builder.AppendLine($"Model: {result.Identity.Model}");
+ builder.AppendLine($"Firmware: {result.Identity.FirmwareRevision}");
+ builder.AppendLine($"Profile revision: {result.Identity.ProfileRevision}");
+ }
+
+ builder.AppendLine();
+ builder.AppendLine("P0 TARGET");
+ builder.AppendLine($"Input profile state: {result.InputProfile?.State.ToString() ?? "-"}");
+ builder.AppendLine($"URCB: {TextOrDash(result.RcbReference)}");
+ builder.AppendLine("Requested trigger options: dchg + GI");
+ builder.AppendLine("Correct canonical TrgOps raw target: 0244");
+
+ if (result.Probe is not null)
+ {
+ builder.AppendLine();
+ builder.AppendLine("TRGOPS READ / WRITE / RESTORE");
+ builder.AppendLine($"Original raw: {TextOrDash(result.Probe.OriginalRaw)}");
+ builder.AppendLine($"Requested raw: {TextOrDash(result.Probe.RequestedRaw)}");
+ builder.AppendLine($"Readback raw: {TextOrDash(result.Probe.ReadbackRaw)}");
+ builder.AppendLine($"Restore readback raw: {TextOrDash(result.Probe.RestoreReadbackRaw)}");
+ builder.AppendLine($"Requested semantic match: {result.Probe.RequestedComparison?.IsSemanticMatch.ToString() ?? "-"}");
+ builder.AppendLine($"Requested raw exact: {result.Probe.RequestedComparison?.IsRawExact.ToString() ?? "-"}");
+ builder.AppendLine($"Requested padding-only diff: {result.Probe.RequestedComparison?.PaddingOnlyDifference.ToString() ?? "-"}");
+ builder.AppendLine($"Restore semantic match: {result.Probe.RestoreComparison?.IsSemanticMatch.ToString() ?? "-"}");
+ builder.AppendLine($"Restore raw exact: {result.Probe.RestoreComparison?.IsRawExact.ToString() ?? "-"}");
+ builder.AppendLine($"Restore padding-only diff: {result.Probe.RestoreComparison?.PaddingOnlyDifference.ToString() ?? "-"}");
+ }
+
+ builder.AppendLine();
+ builder.AppendLine("WIRE / SAFETY EVIDENCE");
+ foreach (var line in result.EvidenceLines)
+ builder.AppendLine(line);
+
+ builder.AppendLine();
+ builder.AppendLine("SAFETY STATE");
+ builder.AppendLine("P0 writes TrgOps only on one forced-live proven-free URCB and immediately restores it.");
+ builder.AppendLine("No OptFlds, DatSet, Resv, RptEna, GI or dynamic DataSet service is used by P0.");
+ builder.AppendLine("P0 does not advance the G2 profile and production automatic dynamic reporting remains OFF.");
+ return builder.ToString();
+ }
+
+ private static string BuildG24Evidence(DynamicReportActivationCommissioningResult result)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("ARSAS G2.4 ONE-URCB INFORMATIONREPORT PROOF EVIDENCE");
+ builder.AppendLine(new string('=', 68));
+ builder.AppendLine($"Result: {result.Summary}");
+ builder.AppendLine($"Blocked: {result.IsBlocked}");
+ builder.AppendLine($"G2.4 success: {result.IsSuccess}");
+ builder.AppendLine($"Cleanup proven: {result.CleanupSucceeded}");
+
+ if (result.Identity is not null)
+ {
+ builder.AppendLine();
+ builder.AppendLine("IED IDENTITY");
+ builder.AppendLine($"Stable identity: {result.Identity.StableIdentityKey}");
+ builder.AppendLine($"Model fingerprint: {result.Identity.ModelFingerprint}");
+ builder.AppendLine($"Model: {result.Identity.Model}");
+ builder.AppendLine($"Firmware: {result.Identity.FirmwareRevision}");
+ builder.AppendLine($"Profile revision: {result.Identity.ProfileRevision}");
+ }
+
+ builder.AppendLine();
+ builder.AppendLine("G2.3 INPUT ENVELOPE");
+ builder.AppendLine($"Input profile state: {result.InputProfile?.State.ToString() ?? "-"}");
+ builder.AppendLine($"Proven safe envelope: {result.InputProfile?.ProvenSafeMemberCount.ToString() ?? "-"} member(s)");
+ builder.AppendLine($"G2.4 exact members: {result.MemberReferences.Count}");
+ foreach (var member in result.MemberReferences)
+ builder.AppendLine("- " + member);
+
+ builder.AppendLine();
+ builder.AppendLine("ONE-URCB PROOF TARGET");
+ builder.AppendLine($"URCB: {TextOrDash(result.RcbReference)}");
+ builder.AppendLine($"Temporary DataSet: {TextOrDash(result.DataSetReference)}");
+
+ if (result.ActivationProof is not null)
+ {
+ builder.AppendLine();
+ builder.AppendLine("RCB ACTIVATION PROOF");
+ builder.AppendLine($"Success: {result.ActivationProof.IsSuccess}");
+ builder.AppendLine($"Fresh RCB availability: {result.ActivationProof.FreshRcbAvailabilityVerified}");
+ builder.AppendLine($"Exact DataSet readback: {result.ActivationProof.DataSetReadbackVerified}");
+ builder.AppendLine($"DatSet binding accepted: {result.ActivationProof.RcbDataSetBindingAccepted}");
+ builder.AppendLine($"RptEna accepted: {result.ActivationProof.RptEnaAccepted}");
+ builder.AppendLine($"Association healthy after activation: {result.ActivationProof.AssociationHealthyAfterActivation}");
+ builder.AppendLine($"Evidence ID: {result.ActivationProof.EvidenceId}");
+ }
+
+ if (result.InformationReportProof is not null)
+ {
+ builder.AppendLine();
+ builder.AppendLine("ACTUAL INFORMATIONREPORT PROOF");
+ builder.AppendLine($"Success: {result.InformationReportProof.IsSuccess}");
+ builder.AppendLine($"Kind: {result.InformationReportProof.Kind}");
+ builder.AppendLine($"Actual InformationReport received: {result.InformationReportProof.ActualInformationReportReceived}");
+ builder.AppendLine($"Report identity verified: {result.InformationReportProof.ReportIdentityVerified}");
+ builder.AppendLine($"Exact member mapping verified: {result.InformationReportProof.ExactMemberMappingVerified}");
+ builder.AppendLine($"Association healthy after report: {result.InformationReportProof.AssociationHealthyAfterReport}");
+ builder.AppendLine($"Report-authoritative proof points: {result.InformationReportProof.ReportAuthoritativePointCount}");
+ builder.AppendLine($"Evidence ID: {result.InformationReportProof.EvidenceId}");
+ }
+
+ if (result.SavedProfile is not null)
+ {
+ builder.AppendLine();
+ builder.AppendLine("PERSISTED PROFILE AFTER G2.4");
+ builder.AppendLine($"State: {result.SavedProfile.State}");
+ builder.AppendLine($"File: {result.ProfilePath}");
+ }
+
+ builder.AppendLine();
+ builder.AppendLine("WIRE / SAFETY EVIDENCE");
+ foreach (var line in result.EvidenceLines)
+ builder.AppendLine(line);
+
+ builder.AppendLine();
+ builder.AppendLine("SAFETY STATE");
+ builder.AppendLine("RptEna accepted != InformationReportProven");
+ builder.AppendLine("GI accepted != InformationReportProven");
+ builder.AppendLine("InformationReportProven != ProductionEligible");
+ builder.AppendLine("G2.4 uses one auxiliary URCB only and does not make production monitoring report-authoritative.");
+ builder.AppendLine("Production automatic dynamic reporting remains OFF until controlled G2.5 scale-out and all G2.6 physical regressions pass.");
+ return builder.ToString();
+ }
+
+ private static string TextOrDash(string? value)
+ => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim();
+
private void CopyEvidence_Click(object sender, RoutedEventArgs e)
{
try
diff --git a/DynamicReportQualificationUiBehavior.cs b/DynamicReportQualificationUiBehavior.cs
index de8d9f2c1..ef8e337ae 100644
--- a/DynamicReportQualificationUiBehavior.cs
+++ b/DynamicReportQualificationUiBehavior.cs
@@ -25,8 +25,8 @@ public static void Install()
private static async void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (sender is not MainWindow window ||
- e.Key != Key.Q ||
- Keyboard.Modifiers != (ModifierKeys.Control | ModifierKeys.Shift))
+ Keyboard.Modifiers != (ModifierKeys.Control | ModifierKeys.Shift) ||
+ (e.Key != Key.Q && e.Key != Key.R && e.Key != Key.T && e.Key != Key.O && e.Key != Key.C))
return;
e.Handled = true;
@@ -36,8 +36,8 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
MessageBox.Show(
window,
- "Select one IEC 61850 IED first. G2 qualification is intentionally scoped to one explicit IED at a time.",
- "G2 Dynamic Reporting Qualification",
+ "Select one IEC 61850 IED first. G2 commissioning is intentionally scoped to one explicit IED at a time.",
+ "G2 Dynamic Reporting Commissioning",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@@ -47,8 +47,8 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
MessageBox.Show(
window,
- "Another G2 qualification operation is already running.",
- "G2 Dynamic Reporting Qualification",
+ "Another G2 commissioning operation is already running.",
+ "G2 Dynamic Reporting Commissioning",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@@ -56,42 +56,24 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e)
try
{
- var selectedCount = device.Signals.Count(signal => signal.IsSelected);
- var answer = MessageBox.Show(
- window,
- $"Run G2 dynamic reporting qualification for {device.Name} ({device.EndpointText})?\n\n" +
- $"Selected signals: {selectedCount}\n\n" +
- "This is an explicit COMMISSIONING operation. ARSAS will open a separate auxiliary MMS association, validate exact Class-A scalar points by direct MMS read, then temporarily Define/GetAttributes/Delete bounded DataSets using the 1 → 4 → 8 → 16 → 32 ladder.\n\n" +
- "It will NOT enable an RCB, will NOT write RptEna/GI, will NOT change the production monitoring planner, and will stop if association continuity or cleanup is not proven.\n\n" +
- "Continue?",
- "G2 Dynamic Reporting Qualification",
- MessageBoxButton.YesNo,
- MessageBoxImage.Warning,
- MessageBoxResult.No);
- if (answer != MessageBoxResult.Yes)
- return;
-
- window.LastStatusText = $"G2 qualification: opening isolated auxiliary MMS association to {device.Name}…";
- var service = new DynamicReportQualificationCommissioningService();
- var result = await service.RunAsync(
- device,
- device.Signals.ToArray(),
- CancellationToken.None);
-
- window.LastStatusText = result.Summary;
- var evidenceWindow = new DynamicReportQualificationResultWindow(result)
- {
- Owner = window
- };
- evidenceWindow.ShowDialog();
+ if (e.Key == Key.Q)
+ await RunG23Async(window, device);
+ else if (e.Key == Key.T)
+ await RunP0TriggerProbeAsync(window, device);
+ else if (e.Key == Key.O)
+ await RunP1OptionalFieldsProbeAsync(window, device);
+ else if (e.Key == Key.C)
+ await RunG24CleanupClosureAsync(window, device);
+ else
+ await RunG24Async(window, device);
}
catch (Exception ex)
{
- window.LastStatusText = "G2 qualification failed locally; production monitoring policy was not changed.";
+ window.LastStatusText = "G2 commissioning stopped locally; production monitoring policy was not changed.";
MessageBox.Show(
window,
- "G2 qualification stopped before any production dynamic-report enablement.\n\n" + ex,
- "G2 Dynamic Reporting Qualification",
+ "G2 commissioning stopped. Production automatic dynamic reporting remains OFF.\n\n" + ex,
+ "G2 Dynamic Reporting Commissioning",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
@@ -100,4 +82,165 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e)
Interlocked.Exchange(ref _qualificationBusy, 0);
}
}
+
+ private static async Task RunG23Async(MainWindow window, Models.Iec61850MonitorDevice device)
+ {
+ var selectedCount = device.Signals.Count(signal => signal.IsSelected);
+ var answer = MessageBox.Show(
+ window,
+ $"Run G2.3 dynamic reporting qualification for {device.Name} ({device.EndpointText})?\n\n" +
+ $"Selected signals: {selectedCount}\n\n" +
+ "This is an explicit COMMISSIONING operation. ARSAS will open a separate auxiliary MMS association, validate exact Class-A scalar points by direct MMS read, then temporarily Define/GetAttributes/Delete bounded DataSets using the 1 → 4 → 8 → 16 → 32 ladder.\n\n" +
+ "It will NOT enable an RCB, will NOT write RptEna/GI, will NOT change the production monitoring planner, and will stop if association continuity or cleanup is not proven.\n\n" +
+ "Continue?",
+ "G2.3 Dynamic Reporting Qualification",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning,
+ MessageBoxResult.No);
+ if (answer != MessageBoxResult.Yes)
+ return;
+
+ window.LastStatusText = $"G2.3 qualification: opening isolated auxiliary MMS association to {device.Name}…";
+ var service = new DynamicReportQualificationCommissioningService();
+ var result = await service.RunAsync(
+ device,
+ device.Signals.ToArray(),
+ CancellationToken.None);
+
+ window.LastStatusText = result.Summary;
+ var evidenceWindow = new DynamicReportQualificationResultWindow(result)
+ {
+ Owner = window
+ };
+ evidenceWindow.ShowDialog();
+ }
+
+ private static async Task RunP0TriggerProbeAsync(MainWindow window, Models.Iec61850MonitorDevice device)
+ {
+ var answer = MessageBox.Show(
+ window,
+ $"Run P0 isolated TrgOps micro-probe for {device.Name} ({device.EndpointText})?\n\n" +
+ "ACTIVE COMMISSIONING WRITE WARNING — TrgOps ONLY\n\n" +
+ "ARSAS will open a separate auxiliary MMS association, force-read live URCB state, choose exactly ONE proven-empty/free URCB, capture its original TrgOps, then write corrected IEC dchg+GI TrgOps using the reserved-bit mapping (canonical raw target 0244).\n\n" +
+ "It will immediately read back the significant TrgOps bits, then restore the exact captured original TrgOps value in a finally path and verify the significant bits again. Raw BER differences are retained as evidence, including padding-only differences.\n\n" +
+ "This P0 action does NOT write OptFlds, DatSet, Resv, RptEna or GI, does NOT create/delete a DataSet, does NOT start a report monitor, and does NOT advance the G2 profile. Production dynamic reporting remains OFF.\n\n" +
+ "Continue?",
+ "P0 Isolated TrgOps Micro-Probe",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning,
+ MessageBoxResult.No);
+ if (answer != MessageBoxResult.Yes)
+ return;
+
+ window.LastStatusText = $"P0: opening isolated auxiliary MMS association to {device.Name} for one-URCB TrgOps micro-probe…";
+ var service = new DynamicReportTriggerOptionsProbeCommissioningService();
+ var result = await service.RunAsync(
+ device,
+ device.Signals.ToArray(),
+ CancellationToken.None);
+
+ window.LastStatusText = result.Summary;
+ var evidenceWindow = new DynamicReportQualificationResultWindow(result)
+ {
+ Owner = window
+ };
+ evidenceWindow.ShowDialog();
+ }
+
+ private static async Task RunP1OptionalFieldsProbeAsync(MainWindow window, Models.Iec61850MonitorDevice device)
+ {
+ var answer = MessageBox.Show(
+ window,
+ $"Run P1 isolated OptFlds micro-probe for {device.Name} ({device.EndpointText})?\n\n" +
+ "ACTIVE COMMISSIONING WRITE WARNING — OptFlds ONLY\n\n" +
+ "ARSAS will open a separate auxiliary MMS association, force-read live URCB state, choose exactly ONE proven-empty/free URCB, capture its original OptFlds, then temporarily request only reason-for-inclusion + data-set-name (canonical raw target 061800).\n\n" +
+ "It will immediately read back the significant OptFlds bits, then restore the exact captured original OptFlds value in a finally path and verify the significant bits again. Raw BER differences are retained separately, including padding-only differences.\n\n" +
+ "This P1 action does NOT write TrgOps, DatSet, Resv, RptEna or GI, does NOT create/delete a DataSet, does NOT start a report monitor, and does NOT advance the G2 profile. Production dynamic reporting remains OFF.\n\n" +
+ "Continue?",
+ "P1 Isolated OptFlds Micro-Probe",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning,
+ MessageBoxResult.No);
+ if (answer != MessageBoxResult.Yes)
+ return;
+
+ window.LastStatusText = $"P1: opening isolated auxiliary MMS association to {device.Name} for one-URCB OptFlds micro-probe…";
+ var service = new DynamicReportOptionalFieldsProbeCommissioningService();
+ var result = await service.RunAsync(
+ device,
+ device.Signals.ToArray(),
+ CancellationToken.None);
+
+ window.LastStatusText = result.Summary;
+ var evidenceWindow = new DynamicReportQualificationResultWindow(result)
+ {
+ Owner = window
+ };
+ evidenceWindow.ShowDialog();
+ }
+
+ private static async Task RunG24CleanupClosureAsync(MainWindow window, Models.Iec61850MonitorDevice device)
+ {
+ var answer = MessageBox.Show(
+ window,
+ $"Run G2.4-C fresh-association cleanup closure for {device.Name} ({device.EndpointText})?\n\n" +
+ "READ-ONLY PHYSICAL MERGE GATE\n\n" +
+ "ARSAS will open a NEW auxiliary MMS association and re-read the exact InformationReport-proven URCB plus the exact temporary G2.4 DataSet identity stored in the profile.\n\n" +
+ "PASS requires fresh evidence that RptEna=false, DatSet is empty, Resv=false, Owner is empty, the temporary DataSet is absent from NamedVariableList discovery and has no readable directory, and the fresh association remains healthy. Current TrgOps/OptFlds are captured as corroborating read-only evidence.\n\n" +
+ "This G2.4-C action performs ZERO MMS writes: no RptEna, Resv, DatSet, TrgOps, OptFlds, GI, DefineNamedVariableList, DeleteNamedVariableList, report monitor, profile save, or production-policy change.\n\n" +
+ "Continue?",
+ "G2.4-C Fresh Association Cleanup Closure",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Information,
+ MessageBoxResult.No);
+ if (answer != MessageBoxResult.Yes)
+ return;
+
+ window.LastStatusText = $"G2.4-C: opening fresh read-only auxiliary MMS association to {device.Name}…";
+ var service = new DynamicReportCleanupClosureCommissioningService();
+ var result = await service.RunAsync(
+ device,
+ device.Signals.ToArray(),
+ CancellationToken.None);
+
+ window.LastStatusText = result.Summary;
+ var evidenceWindow = new DynamicReportQualificationResultWindow(result)
+ {
+ Owner = window
+ };
+ evidenceWindow.ShowDialog();
+ }
+
+ private static async Task RunG24Async(MainWindow window, Models.Iec61850MonitorDevice device)
+ {
+ var answer = MessageBox.Show(
+ window,
+ $"Run G2.4 one-URCB InformationReport proof for {device.Name} ({device.EndpointText})?\n\n" +
+ "ACTIVE COMMISSIONING WRITE WARNING\n\n" +
+ "ARSAS will use the identity-bound G2.3 EnvelopeQualified profile, open a separate auxiliary MMS association, force-read live URCB DatSet/RptEna/Resv/Owner state, and choose exactly ONE proven-empty/free URCB.\n\n" +
+ "For that one URCB only, ARSAS will capture the original TrgOps and OptFlds values, temporarily enable dchg+GI and reason-for-inclusion+data-set-name with IEC significant-bit readback, then create ONE temporary dynamic DataSet with no more than 8 already-qualified members, bind it, reserve the URCB when supported, write RptEna=true, register report routing, request GI, and wait for an ACTUAL strictly mapped InformationReport.\n\n" +
+ "RptEna/GI acceptance alone is NOT success. After the proof attempt ARSAS will disable the URCB, restore its prior DatSet binding, delete the temporary DataSet, release reservation, then restore the captured original OptFlds and TrgOps values and verify IEC significant-bit readback. Raw BER evidence is retained separately. The profile advances only when actual report proof AND all cleanup/restore evidence pass.\n\n" +
+ "Production monitoring and automatic production dynamic reporting remain OFF/untouched.\n\n" +
+ "Continue?",
+ "G2.4 One-URCB InformationReport Proof",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning,
+ MessageBoxResult.No);
+ if (answer != MessageBoxResult.Yes)
+ return;
+
+ window.LastStatusText = $"G2.4: opening isolated auxiliary MMS association to {device.Name} for transactional one-URCB proof…";
+ var service = new DynamicReportActivationCommissioningServiceV2();
+ var result = await service.RunAsync(
+ device,
+ device.Signals.ToArray(),
+ CancellationToken.None);
+
+ window.LastStatusText = result.Summary;
+ var evidenceWindow = new DynamicReportQualificationResultWindow(result)
+ {
+ Owner = window
+ };
+ evidenceWindow.ShowDialog();
+ }
}
diff --git a/Services/DynamicReportActivationCommissioningService.cs b/Services/DynamicReportActivationCommissioningService.cs
new file mode 100644
index 000000000..679d694fc
--- /dev/null
+++ b/Services/DynamicReportActivationCommissioningService.cs
@@ -0,0 +1,955 @@
+using ArIED61850Tester.Models;
+using ArMms = AR.Iec61850.Mms;
+
+namespace ArIED61850Tester.Services;
+
+internal sealed class DynamicReportActivationCommissioningResult
+{
+ public bool IsSuccess { get; init; }
+ public bool IsBlocked { get; init; }
+ public bool CleanupSucceeded { get; init; }
+ public string Summary { get; init; } = string.Empty;
+ public ArMms.MmsDynamicReportIedIdentity? Identity { get; init; }
+ public ArMms.MmsDynamicReportQualificationProfile? InputProfile { get; init; }
+ public ArMms.MmsDynamicReportQualificationProfile? SavedProfile { get; init; }
+ public ArMms.MmsDynamicRcbActivationProof? ActivationProof { get; init; }
+ public ArMms.MmsDynamicInformationReportProof? InformationReportProof { get; init; }
+ public string RcbReference { get; init; } = string.Empty;
+ public string DataSetReference { get; init; } = string.Empty;
+ public IReadOnlyList MemberReferences { get; init; } = Array.Empty();
+ public string ProfilePath { get; init; } = string.Empty;
+ public IReadOnlyList EvidenceLines { get; init; } = Array.Empty();
+}
+
+internal sealed class DynamicReportFrameValidation
+{
+ public bool IsSuccess { get; init; }
+ public string Reason { get; init; } = string.Empty;
+ public ArMms.MmsDynamicInformationReportKind Kind { get; init; } = ArMms.MmsDynamicInformationReportKind.Unknown;
+ public int AuthoritativePointCount { get; init; }
+}
+
+///
+/// G2.4 explicit commissioning gate. This service never changes the production
+/// monitoring planner. It consumes only an identity-compatible EnvelopeQualified
+/// profile, opens an auxiliary association, claims exactly one live-verified empty
+/// URCB, creates one temporary DataSet inside the proven envelope, and advances the
+/// persisted profile only after an actual strictly mapped InformationReport is seen
+/// and cleanup is proven.
+///
+internal sealed class DynamicReportActivationCommissioningService
+{
+ internal const int MaximumG24Members = 8;
+ private static readonly TimeSpan AuxiliaryAssociationTimeout = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan InformationReportProofWindow = TimeSpan.FromSeconds(10);
+
+ private readonly DynamicReportQualificationProfileStore _profileStore;
+
+ public DynamicReportActivationCommissioningService(
+ DynamicReportQualificationProfileStore? profileStore = null)
+ {
+ _profileStore = profileStore ?? new DynamicReportQualificationProfileStore();
+ }
+
+ public async Task RunAsync(
+ Iec61850MonitorDevice device,
+ IReadOnlyList fullModelSignals,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+ ArgumentNullException.ThrowIfNull(fullModelSignals);
+
+ var evidence = new List();
+ ArMms.MmsDynamicReportIedIdentity identity;
+ try
+ {
+ identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
+ {
+ return Blocked("G2.4 identity preflight failed: " + ex.Message, evidence);
+ }
+
+ evidence.Add($"G2.4 identity stableKey={identity.StableIdentityKey}; fingerprint={identity.ModelFingerprint}; profileRevision={TextOrDash(identity.ProfileRevision)}");
+
+ var loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false);
+ evidence.Add($"G2.4 persisted profile: exists={loaded.Exists}; valid={loaded.IsValid}; reason={loaded.Reason}");
+ if (!loaded.IsValid || loaded.Profile is null)
+ {
+ return Blocked(
+ "G2.4 requires an identity-compatible persisted G2.3 EnvelopeQualified profile. Run the clean G2.3 qualification first.",
+ evidence,
+ identity,
+ loaded.FilePath);
+ }
+
+ var profile = loaded.Profile;
+ if (profile.State == ArMms.MmsDynamicReportQualificationState.InformationReportProven ||
+ profile.State == ArMms.MmsDynamicReportQualificationState.ProductionEligible)
+ {
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsBlocked = true,
+ Summary = $"The compatible profile is already {profile.State}; G2.4 will not repeat active RCB mutation or downgrade evidence.",
+ Identity = identity,
+ InputProfile = profile,
+ SavedProfile = profile,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ if (profile.State != ArMms.MmsDynamicReportQualificationState.EnvelopeQualified ||
+ profile.AcceptedEnvelope is null)
+ {
+ return Blocked(
+ $"G2.4 requires profile state EnvelopeQualified, but the compatible profile is {profile.State}. Fail closed rather than infer missing field evidence.",
+ evidence,
+ identity,
+ loaded.FilePath,
+ profile);
+ }
+
+ var qualifiedReferences = profile.AcceptedEnvelope.ExactProvenMemberReferences
+ .Take(Math.Min(MaximumG24Members, profile.ProvenSafeMemberCount))
+ .ToArray();
+ if (qualifiedReferences.Length == 0 || qualifiedReferences.Length > profile.ProvenSafeMemberCount)
+ {
+ return Blocked(
+ "The accepted envelope has no usable exact member sequence. G2.4 cannot create a DataSet from inferred or unqualified members.",
+ evidence,
+ identity,
+ loaded.FilePath,
+ profile);
+ }
+
+ evidence.Add($"G2.4 envelope gate: profileState={profile.State}; provenMembers={profile.ProvenSafeMemberCount}; commissioningMembers={qualifiedReferences.Length}; hardCeiling={MaximumG24Members}");
+ evidence.Add("G2.4 exact qualified members: " + string.Join(" | ", qualifiedReferences));
+
+ await using var auxiliary = new ArMms.MmsClientSession();
+ try
+ {
+ await auxiliary.ConnectAsync(
+ device.IpAddress,
+ device.Port,
+ AuxiliaryAssociationTimeout,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException)
+ {
+ evidence.Add($"G2.4 auxiliary association failed: {ex.GetType().Name}: {ex.Message}");
+ return Blocked(
+ "The isolated G2.4 auxiliary MMS association was not established. No RCB or dynamic DataSet mutation was attempted.",
+ evidence,
+ identity,
+ loaded.FilePath,
+ profile);
+ }
+
+ evidence.Add($"G2.4 auxiliary association ready: state={auxiliary.State}; handshake={TextOrDash(auxiliary.LastHandshakeMessage)}");
+
+ ArMms.MmsDiscoveryResult discovery;
+ try
+ {
+ discovery = await auxiliary.DiscoverAsync(
+ probeReportAttributes: true,
+ maxReportAttributeProbes: 64,
+ cancellationToken: cancellationToken,
+ readDataSetDirectories: false,
+ maxDataSetDirectoryReads: 0).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"G2.4 auxiliary discovery failed: {ex.GetType().Name}: {ex.Message}");
+ return Failed(
+ "Fresh auxiliary discovery failed before any G2.4 RCB mutation.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath);
+ }
+
+ evidence.Add($"G2.4 auxiliary discovery: {discovery.Summary}");
+
+ if (!TryResolveExactQualifiedMembers(
+ discovery.IedDirectory,
+ qualifiedReferences,
+ out var exactPoints,
+ out var exactReason))
+ {
+ evidence.Add("G2.4 exact member revalidation failed: " + exactReason);
+ return Failed(
+ "The persisted qualified envelope no longer maps exactly to the live MMS model. No RCB mutation was attempted.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath,
+ qualifiedReferences);
+ }
+
+ foreach (var point in exactPoints)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var read = await auxiliary.ReadSingleVariableAsync(point.ToObjectReference(), cancellationToken).ConfigureAwait(false);
+ evidence.Add($"G2.4 direct-read {point.MmsReference}: success={read.IsSuccess}; result={read.Message}");
+ if (!read.IsSuccess || !auxiliary.IsMmsInitiated)
+ {
+ return Failed(
+ "An exact G2.3 member failed fresh direct MMS validation. No RCB mutation was attempted.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath,
+ qualifiedReferences);
+ }
+ }
+
+ var selectedRcb = SelectQualifiedUrcb(discovery.ReportInventory, exactPoints[0].Domain, out var rcbSelectionReason);
+ evidence.Add("G2.4 URCB selection: " + rcbSelectionReason);
+ if (selectedRcb is null)
+ {
+ return Failed(
+ "No live-proven empty URCB with GI, DataSet-name reporting and a usable RptID is available for strict G2.4 proof. No RCB mutation was attempted.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath,
+ qualifiedReferences);
+ }
+
+ var dataSetName = "AR_G24_" + Guid.NewGuid().ToString("N")[..8].ToUpperInvariant();
+ var plan = ArMms.MmsReportSubscriptionPlanner.BuildDynamicPlan(
+ discovery.ReportInventory,
+ discovery.IedDirectory,
+ exactPoints.Select(point => point.UserReference),
+ preferredLogicalDevice: selectedRcb.Domain,
+ preferredRcbReference: selectedRcb.Reference,
+ dataSetName: dataSetName,
+ strictRcb: true,
+ allowUrCbFallback: true,
+ allowPollingFallback: false);
+
+ if (!ValidatePlanAgainstEnvelope(plan, selectedRcb.Reference, qualifiedReferences, out var planReason))
+ {
+ evidence.Add("G2.4 plan rejected: " + planReason);
+ return Failed(
+ "The strict one-URCB plan did not preserve the exact qualified member sequence. No RCB mutation was attempted.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath,
+ qualifiedReferences,
+ selectedRcb.Reference,
+ plan.DataSetReference);
+ }
+
+ evidence.Add($"G2.4 plan: rcb={plan.ReportControl!.Reference}; dataset={plan.DataSetReference}; members={plan.DynamicPoints.Count}; mode={plan.Mode}; status={plan.Status}");
+
+ ArMms.MmsRcbAvailabilityResult freshAvailability;
+ try
+ {
+ freshAvailability = await auxiliary.CheckReportControlAvailabilityAsync(
+ discovery.ReportInventory,
+ discovery.IedDirectory,
+ new ArMms.MmsRcbAvailabilityOptions
+ {
+ MaxReportControls = 64,
+ ReadDataSetDirectories = false
+ },
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"G2.4 fresh RCB revalidation failed: {ex.GetType().Name}: {ex.Message}");
+ return Failed(
+ "Fresh URCB availability could not be re-read immediately before mutation. G2.4 stopped without claiming the RCB.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath,
+ qualifiedReferences,
+ selectedRcb.Reference,
+ plan.DataSetReference);
+ }
+
+ var freshRcb = freshAvailability.ReportControls.FirstOrDefault(snapshot => SameReference(snapshot.Reference, selectedRcb.Reference));
+ if (!IsFreshUrcbSafeForG24(freshRcb, out var freshReason))
+ {
+ evidence.Add("G2.4 fresh URCB rejected: " + freshReason);
+ return Failed(
+ "The selected URCB was not still proven free at the final pre-mutation check. G2.4 stopped without claiming it.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath,
+ qualifiedReferences,
+ selectedRcb.Reference,
+ plan.DataSetReference);
+ }
+
+ ApplyFreshSnapshot(plan.ReportControl!, freshRcb!);
+ evidence.Add($"G2.4 fresh URCB PASS: {freshRcb!.Reference}; RptEna={TextOrDash(freshRcb.EnabledState)}; Resv={TextOrDash(freshRcb.ReservationState)}; DatSet={TextOrDash(freshRcb.DataSetReference)}; RptID={TextOrDash(freshRcb.ReportId)}; TrgOps={TextOrDash(freshRcb.TriggerOptions)}; OptFlds={TextOrDash(freshRcb.OptionalFields)}");
+
+ ArMms.MmsPersistentReportMonitorAttemptResult attempt;
+ try
+ {
+ attempt = await auxiliary.StartPersistentReportMonitorWithAttemptEvidenceAsync(
+ plan,
+ triggerGeneralInterrogation: true,
+ deleteDynamicDataSetOnStop: true,
+ directory: discovery.IedDirectory,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"G2.4 activation exception: {ex.GetType().Name}: {ex.Message}");
+ return Failed(
+ "G2.4 activation threw before a persistent monitor session was returned. The profile was not advanced; inspect the IED from a fresh association before retrying.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath,
+ qualifiedReferences,
+ selectedRcb.Reference,
+ plan.DataSetReference);
+ }
+
+ AppendWriteSteps(evidence, "G2.4 activation", attempt.StartResult.WriteSteps);
+ foreach (var warning in attempt.StartResult.Warnings)
+ evidence.Add("G2.4 activation warning: " + warning);
+ if (!attempt.IsSuccess || attempt.StartResult.Session is null)
+ {
+ AppendWriteSteps(evidence, "G2.4 failed-start cleanup", attempt.CleanupSteps);
+ foreach (var warning in attempt.CleanupWarnings)
+ evidence.Add("G2.4 cleanup warning: " + warning);
+ evidence.Add($"G2.4 activation failed: reason={attempt.FailureReason}; dynamicAttempted={attempt.DynamicAttempted}; cleanupAttempted={attempt.CleanupAttempted}; cleanupSucceeded={attempt.CleanupSucceeded}; sessionState={auxiliary.State}; message={attempt.StartResult.Message}");
+
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsSuccess = false,
+ CleanupSucceeded = attempt.CleanupSucceeded,
+ Summary = attempt.CleanupSucceeded
+ ? "G2.4 one-URCB activation did not complete. Failed-start rollback was proven; the persisted profile remains EnvelopeQualified."
+ : "G2.4 activation failed and rollback was not fully proven. Do not retry on the same association; inspect the IED from a fresh commissioning association.",
+ Identity = identity,
+ InputProfile = profile,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ var session = attempt.StartResult.Session;
+ ArMms.MmsDynamicRcbActivationProof? activationProof = null;
+ ArMms.MmsDynamicInformationReportProof? informationProof = null;
+ var proofException = string.Empty;
+ var cleanupSucceeded = false;
+ var cleanupMessage = string.Empty;
+
+ try
+ {
+ var readback = await auxiliary.GetDataSetDirectoryAsync(
+ plan.DataSetReference,
+ discovery.IedDirectory,
+ cancellationToken).ConfigureAwait(false);
+ var exactReadback = readback.IsSuccess && ExactSequenceEquals(
+ qualifiedReferences,
+ readback.Members.Select(member => member.MmsReference));
+ evidence.Add($"G2.4 DataSet readback: success={readback.IsSuccess}; exact={exactReadback}; members={readback.Members.Count}; deletable={readback.IsDeletable?.ToString().ToLowerInvariant() ?? "unknown"}; result={readback.Message}");
+ evidence.Add("G2.4 DataSet readback members: " + string.Join(" | ", readback.Members.Select(member => member.MmsReference)));
+
+ var afterEnable = attempt.StartResult.RcbSnapshots.LastOrDefault(snapshot =>
+ snapshot.Stage.Equals("after-enable", StringComparison.OrdinalIgnoreCase));
+ var bindingAccepted = SuccessfulStep(attempt.StartResult.WriteSteps, "DatSet") &&
+ afterEnable is not null &&
+ afterEnable.IsSuccess &&
+ SameReference(afterEnable.DataSetReference, plan.DataSetReference);
+ var rptEnaAccepted = SuccessfulStep(attempt.StartResult.WriteSteps, "RptEna") &&
+ afterEnable is not null &&
+ afterEnable.IsSuccess &&
+ ParseBool(afterEnable.EnabledState) == true;
+
+ activationProof = new ArMms.MmsDynamicRcbActivationProof
+ {
+ EvidenceId = $"arsas-g2.4-activation-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}",
+ ObservedAtUtc = DateTimeOffset.UtcNow,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ FreshRcbAvailabilityVerified = true,
+ DataSetReadbackVerified = exactReadback,
+ RcbDataSetBindingAccepted = bindingAccepted,
+ RptEnaAccepted = rptEnaAccepted,
+ AssociationHealthyAfterActivation = auxiliary.IsMmsInitiated
+ };
+ evidence.Add($"G2.4 activation proof: success={activationProof.IsSuccess}; freshRcb={activationProof.FreshRcbAvailabilityVerified}; datasetReadback={activationProof.DataSetReadbackVerified}; binding={activationProof.RcbDataSetBindingAccepted}; rptEna={activationProof.RptEnaAccepted}; associationHealthy={activationProof.AssociationHealthyAfterActivation}");
+
+ if (activationProof.IsSuccess)
+ {
+ var receive = await auxiliary.ReceivePersistentReportMonitorSliceAsync(
+ session,
+ InformationReportProofWindow,
+ pollDirectory: null,
+ pollReferences: null,
+ pollInterval: null,
+ triggerGeneralInterrogation: true,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ AppendWriteSteps(evidence, "G2.4 receive", receive.WriteSteps);
+ evidence.Add($"G2.4 receive: reports={receive.Reports.Count}; unrouted={auxiliary.UnroutedPersistentReportCount}; route={TextOrDash(auxiliary.LastReceiveRoutingSummary)}; result={receive.Message}");
+
+ DynamicReportFrameValidation? acceptedValidation = null;
+ ArMms.MmsReportFrame? acceptedFrame = null;
+ foreach (var frame in receive.Reports)
+ {
+ var validation = ValidateInformationReportFrame(
+ frame,
+ selectedRcb.ReportId,
+ plan.DataSetReference,
+ qualifiedReferences);
+ evidence.Add($"G2.4 report candidate: rptId={TextOrDash(frame.Header.ReportId)}; dataset={TextOrDash(frame.Header.DataSetReference)}; decoder={frame.DecoderMode}; values={frame.Values.Count}; included=[{string.Join(",", frame.IncludedDataSetIndexes)}]; valid={validation.IsSuccess}; reason={validation.Reason}");
+ if (validation.IsSuccess)
+ {
+ acceptedValidation = validation;
+ acceptedFrame = frame;
+ break;
+ }
+ }
+
+ if (acceptedValidation is not null && acceptedFrame is not null)
+ {
+ informationProof = new ArMms.MmsDynamicInformationReportProof
+ {
+ EvidenceId = $"arsas-g2.4-report-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}",
+ ObservedAtUtc = acceptedFrame.ReceivedAt,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ Kind = acceptedValidation.Kind,
+ ActualInformationReportReceived = true,
+ ReportIdentityVerified = true,
+ ExactMemberMappingVerified = true,
+ AssociationHealthyAfterReport = auxiliary.IsMmsInitiated,
+ ReportAuthoritativePointCount = acceptedValidation.AuthoritativePointCount
+ };
+ evidence.Add($"G2.4 InformationReport proof: success={informationProof.IsSuccess}; kind={informationProof.Kind}; actual={informationProof.ActualInformationReportReceived}; identity={informationProof.ReportIdentityVerified}; exactMapping={informationProof.ExactMemberMappingVerified}; authoritativePoints={informationProof.ReportAuthoritativePointCount}; associationHealthy={informationProof.AssociationHealthyAfterReport}");
+ }
+ else
+ {
+ evidence.Add("G2.4 InformationReport proof: success=false; no received frame satisfied strict RptID + DatSet + full exact ordered member mapping requirements. RptEna/GI acceptance is not treated as report proof.");
+ }
+ }
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ proofException = $"{ex.GetType().Name}: {ex.Message}";
+ evidence.Add("G2.4 proof exception: " + proofException);
+ }
+ finally
+ {
+ try
+ {
+ var stop = await auxiliary.StopPersistentReportMonitorAsync(session, CancellationToken.None).ConfigureAwait(false);
+ cleanupSucceeded = stop.IsSuccess;
+ cleanupMessage = stop.Message;
+ AppendWriteSteps(evidence, "G2.4 cleanup", stop.WriteSteps);
+ evidence.Add($"G2.4 cleanup: success={stop.IsSuccess}; sessionState={auxiliary.State}; result={stop.Message}");
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ cleanupSucceeded = false;
+ cleanupMessage = $"{ex.GetType().Name}: {ex.Message}";
+ evidence.Add("G2.4 cleanup exception: " + cleanupMessage);
+ }
+ }
+
+ if (!cleanupSucceeded)
+ {
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsSuccess = false,
+ CleanupSucceeded = false,
+ Summary = "G2.4 active proof ended without proven cleanup. The persisted profile was deliberately NOT advanced; use a fresh association to inspect RptEna/DatSet/Resv before any retry.",
+ Identity = identity,
+ InputProfile = profile,
+ ActivationProof = activationProof,
+ InformationReportProof = informationProof,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ if (activationProof?.IsSuccess != true || informationProof?.IsSuccess != true)
+ {
+ var why = !string.IsNullOrWhiteSpace(proofException)
+ ? proofException
+ : activationProof?.IsSuccess != true
+ ? "RCB activation evidence was incomplete."
+ : "No strict actual InformationReport proof was obtained.";
+ evidence.Add("G2.4 profile unchanged after safe cleanup: " + why);
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsSuccess = false,
+ CleanupSucceeded = true,
+ Summary = "G2.4 cleanup passed, but actual strict InformationReport proof did not. The persisted profile remains EnvelopeQualified and production dynamic reporting remains OFF.",
+ Identity = identity,
+ InputProfile = profile,
+ ActivationProof = activationProof,
+ InformationReportProof = informationProof,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ ArMms.MmsDynamicReportQualificationProfile activatedProfile;
+ ArMms.MmsDynamicReportQualificationProfile finalProfile;
+ try
+ {
+ activatedProfile = ArMms.MmsDynamicReportQualificationProfilePolicy.RecordRcbActivationProof(
+ profile,
+ identity,
+ activationProof);
+ finalProfile = ArMms.MmsDynamicReportQualificationProfilePolicy.RecordInformationReportProof(
+ activatedProfile,
+ identity,
+ informationProof);
+ await _profileStore.SaveAsync(finalProfile, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or IOException or UnauthorizedAccessException)
+ {
+ evidence.Add($"G2.4 profile transition/save failed: {ex.GetType().Name}: {ex.Message}");
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsSuccess = false,
+ CleanupSucceeded = true,
+ Summary = "Physical G2.4 activation/report evidence passed and cleanup passed, but the identity-bound profile transition could not be persisted. Production dynamic reporting remains OFF.",
+ Identity = identity,
+ InputProfile = profile,
+ ActivationProof = activationProof,
+ InformationReportProof = informationProof,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ evidence.Add($"G2.4 profile saved: state={finalProfile.State}; rcb={finalProfile.RcbActivationProof?.RcbReference}; dataset={finalProfile.RcbActivationProof?.DataSetReference}; members={finalProfile.RcbActivationProof?.MemberReferences.Count}; path={loaded.FilePath}");
+ evidence.Add("G2.4 safety: InformationReportProven is NOT ProductionEligible. Production automatic dynamic reporting remains OFF until G2.5 scale-out and G2.6 regressions pass.");
+
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsSuccess = true,
+ CleanupSucceeded = true,
+ Summary = $"G2.4 PASS: one fresh URCB delivered an actual strictly mapped InformationReport for {qualifiedReferences.Length} qualified member(s), cleanup passed, and the identity-bound profile advanced to {finalProfile.State}. Production automatic dynamic reporting remains OFF.",
+ Identity = identity,
+ InputProfile = profile,
+ SavedProfile = finalProfile,
+ ActivationProof = activationProof,
+ InformationReportProof = informationProof,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ internal static bool TryResolveExactQualifiedMembers(
+ ArMms.MmsIedModelDirectory directory,
+ IReadOnlyList qualifiedReferences,
+ out IReadOnlyList points,
+ out string reason)
+ {
+ ArgumentNullException.ThrowIfNull(directory);
+ ArgumentNullException.ThrowIfNull(qualifiedReferences);
+ var resolved = new List();
+
+ foreach (var reference in qualifiedReferences)
+ {
+ if (string.IsNullOrWhiteSpace(reference) || !directory.TryFindByMmsReference(reference, out var point))
+ {
+ points = Array.Empty();
+ reason = $"Exact live MMS reference not found: {reference}";
+ return false;
+ }
+ if (point.IsControlAttribute || point.IsReportAttribute ||
+ !(point.FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase) ||
+ point.FunctionalConstraint.Equals("MX", StringComparison.OrdinalIgnoreCase)))
+ {
+ points = Array.Empty();
+ reason = $"Qualified reference no longer resolves to a safe ST/MX process point: {reference}";
+ return false;
+ }
+ if (!SameReference(point.MmsReference, reference))
+ {
+ points = Array.Empty();
+ reason = $"Live MMS normalization changed the qualified identity: expected={reference}, actual={point.MmsReference}";
+ return false;
+ }
+ resolved.Add(point);
+ }
+
+ points = resolved;
+ reason = $"Exact ordered live mapping preserved for {resolved.Count} qualified member(s).";
+ return resolved.Count == qualifiedReferences.Count;
+ }
+
+ internal static ArMms.MmsReportControlCandidate? SelectQualifiedUrcb(
+ ArMms.MmsReportInventory inventory,
+ string preferredLogicalDevice,
+ out string reason)
+ {
+ ArgumentNullException.ThrowIfNull(inventory);
+ var urcbOnly = new ArMms.MmsReportInventory();
+ foreach (var rcb in inventory.ReportControls.Where(candidate => !candidate.Buffered))
+ urcbOnly.ReportControls.Add(rcb);
+
+ var selection = ArMms.MmsRcbPoolSelector.BuildDynamicSelection(
+ urcbOnly,
+ preferredLogicalDevice,
+ preferredRcbReference: null,
+ strictRcb: false,
+ allowUrCbFallback: true,
+ allowPollingFallback: false);
+
+ var eligible = selection.Candidates
+ .Where(candidate => candidate.Availability == ArMms.MmsRcbAvailabilityKind.AvailableDynamicEmpty)
+ .Where(candidate => !candidate.IsBuffered)
+ .Select(candidate => new
+ {
+ Evaluation = candidate,
+ Rcb = urcbOnly.ReportControls.FirstOrDefault(rcb => SameReference(rcb.Reference, candidate.Reference))
+ })
+ .Where(item => item.Rcb is not null && HasStrictReportIdentityFields(item.Rcb))
+ .OrderByDescending(item => item.Evaluation.IsSameLogicalDevice)
+ .ThenByDescending(item => item.Evaluation.Score)
+ .ThenBy(item => item.Evaluation.Reference, StringComparer.OrdinalIgnoreCase)
+ .FirstOrDefault();
+
+ if (eligible?.Rcb is null)
+ {
+ var dynamicEmpty = selection.Candidates.Count(candidate =>
+ candidate.Availability == ArMms.MmsRcbAvailabilityKind.AvailableDynamicEmpty && !candidate.IsBuffered);
+ reason = $"URCB total={urcbOnly.ReportControls.Count}; live dynamic-empty={dynamicEmpty}; strictProofEligible=0. Strict proof also requires non-empty RptID, GI in current TrgOps and data-set-name in current OptFlds.";
+ return null;
+ }
+
+ reason = $"selected={eligible.Rcb.Reference}; score={eligible.Evaluation.Score}; sameLD={eligible.Evaluation.IsSameLogicalDevice}; RptID={TextOrDash(eligible.Rcb.ReportId)}; TrgOps={TextOrDash(eligible.Rcb.TriggerOptions)}; OptFlds={TextOrDash(eligible.Rcb.OptionalFields)}";
+ return eligible.Rcb;
+ }
+
+ internal static bool IsFreshUrcbSafeForG24(ArMms.MmsRcbAvailabilitySnapshot? snapshot, out string reason)
+ {
+ if (snapshot is null)
+ {
+ reason = "Selected URCB was missing from the fresh availability read.";
+ return false;
+ }
+ if (snapshot.Buffered)
+ {
+ reason = "G2.4 first proof permits URCB only; the fresh candidate is buffered.";
+ return false;
+ }
+ if (snapshot.DataSetProbeState != ArMms.MmsRcbDataSetProbeState.ReadSucceeded ||
+ !string.IsNullOrWhiteSpace(snapshot.DataSetReference))
+ {
+ reason = "Fresh live DatSet must be positively read and empty before G2.4 mutation.";
+ return false;
+ }
+ if (ParseBool(snapshot.EnabledState) != false)
+ {
+ reason = $"Fresh RptEna is not explicit false: {TextOrDash(snapshot.EnabledState)}";
+ return false;
+ }
+ if (snapshot.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase) &&
+ ParseBool(snapshot.ReservationState) != false)
+ {
+ reason = $"Fresh URCB Resv is not explicit false: {TextOrDash(snapshot.ReservationState)}";
+ return false;
+ }
+ if (ParseUnsigned(snapshot.ReservationTimeSeconds) is > 0)
+ {
+ reason = $"Fresh reservation time is positive: {snapshot.ReservationTimeSeconds}";
+ return false;
+ }
+ if (HasOwner(snapshot.Owner))
+ {
+ reason = $"Fresh URCB Owner is non-empty: {snapshot.Owner}";
+ return false;
+ }
+ if (!HasStrictReportIdentityFields(snapshot))
+ {
+ reason = "Fresh URCB no longer has strict G2.4 report identity fields (RptID + GI TrgOps + data-set-name OptFlds).";
+ return false;
+ }
+
+ reason = "Fresh live DatSet is empty, RptEna=false, reservation/Owner is free, and strict report identity fields are present.";
+ return true;
+ }
+
+ internal static bool ValidatePlanAgainstEnvelope(
+ ArMms.MmsReportSubscriptionPlan plan,
+ string expectedRcbReference,
+ IReadOnlyList qualifiedReferences,
+ out string reason)
+ {
+ ArgumentNullException.ThrowIfNull(plan);
+ ArgumentNullException.ThrowIfNull(qualifiedReferences);
+ if (!plan.IsReady || plan.ReportControl is null || plan.Mode != ArMms.MmsReportSubscriptionPlanMode.DynamicDataSet)
+ {
+ reason = "Plan is not a ready DynamicDataSet plan.";
+ return false;
+ }
+ if (plan.ReportControl.Buffered || !SameReference(plan.ReportControl.Reference, expectedRcbReference))
+ {
+ reason = $"Plan did not preserve the exact selected URCB. expected={expectedRcbReference}, actual={plan.ReportControl.Reference}, buffered={plan.ReportControl.Buffered}";
+ return false;
+ }
+ var planned = plan.DynamicPoints.Select(point => point.MmsReference).ToArray();
+ if (!ExactSequenceEquals(qualifiedReferences, planned))
+ {
+ reason = "Plan member sequence differs from the exact persisted qualified envelope.";
+ return false;
+ }
+ if (planned.Length == 0 || planned.Length > MaximumG24Members)
+ {
+ reason = $"Plan member count {planned.Length} is outside G2.4 commissioning bound 1..{MaximumG24Members}.";
+ return false;
+ }
+ if (string.IsNullOrWhiteSpace(plan.DataSetReference))
+ {
+ reason = "Plan did not produce a temporary DataSet reference.";
+ return false;
+ }
+
+ reason = "Strict one-URCB plan preserves the exact ordered qualified envelope.";
+ return true;
+ }
+
+ internal static DynamicReportFrameValidation ValidateInformationReportFrame(
+ ArMms.MmsReportFrame frame,
+ string expectedReportId,
+ string expectedDataSetReference,
+ IReadOnlyList qualifiedReferences)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ ArgumentNullException.ThrowIfNull(qualifiedReferences);
+
+ if (frame.DecoderMode.Equals("rejected-unmapped", StringComparison.OrdinalIgnoreCase))
+ return InvalidFrame("Report decoder quarantined the frame as unmapped.");
+ if (string.IsNullOrWhiteSpace(expectedReportId) ||
+ !frame.Header.ReportId.Trim().Equals(expectedReportId.Trim(), StringComparison.OrdinalIgnoreCase))
+ {
+ return InvalidFrame($"RptID mismatch. expected={TextOrDash(expectedReportId)}, actual={TextOrDash(frame.Header.ReportId)}");
+ }
+ if (string.IsNullOrWhiteSpace(frame.Header.DataSetReference) ||
+ !SameReference(frame.Header.DataSetReference, expectedDataSetReference))
+ {
+ return InvalidFrame($"DatSet identity mismatch. expected={expectedDataSetReference}, actual={TextOrDash(frame.Header.DataSetReference)}");
+ }
+ if (qualifiedReferences.Count == 0 || frame.Values.Count != qualifiedReferences.Count)
+ {
+ return InvalidFrame($"Full exact member proof requires {qualifiedReferences.Count} values, received {frame.Values.Count}.");
+ }
+
+ var expectedIndexes = Enumerable.Range(0, qualifiedReferences.Count).ToArray();
+ if (!frame.IncludedDataSetIndexes.SequenceEqual(expectedIndexes))
+ {
+ return InvalidFrame($"Inclusion sequence is not the complete ordered DataSet. expected=[{string.Join(",", expectedIndexes)}], actual=[{string.Join(",", frame.IncludedDataSetIndexes)}]");
+ }
+
+ for (var index = 0; index < qualifiedReferences.Count; index++)
+ {
+ var value = frame.Values[index];
+ if (value.Index != index)
+ return InvalidFrame($"Mapped value index mismatch at offset {index}: DataSet index={value.Index}.");
+ if (value.Member is null || !SameReference(value.Member.MmsReference, qualifiedReferences[index]))
+ {
+ return InvalidFrame($"Mapped member mismatch at offset {index}: expected={qualifiedReferences[index]}, actual={value.Member?.MmsReference ?? ""}.");
+ }
+ if (value.Value is null || value.FailureCode.HasValue)
+ return InvalidFrame($"Mapped member {qualifiedReferences[index]} has no successful process value (failure={value.FailureCode?.ToString() ?? "none"}).");
+ if (!string.IsNullOrWhiteSpace(value.DataReference) &&
+ !SameReference(value.DataReference, qualifiedReferences[index]) &&
+ !SameReference(value.DataReference, value.Member.UserReference))
+ {
+ return InvalidFrame($"DataRef mismatch at offset {index}: expected={qualifiedReferences[index]}, actual={value.DataReference}.");
+ }
+ }
+
+ var reasons = frame.Values
+ .SelectMany(value => value.ReasonForInclusion)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ var kind = reasons.Contains("general-interrogation", StringComparer.OrdinalIgnoreCase)
+ ? ArMms.MmsDynamicInformationReportKind.GeneralInterrogation
+ : reasons.Contains("integrity", StringComparer.OrdinalIgnoreCase)
+ ? ArMms.MmsDynamicInformationReportKind.Integrity
+ : reasons.Any(reason => reason.Equals("data-change", StringComparison.OrdinalIgnoreCase) ||
+ reason.Equals("quality-change", StringComparison.OrdinalIgnoreCase) ||
+ reason.Equals("data-update", StringComparison.OrdinalIgnoreCase))
+ ? ArMms.MmsDynamicInformationReportKind.DataChange
+ : ArMms.MmsDynamicInformationReportKind.OtherVerified;
+
+ return new DynamicReportFrameValidation
+ {
+ IsSuccess = true,
+ Kind = kind,
+ AuthoritativePointCount = qualifiedReferences.Count,
+ Reason = $"Actual InformationReport identity and full ordered mapping verified for {qualifiedReferences.Count} member(s); reasons={TextOrDash(string.Join(",", reasons))}."
+ };
+ }
+
+ private static bool HasStrictReportIdentityFields(ArMms.MmsReportControlCandidate rcb)
+ {
+ if (string.IsNullOrWhiteSpace(rcb.ReportId))
+ return false;
+ var triggers = ArMms.MmsReportControlFieldCodec.DecodeTriggerOptions(rcb.TriggerOptions);
+ var fields = ArMms.MmsReportControlFieldCodec.DecodeOptionalFields(rcb.OptionalFields);
+ return triggers.GeneralInterrogation && fields.DataSetName;
+ }
+
+ private static bool HasStrictReportIdentityFields(ArMms.MmsRcbAvailabilitySnapshot rcb)
+ {
+ if (string.IsNullOrWhiteSpace(rcb.ReportId))
+ return false;
+ var triggers = ArMms.MmsReportControlFieldCodec.DecodeTriggerOptions(rcb.TriggerOptions);
+ var fields = ArMms.MmsReportControlFieldCodec.DecodeOptionalFields(rcb.OptionalFields);
+ return triggers.GeneralInterrogation && fields.DataSetName;
+ }
+
+ private static void ApplyFreshSnapshot(ArMms.MmsReportControlCandidate target, ArMms.MmsRcbAvailabilitySnapshot source)
+ {
+ target.DataSetReference = source.DataSetReference;
+ target.DataSetProbeState = source.DataSetProbeState;
+ target.DataSetProbeMessage = source.DataSetProbeMessage;
+ target.ReportId = source.ReportId;
+ target.ConfRev = source.ConfRev;
+ target.BufferTimeMs = source.BufferTimeMs;
+ target.IntegrityPeriodMs = source.IntegrityPeriodMs;
+ target.TriggerOptions = source.TriggerOptions;
+ target.OptionalFields = source.OptionalFields;
+ target.EnabledState = source.EnabledState;
+ target.ReservationState = source.ReservationState;
+ target.ReservationTimeSeconds = source.ReservationTimeSeconds;
+ target.Owner = source.Owner;
+ target.Attributes = source.Attributes.ToList();
+ }
+
+ private static bool SuccessfulStep(IEnumerable steps, string attribute)
+ => steps.Any(step => step.Attempted && step.IsSuccess && step.Attribute.Equals(attribute, StringComparison.OrdinalIgnoreCase));
+
+ private static DynamicReportFrameValidation InvalidFrame(string reason)
+ => new() { IsSuccess = false, Reason = reason };
+
+ private static bool ExactSequenceEquals(IEnumerable expected, IEnumerable actual)
+ {
+ var left = expected.ToArray();
+ var right = actual.ToArray();
+ if (left.Length != right.Length)
+ return false;
+ for (var index = 0; index < left.Length; index++)
+ {
+ if (!SameReference(left[index], right[index]))
+ return false;
+ }
+ return true;
+ }
+
+ private static bool SameReference(string? left, string? right)
+ => NormalizeReference(left).Equals(NormalizeReference(right), StringComparison.OrdinalIgnoreCase);
+
+ private static string NormalizeReference(string? reference)
+ => (reference ?? string.Empty).Trim().Replace('$', '.');
+
+ private static bool? ParseBool(string? value)
+ {
+ var text = (value ?? string.Empty).Trim();
+ if (text.Length == 0 || text == "-")
+ return null;
+ if (bool.TryParse(text, out var parsed))
+ return parsed;
+ if (text is "1" or "01" || text.Equals("yes", StringComparison.OrdinalIgnoreCase) || text.Equals("on", StringComparison.OrdinalIgnoreCase))
+ return true;
+ if (text is "0" or "00" || text.Equals("no", StringComparison.OrdinalIgnoreCase) || text.Equals("off", StringComparison.OrdinalIgnoreCase))
+ return false;
+ return null;
+ }
+
+ private static ulong? ParseUnsigned(string? value)
+ {
+ var text = (value ?? string.Empty).Trim();
+ return ulong.TryParse(text, out var parsed) ? parsed : null;
+ }
+
+ private static bool HasOwner(string? value)
+ {
+ var text = (value ?? string.Empty).Trim();
+ if (text.Length == 0 || text == "-" || text == "[]" || text.Equals("null", StringComparison.OrdinalIgnoreCase))
+ return false;
+ var compact = text.Replace("0x", string.Empty, StringComparison.OrdinalIgnoreCase)
+ .Replace(":", string.Empty, StringComparison.Ordinal)
+ .Replace("-", string.Empty, StringComparison.Ordinal)
+ .Replace(" ", string.Empty, StringComparison.Ordinal);
+ return compact.Length > 0 && compact.Any(character => character != '0');
+ }
+
+ private static void AppendWriteSteps(
+ ICollection evidence,
+ string label,
+ IEnumerable steps)
+ {
+ foreach (var step in steps)
+ evidence.Add($"{label} write: attribute={step.Attribute}; reference={step.Reference}; attempted={step.Attempted}; success={step.IsSuccess}; result={step.Message}");
+ }
+
+ private static DynamicReportActivationCommissioningResult Blocked(
+ string summary,
+ IReadOnlyList evidence,
+ ArMms.MmsDynamicReportIedIdentity? identity = null,
+ string profilePath = "",
+ ArMms.MmsDynamicReportQualificationProfile? profile = null)
+ => new()
+ {
+ IsBlocked = true,
+ Summary = summary,
+ Identity = identity,
+ InputProfile = profile,
+ ProfilePath = profilePath,
+ EvidenceLines = evidence.ToArray()
+ };
+
+ private static DynamicReportActivationCommissioningResult Failed(
+ string summary,
+ IReadOnlyList evidence,
+ ArMms.MmsDynamicReportIedIdentity identity,
+ ArMms.MmsDynamicReportQualificationProfile profile,
+ string profilePath,
+ IReadOnlyList? memberReferences = null,
+ string rcbReference = "",
+ string dataSetReference = "")
+ => new()
+ {
+ IsSuccess = false,
+ Summary = summary,
+ Identity = identity,
+ InputProfile = profile,
+ MemberReferences = memberReferences?.ToArray() ?? Array.Empty(),
+ RcbReference = rcbReference,
+ DataSetReference = dataSetReference,
+ ProfilePath = profilePath,
+ EvidenceLines = evidence.ToArray()
+ };
+
+ private static string TextOrDash(string? value)
+ => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim();
+}
diff --git a/Services/DynamicReportActivationCommissioningServiceV2.cs b/Services/DynamicReportActivationCommissioningServiceV2.cs
new file mode 100644
index 000000000..4ad28b336
--- /dev/null
+++ b/Services/DynamicReportActivationCommissioningServiceV2.cs
@@ -0,0 +1,1034 @@
+using ArIED61850Tester.Models;
+using ArMms = AR.Iec61850.Mms;
+
+namespace ArIED61850Tester.Services;
+
+///
+/// G2.4 field-corrected commissioning coordinator.
+///
+/// The first field correction moved URCB selection from discovery-only state to a
+/// forced-live availability sweep. The second physical run then proved 30 empty/free
+/// URCBs but also proved that their current TrgOps/OptFlds do not carry GI/DataSetName.
+/// This coordinator therefore uses the engine's explicit transactional proof-field
+/// lease: capture exact originals, temporarily enable only the self-identifying proof
+/// fields, perform one-URCB report proof, then restore exact originals before profile
+/// advancement. Production planning remains untouched.
+///
+internal sealed class DynamicReportActivationCommissioningServiceV2
+{
+ private static readonly TimeSpan AuxiliaryAssociationTimeout = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan InformationReportProofWindow = TimeSpan.FromSeconds(10);
+ private const string TemporaryTriggerOptions = "dchg gi";
+ private const string TemporaryOptionalFields = "reason-for-inclusion data-set-name";
+
+ private readonly DynamicReportQualificationProfileStore _profileStore;
+
+ public DynamicReportActivationCommissioningServiceV2(
+ DynamicReportQualificationProfileStore? profileStore = null)
+ {
+ _profileStore = profileStore ?? new DynamicReportQualificationProfileStore();
+ }
+
+ public async Task RunAsync(
+ Iec61850MonitorDevice device,
+ IReadOnlyList fullModelSignals,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+ ArgumentNullException.ThrowIfNull(fullModelSignals);
+
+ var evidence = new List();
+ ArMms.MmsDynamicReportIedIdentity identity;
+ try
+ {
+ identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
+ {
+ return Blocked("G2.4 identity preflight failed: " + ex.Message, evidence);
+ }
+
+ evidence.Add($"G2.4 identity stableKey={identity.StableIdentityKey}; fingerprint={identity.ModelFingerprint}; profileRevision={TextOrDash(identity.ProfileRevision)}");
+
+ var loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false);
+ evidence.Add($"G2.4 persisted profile: exists={loaded.Exists}; valid={loaded.IsValid}; reason={loaded.Reason}");
+ if (!loaded.IsValid || loaded.Profile is null)
+ {
+ return Blocked(
+ "G2.4 requires an identity-compatible persisted G2.3 EnvelopeQualified profile.",
+ evidence,
+ identity,
+ loaded.FilePath);
+ }
+
+ var profile = loaded.Profile;
+ if (profile.State is ArMms.MmsDynamicReportQualificationState.InformationReportProven or
+ ArMms.MmsDynamicReportQualificationState.ProductionEligible)
+ {
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsBlocked = true,
+ Summary = $"The compatible profile is already {profile.State}; G2.4 will not repeat active RCB mutation or downgrade evidence.",
+ Identity = identity,
+ InputProfile = profile,
+ SavedProfile = profile,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ if (profile.State != ArMms.MmsDynamicReportQualificationState.EnvelopeQualified ||
+ profile.AcceptedEnvelope is null)
+ {
+ return Blocked(
+ $"G2.4 requires profile state EnvelopeQualified, but the compatible profile is {profile.State}.",
+ evidence,
+ identity,
+ loaded.FilePath,
+ profile);
+ }
+
+ var qualifiedReferences = profile.AcceptedEnvelope.ExactProvenMemberReferences
+ .Take(Math.Min(DynamicReportActivationCommissioningService.MaximumG24Members, profile.ProvenSafeMemberCount))
+ .ToArray();
+ if (qualifiedReferences.Length == 0 || qualifiedReferences.Length > profile.ProvenSafeMemberCount)
+ {
+ return Blocked(
+ "The accepted envelope has no usable exact member sequence.",
+ evidence,
+ identity,
+ loaded.FilePath,
+ profile);
+ }
+
+ evidence.Add($"G2.4 envelope gate: profileState={profile.State}; provenMembers={profile.ProvenSafeMemberCount}; commissioningMembers={qualifiedReferences.Length}; hardCeiling={DynamicReportActivationCommissioningService.MaximumG24Members}");
+ evidence.Add("G2.4 exact qualified members: " + string.Join(" | ", qualifiedReferences));
+
+ await using var auxiliary = new ArMms.MmsClientSession();
+ try
+ {
+ await auxiliary.ConnectAsync(
+ device.IpAddress,
+ device.Port,
+ AuxiliaryAssociationTimeout,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException)
+ {
+ evidence.Add($"G2.4 auxiliary association failed: {ex.GetType().Name}: {ex.Message}");
+ return Blocked(
+ "The isolated G2.4 auxiliary MMS association was not established. No RCB or dynamic DataSet mutation was attempted.",
+ evidence,
+ identity,
+ loaded.FilePath,
+ profile);
+ }
+
+ evidence.Add($"G2.4 auxiliary association ready: state={auxiliary.State}; localTcpAddress={TextOrDash(auxiliary.LocalTcpAddress)}; handshake={TextOrDash(auxiliary.LastHandshakeMessage)}");
+
+ ArMms.MmsDiscoveryResult discovery;
+ try
+ {
+ discovery = await auxiliary.DiscoverAsync(
+ probeReportAttributes: true,
+ maxReportAttributeProbes: 64,
+ cancellationToken: cancellationToken,
+ readDataSetDirectories: false,
+ maxDataSetDirectoryReads: 0).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"G2.4 auxiliary discovery failed: {ex.GetType().Name}: {ex.Message}");
+ return Failed("Fresh auxiliary discovery failed before any G2.4 RCB mutation.", evidence, identity, profile, loaded.FilePath);
+ }
+
+ evidence.Add($"G2.4 auxiliary discovery: {discovery.Summary}");
+
+ if (!DynamicReportActivationCommissioningService.TryResolveExactQualifiedMembers(
+ discovery.IedDirectory,
+ qualifiedReferences,
+ out var exactPoints,
+ out var exactReason))
+ {
+ evidence.Add("G2.4 exact member revalidation failed: " + exactReason);
+ return Failed(
+ "The persisted qualified envelope no longer maps exactly to the live MMS model. No RCB mutation was attempted.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath,
+ qualifiedReferences);
+ }
+
+ foreach (var point in exactPoints)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var read = await auxiliary.ReadSingleVariableAsync(point.ToObjectReference(), cancellationToken).ConfigureAwait(false);
+ evidence.Add($"G2.4 direct-read {point.MmsReference}: success={read.IsSuccess}; result={read.Message}");
+ if (!read.IsSuccess || !auxiliary.IsMmsInitiated)
+ {
+ return Failed(
+ "An exact G2.3 member failed fresh direct MMS validation. No RCB mutation was attempted.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath,
+ qualifiedReferences);
+ }
+ }
+
+ ArMms.MmsRcbAvailabilityResult selectionAvailability;
+ try
+ {
+ selectionAvailability = await auxiliary.CheckReportControlAvailabilityAsync(
+ discovery.ReportInventory,
+ discovery.IedDirectory,
+ new ArMms.MmsRcbAvailabilityOptions
+ {
+ MaxReportControls = 64,
+ ReadDataSetDirectories = false
+ },
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"G2.4 forced live URCB availability sweep failed: {ex.GetType().Name}: {ex.Message}");
+ return Failed(
+ "G2.4 could not obtain forced live DatSet/RptEna/Resv evidence. No RCB mutation was attempted.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath,
+ qualifiedReferences);
+ }
+
+ evidence.Add("G2.4 forced live availability: " + selectionAvailability.Summary);
+ foreach (var warning in selectionAvailability.Warnings)
+ evidence.Add("G2.4 availability warning: " + warning);
+
+ var selectedRcb = SelectQualifiedUrcbFromFreshAvailability(
+ selectionAvailability,
+ discovery.ReportInventory,
+ exactPoints[0].Domain,
+ out var selectedSnapshot,
+ out var rcbSelectionReason,
+ out var candidateDiagnostics);
+ evidence.Add("G2.4 URCB selection: " + rcbSelectionReason);
+ foreach (var diagnostic in candidateDiagnostics)
+ evidence.Add("G2.4 URCB candidate: " + diagnostic);
+
+ if (selectedRcb is null || selectedSnapshot is null)
+ {
+ return Failed(
+ "No forced-live proven-free URCB is eligible for a transactional G2.4 proof-field lease. No RCB mutation was attempted.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath,
+ qualifiedReferences);
+ }
+
+ ApplyFreshSnapshot(selectedRcb, selectedSnapshot);
+
+ ArMms.MmsDynamicRcbCommissioningFieldPrepareResult fieldPrepare;
+ try
+ {
+ fieldPrepare = await auxiliary.PrepareDynamicRcbCommissioningFieldsAsync(
+ selectedRcb,
+ TemporaryTriggerOptions,
+ TemporaryOptionalFields,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"G2.4 proof-field lease exception: {ex.GetType().Name}: {ex.Message}");
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsSuccess = false,
+ CleanupSucceeded = false,
+ Summary = "G2.4 could not establish the temporary TrgOps/OptFlds lease. Treat the selected URCB as requiring fresh read-only inspection before retry.",
+ Identity = identity,
+ InputProfile = profile,
+ RcbReference = selectedRcb.Reference,
+ MemberReferences = qualifiedReferences,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ AppendWriteSteps(evidence, "G2.4 proof-field prepare", fieldPrepare.WriteSteps);
+ foreach (var line in fieldPrepare.Evidence)
+ evidence.Add("G2.4 proof-field prepare: " + line);
+ evidence.Add($"G2.4 proof-field prepare result: success={fieldPrepare.IsSuccess}; rollback={fieldPrepare.CleanupSucceeded}; result={fieldPrepare.Message}");
+
+ if (!fieldPrepare.IsSuccess || fieldPrepare.Lease is null)
+ {
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsSuccess = false,
+ CleanupSucceeded = fieldPrepare.CleanupSucceeded,
+ Summary = fieldPrepare.CleanupSucceeded
+ ? "G2.4 temporary proof-field preparation failed but exact TrgOps/OptFlds rollback passed. The profile remains EnvelopeQualified."
+ : "G2.4 temporary proof-field preparation failed and exact rollback was not proven. Inspect the RCB from a fresh association before retry.",
+ Identity = identity,
+ InputProfile = profile,
+ RcbReference = selectedRcb.Reference,
+ MemberReferences = qualifiedReferences,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ var fieldLease = fieldPrepare.Lease;
+ evidence.Add($"G2.4 proof-field lease ACTIVE: rcb={selectedRcb.Reference}; originalTrgOps={fieldLease.OriginalTriggerOptionsText}; originalOptFlds={fieldLease.OriginalOptionalFieldsText}; temporaryTrgOps={TemporaryTriggerOptions}; temporaryOptFlds={TemporaryOptionalFields}");
+
+ var dataSetName = "AR_G24_" + Guid.NewGuid().ToString("N")[..8].ToUpperInvariant();
+ var plan = ArMms.MmsReportSubscriptionPlanner.BuildDynamicPlan(
+ discovery.ReportInventory,
+ discovery.IedDirectory,
+ exactPoints.Select(point => point.UserReference),
+ preferredLogicalDevice: selectedRcb.Domain,
+ preferredRcbReference: selectedRcb.Reference,
+ dataSetName: dataSetName,
+ strictRcb: true,
+ allowUrCbFallback: true,
+ allowPollingFallback: false);
+
+ if (!DynamicReportActivationCommissioningService.ValidatePlanAgainstEnvelope(
+ plan,
+ selectedRcb.Reference,
+ qualifiedReferences,
+ out var planReason))
+ {
+ evidence.Add("G2.4 plan rejected: " + planReason);
+ var fieldRestore = await RestoreProofFieldLeaseAsync(auxiliary, fieldLease, evidence, "plan-reject").ConfigureAwait(false);
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsSuccess = false,
+ CleanupSucceeded = fieldRestore,
+ Summary = fieldRestore
+ ? "The strict one-URCB plan did not preserve the exact qualified member sequence. Temporary proof fields were restored exactly."
+ : "The strict one-URCB plan was rejected and temporary proof-field restore was not fully proven.",
+ Identity = identity,
+ InputProfile = profile,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ evidence.Add($"G2.4 plan: rcb={plan.ReportControl!.Reference}; dataset={plan.DataSetReference}; members={plan.DynamicPoints.Count}; mode={plan.Mode}; status={plan.Status}");
+
+ // Re-probe exactly the chosen RCB after the temporary proof-field lease and
+ // immediately before the first DataSet/RCB production-like write. The proof-field
+ // writes can auto-reserve an Edition 2.1 URCB on this same auxiliary association,
+ // so the exact selected RCB is marked caller-owned for this one post-lease read.
+ var oneRcbInventory = new ArMms.MmsReportInventory();
+ oneRcbInventory.ReportControls.Add(selectedRcb);
+
+ ArMms.MmsRcbAvailabilityResult finalAvailability;
+ try
+ {
+ finalAvailability = await auxiliary.CheckReportControlAvailabilityAsync(
+ oneRcbInventory,
+ discovery.IedDirectory,
+ BuildPostLeaseAvailabilityOptions(selectedRcb.Reference),
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"G2.4 final URCB revalidation failed: {ex.GetType().Name}: {ex.Message}");
+ var fieldRestore = await RestoreProofFieldLeaseAsync(auxiliary, fieldLease, evidence, "final-revalidation-exception").ConfigureAwait(false);
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsSuccess = false,
+ CleanupSucceeded = fieldRestore,
+ Summary = fieldRestore
+ ? "Final URCB state could not be re-read before DataSet binding. Temporary proof fields were restored exactly."
+ : "Final URCB revalidation failed and temporary proof-field restore was not fully proven.",
+ Identity = identity,
+ InputProfile = profile,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ var freshRcb = finalAvailability.ReportControls.SingleOrDefault();
+ evidence.Add($"G2.4 post-lease ownership evidence: availability={freshRcb?.Availability}; Resv={TextOrDash(freshRcb?.ReservationState)}; Owner={TextOrDash(freshRcb?.Owner)}; localTcpAddress={TextOrDash(auxiliary.LocalTcpAddress)}");
+ if (!IsPostLeaseUrcbSafeForG24(freshRcb, auxiliary.LocalTcpAddress, out var freshReason))
+ {
+ evidence.Add("G2.4 final URCB rejected: " + freshReason);
+ var fieldRestore = await RestoreProofFieldLeaseAsync(auxiliary, fieldLease, evidence, "final-revalidation-reject").ConfigureAwait(false);
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsSuccess = false,
+ CleanupSucceeded = fieldRestore,
+ Summary = fieldRestore
+ ? "The selected URCB did not remain strict-proof eligible after temporary field configuration. Exact field restore passed."
+ : "The selected URCB failed final strict-proof revalidation and exact field restore was not fully proven.",
+ Identity = identity,
+ InputProfile = profile,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ ApplyFreshSnapshot(plan.ReportControl!, freshRcb!);
+ // The transactional engine direct write/readback is stronger than inconsistent
+ // GetNameList child advertisement. Preserve that proven writability for the
+ // monitor's defensive attribute gate.
+ EnsureAttribute(plan.ReportControl!, "TrgOps");
+ EnsureAttribute(plan.ReportControl!, "OptFlds");
+ plan.ReportControl!.TriggerOptions = TemporaryTriggerOptions;
+ plan.ReportControl.OptionalFields = TemporaryOptionalFields;
+ selectedRcb.TriggerOptions = TemporaryTriggerOptions;
+ selectedRcb.OptionalFields = TemporaryOptionalFields;
+
+ evidence.Add($"G2.4 final URCB PASS: {freshRcb!.Reference}; probe={freshRcb.DataSetProbeState}; availability={freshRcb.Availability}; RptEna={TextOrDash(freshRcb.EnabledState)}; Resv={TextOrDash(freshRcb.ReservationState)}; Owner={TextOrDash(freshRcb.Owner)}; localTcpAddress={TextOrDash(auxiliary.LocalTcpAddress)}; DatSet={TextOrDash(freshRcb.DataSetReference)}; RptID={TextOrDash(freshRcb.ReportId)}; TrgOps={TextOrDash(freshRcb.TriggerOptions)}; OptFlds={TextOrDash(freshRcb.OptionalFields)}");
+
+ ArMms.MmsPersistentReportMonitorAttemptResult attempt;
+ try
+ {
+ // Do NOT issue GI during start. First enable and register the persistent monitor;
+ // GI is sent only by the receive slice below so the first proof report cannot race
+ // ahead of report routing.
+ attempt = await auxiliary.StartPersistentReportMonitorWithAttemptEvidenceAsync(
+ plan,
+ triggerGeneralInterrogation: false,
+ deleteDynamicDataSetOnStop: true,
+ directory: discovery.IedDirectory,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"G2.4 activation exception: {ex.GetType().Name}: {ex.Message}");
+ var fieldRestore = await RestoreProofFieldLeaseAsync(auxiliary, fieldLease, evidence, "activation-exception").ConfigureAwait(false);
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsSuccess = false,
+ CleanupSucceeded = false,
+ Summary = fieldRestore
+ ? "G2.4 activation threw before a monitor session was returned. Proof fields were restored, but other dynamic mutation cleanup is not proven; inspect the IED from a fresh association."
+ : "G2.4 activation threw and proof-field restore was not fully proven. Inspect the IED from a fresh association before retry.",
+ Identity = identity,
+ InputProfile = profile,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ AppendWriteSteps(evidence, "G2.4 activation", attempt.StartResult.WriteSteps);
+ foreach (var warning in attempt.StartResult.Warnings)
+ evidence.Add("G2.4 activation warning: " + warning);
+
+ if (!attempt.IsSuccess || attempt.StartResult.Session is null)
+ {
+ AppendWriteSteps(evidence, "G2.4 failed-start cleanup", attempt.CleanupSteps);
+ foreach (var warning in attempt.CleanupWarnings)
+ evidence.Add("G2.4 cleanup warning: " + warning);
+ evidence.Add($"G2.4 activation failed: reason={attempt.FailureReason}; dynamicAttempted={attempt.DynamicAttempted}; cleanupAttempted={attempt.CleanupAttempted}; cleanupSucceeded={attempt.CleanupSucceeded}; sessionState={auxiliary.State}; message={attempt.StartResult.Message}");
+
+ var fieldRestore = await RestoreProofFieldLeaseAsync(auxiliary, fieldLease, evidence, "failed-start").ConfigureAwait(false);
+ var combinedCleanup = attempt.CleanupSucceeded && fieldRestore;
+
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsSuccess = false,
+ CleanupSucceeded = combinedCleanup,
+ Summary = combinedCleanup
+ ? "G2.4 one-URCB activation did not complete. Failed-start rollback plus exact TrgOps/OptFlds restore passed; the profile remains EnvelopeQualified."
+ : "G2.4 activation failed and complete rollback (monitor + proof fields) was not fully proven. Inspect the IED from a fresh association.",
+ Identity = identity,
+ InputProfile = profile,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ var session = attempt.StartResult.Session;
+ ArMms.MmsDynamicRcbActivationProof? activationProof = null;
+ ArMms.MmsDynamicInformationReportProof? informationProof = null;
+ var proofException = string.Empty;
+ var monitorCleanupSucceeded = false;
+ var fieldRestoreSucceeded = false;
+
+ try
+ {
+ var readback = await auxiliary.GetDataSetDirectoryAsync(
+ plan.DataSetReference,
+ discovery.IedDirectory,
+ cancellationToken).ConfigureAwait(false);
+ var exactReadback = readback.IsSuccess && ExactSequenceEquals(
+ qualifiedReferences,
+ readback.Members.Select(member => member.MmsReference));
+ evidence.Add($"G2.4 DataSet readback: success={readback.IsSuccess}; exact={exactReadback}; members={readback.Members.Count}; deletable={readback.IsDeletable?.ToString().ToLowerInvariant() ?? "unknown"}; result={readback.Message}");
+ evidence.Add("G2.4 DataSet readback members: " + string.Join(" | ", readback.Members.Select(member => member.MmsReference)));
+
+ var afterEnable = attempt.StartResult.RcbSnapshots.LastOrDefault(snapshot =>
+ snapshot.Stage.Equals("after-enable", StringComparison.OrdinalIgnoreCase));
+ var bindingAccepted = SuccessfulStep(attempt.StartResult.WriteSteps, "DatSet") &&
+ afterEnable is not null &&
+ afterEnable.IsSuccess &&
+ SameReference(afterEnable.DataSetReference, plan.DataSetReference);
+ var rptEnaAccepted = SuccessfulStep(attempt.StartResult.WriteSteps, "RptEna") &&
+ afterEnable is not null &&
+ afterEnable.IsSuccess &&
+ ParseBool(afterEnable.EnabledState) == true;
+
+ activationProof = new ArMms.MmsDynamicRcbActivationProof
+ {
+ EvidenceId = $"arsas-g2.4-activation-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}",
+ ObservedAtUtc = DateTimeOffset.UtcNow,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ FreshRcbAvailabilityVerified = true,
+ DataSetReadbackVerified = exactReadback,
+ RcbDataSetBindingAccepted = bindingAccepted,
+ RptEnaAccepted = rptEnaAccepted,
+ AssociationHealthyAfterActivation = auxiliary.IsMmsInitiated
+ };
+ evidence.Add($"G2.4 activation proof: success={activationProof.IsSuccess}; freshRcb={activationProof.FreshRcbAvailabilityVerified}; datasetReadback={activationProof.DataSetReadbackVerified}; binding={activationProof.RcbDataSetBindingAccepted}; rptEna={activationProof.RptEnaAccepted}; associationHealthy={activationProof.AssociationHealthyAfterActivation}");
+
+ if (activationProof.IsSuccess)
+ {
+ var receive = await auxiliary.ReceivePersistentReportMonitorSliceAsync(
+ session,
+ InformationReportProofWindow,
+ pollDirectory: null,
+ pollReferences: null,
+ pollInterval: null,
+ triggerGeneralInterrogation: true,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ AppendWriteSteps(evidence, "G2.4 receive", receive.WriteSteps);
+ evidence.Add($"G2.4 receive: reports={receive.Reports.Count}; unrouted={auxiliary.UnroutedPersistentReportCount}; route={TextOrDash(auxiliary.LastReceiveRoutingSummary)}; result={receive.Message}");
+
+ foreach (var frame in receive.Reports)
+ {
+ var validation = DynamicReportActivationCommissioningService.ValidateInformationReportFrame(
+ frame,
+ selectedRcb.ReportId,
+ plan.DataSetReference,
+ qualifiedReferences);
+ evidence.Add($"G2.4 report candidate: rptId={TextOrDash(frame.Header.ReportId)}; dataset={TextOrDash(frame.Header.DataSetReference)}; decoder={frame.DecoderMode}; values={frame.Values.Count}; included=[{string.Join(",", frame.IncludedDataSetIndexes)}]; valid={validation.IsSuccess}; reason={validation.Reason}");
+ if (!validation.IsSuccess)
+ continue;
+
+ informationProof = new ArMms.MmsDynamicInformationReportProof
+ {
+ EvidenceId = $"arsas-g2.4-report-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}",
+ ObservedAtUtc = frame.ReceivedAt,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ Kind = validation.Kind,
+ ActualInformationReportReceived = true,
+ ReportIdentityVerified = true,
+ ExactMemberMappingVerified = true,
+ AssociationHealthyAfterReport = auxiliary.IsMmsInitiated,
+ ReportAuthoritativePointCount = validation.AuthoritativePointCount
+ };
+ evidence.Add($"G2.4 InformationReport proof: success={informationProof.IsSuccess}; kind={informationProof.Kind}; actual={informationProof.ActualInformationReportReceived}; identity={informationProof.ReportIdentityVerified}; exactMapping={informationProof.ExactMemberMappingVerified}; authoritativePoints={informationProof.ReportAuthoritativePointCount}; associationHealthy={informationProof.AssociationHealthyAfterReport}");
+ break;
+ }
+
+ if (informationProof is null)
+ evidence.Add("G2.4 InformationReport proof: success=false; no received frame satisfied strict RptID + DatSet + full exact ordered member mapping requirements. RptEna/GI acceptance is not treated as report proof.");
+ }
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ proofException = $"{ex.GetType().Name}: {ex.Message}";
+ evidence.Add("G2.4 proof exception: " + proofException);
+ }
+ finally
+ {
+ try
+ {
+ var stop = await auxiliary.StopPersistentReportMonitorAsync(session, CancellationToken.None).ConfigureAwait(false);
+ monitorCleanupSucceeded = stop.IsSuccess;
+ AppendWriteSteps(evidence, "G2.4 monitor cleanup", stop.WriteSteps);
+ evidence.Add($"G2.4 monitor cleanup: success={stop.IsSuccess}; sessionState={auxiliary.State}; result={stop.Message}");
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ monitorCleanupSucceeded = false;
+ evidence.Add($"G2.4 monitor cleanup exception: {ex.GetType().Name}: {ex.Message}");
+ }
+
+ // Proof fields are restored only after the monitor has been stopped/disabled
+ // and DatSet/Resv cleanup has been attempted.
+ fieldRestoreSucceeded = await RestoreProofFieldLeaseAsync(
+ auxiliary,
+ fieldLease,
+ evidence,
+ "post-monitor").ConfigureAwait(false);
+ }
+
+ var cleanupSucceeded = monitorCleanupSucceeded && fieldRestoreSucceeded;
+ evidence.Add($"G2.4 combined cleanup: monitor={monitorCleanupSucceeded}; proofFields={fieldRestoreSucceeded}; success={cleanupSucceeded}");
+
+ if (!cleanupSucceeded)
+ {
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsSuccess = false,
+ CleanupSucceeded = false,
+ Summary = "G2.4 active proof ended without complete cleanup proof. Profile was deliberately NOT advanced; inspect RptEna/DatSet/Resv/TrgOps/OptFlds from a fresh association before retry.",
+ Identity = identity,
+ InputProfile = profile,
+ ActivationProof = activationProof,
+ InformationReportProof = informationProof,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ if (activationProof?.IsSuccess != true || informationProof?.IsSuccess != true)
+ {
+ var why = !string.IsNullOrWhiteSpace(proofException)
+ ? proofException
+ : activationProof?.IsSuccess != true
+ ? "RCB activation evidence was incomplete."
+ : "No strict actual InformationReport proof was obtained.";
+ evidence.Add("G2.4 profile unchanged after complete cleanup: " + why);
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsSuccess = false,
+ CleanupSucceeded = true,
+ Summary = "G2.4 complete cleanup passed, but actual strict InformationReport proof did not. The persisted profile remains EnvelopeQualified and production dynamic reporting remains OFF.",
+ Identity = identity,
+ InputProfile = profile,
+ ActivationProof = activationProof,
+ InformationReportProof = informationProof,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ ArMms.MmsDynamicReportQualificationProfile finalProfile;
+ try
+ {
+ var activatedProfile = ArMms.MmsDynamicReportQualificationProfilePolicy.RecordRcbActivationProof(
+ profile,
+ identity,
+ activationProof);
+ finalProfile = ArMms.MmsDynamicReportQualificationProfilePolicy.RecordInformationReportProof(
+ activatedProfile,
+ identity,
+ informationProof);
+ await _profileStore.SaveAsync(finalProfile, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or IOException or UnauthorizedAccessException)
+ {
+ evidence.Add($"G2.4 profile transition/save failed: {ex.GetType().Name}: {ex.Message}");
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsSuccess = false,
+ CleanupSucceeded = true,
+ Summary = "Physical G2.4 activation/report evidence passed and all cleanup passed, but the identity-bound profile transition could not be persisted. Production dynamic reporting remains OFF.",
+ Identity = identity,
+ InputProfile = profile,
+ ActivationProof = activationProof,
+ InformationReportProof = informationProof,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ evidence.Add($"G2.4 profile saved: state={finalProfile.State}; rcb={finalProfile.RcbActivationProof?.RcbReference}; dataset={finalProfile.RcbActivationProof?.DataSetReference}; members={finalProfile.RcbActivationProof?.MemberReferences.Count}; path={loaded.FilePath}");
+ evidence.Add("G2.4 safety: InformationReportProven is NOT ProductionEligible. Production automatic dynamic reporting remains OFF until G2.5 scale-out and G2.6 regressions pass.");
+
+ return new DynamicReportActivationCommissioningResult
+ {
+ IsSuccess = true,
+ CleanupSucceeded = true,
+ Summary = $"G2.4 PASS: one fresh URCB delivered an actual strictly mapped InformationReport for {qualifiedReferences.Length} qualified member(s), monitor cleanup plus exact TrgOps/OptFlds restore passed, and the identity-bound profile advanced to {finalProfile.State}. Production automatic dynamic reporting remains OFF.",
+ Identity = identity,
+ InputProfile = profile,
+ SavedProfile = finalProfile,
+ ActivationProof = activationProof,
+ InformationReportProof = informationProof,
+ RcbReference = selectedRcb.Reference,
+ DataSetReference = plan.DataSetReference,
+ MemberReferences = qualifiedReferences,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ internal static ArMms.MmsReportControlCandidate? SelectQualifiedUrcbFromFreshAvailability(
+ ArMms.MmsRcbAvailabilityResult availability,
+ ArMms.MmsReportInventory inventory,
+ string preferredLogicalDevice,
+ out ArMms.MmsRcbAvailabilitySnapshot? selectedSnapshot,
+ out string reason,
+ out IReadOnlyList diagnostics)
+ {
+ ArgumentNullException.ThrowIfNull(availability);
+ ArgumentNullException.ThrowIfNull(inventory);
+
+ var urcbSnapshots = availability.ReportControls
+ .Where(snapshot => !snapshot.Buffered)
+ .ToArray();
+ var provenEmpty = urcbSnapshots.Count(snapshot =>
+ snapshot.DataSetProbeState == ArMms.MmsRcbDataSetProbeState.ReadSucceeded &&
+ string.IsNullOrWhiteSpace(snapshot.DataSetReference));
+
+ var evaluated = urcbSnapshots
+ .Select(snapshot =>
+ {
+ var safe = IsLeaseableFreeUrcbForG24(snapshot, out var why);
+ return new { Snapshot = snapshot, Safe = safe, Why = why };
+ })
+ .ToArray();
+
+ diagnostics = evaluated
+ .OrderByDescending(item => item.Safe)
+ .ThenByDescending(item => item.Snapshot.Domain.Equals(preferredLogicalDevice, StringComparison.OrdinalIgnoreCase))
+ .ThenBy(item => item.Snapshot.Reference, StringComparer.OrdinalIgnoreCase)
+ .Take(12)
+ .Select(item =>
+ $"ref={item.Snapshot.Reference}; leaseable={item.Safe}; availability={item.Snapshot.Availability}; probe={item.Snapshot.DataSetProbeState}; DatSet={TextOrDash(item.Snapshot.DataSetReference)}; RptEna={TextOrDash(item.Snapshot.EnabledState)}; Resv={TextOrDash(item.Snapshot.ReservationState)}; Owner={TextOrDash(item.Snapshot.Owner)}; RptID={TextOrDash(item.Snapshot.ReportId)}; currentTrgOps={TextOrDash(item.Snapshot.TriggerOptions)}; currentOptFlds={TextOrDash(item.Snapshot.OptionalFields)}; reason={item.Why}")
+ .ToArray();
+
+ var selected = evaluated
+ .Where(item => item.Safe)
+ .OrderByDescending(item => item.Snapshot.Domain.Equals(preferredLogicalDevice, StringComparison.OrdinalIgnoreCase))
+ .ThenBy(item => item.Snapshot.Reference, StringComparer.OrdinalIgnoreCase)
+ .FirstOrDefault();
+
+ if (selected is null)
+ {
+ selectedSnapshot = null;
+ reason = $"URCB total={urcbSnapshots.Length}; forced-live empty DatSet={provenEmpty}; transactionalLeaseEligible=0. Selection requires a proven-empty/free URCB and usable RptID; current GI/DataSetName bits may be temporarily leased only after exact original capture.";
+ return null;
+ }
+
+ var candidate = inventory.ReportControls.FirstOrDefault(rcb => SameReference(rcb.Reference, selected.Snapshot.Reference));
+ if (candidate is null)
+ {
+ selectedSnapshot = null;
+ reason = $"Forced-live URCB {selected.Snapshot.Reference} passed lease gates but could not be mapped back to the exact discovered RCB identity.";
+ return null;
+ }
+
+ selectedSnapshot = selected.Snapshot;
+ reason = $"selected={candidate.Reference}; sameLD={candidate.Domain.Equals(preferredLogicalDevice, StringComparison.OrdinalIgnoreCase)}; forcedLiveProbe={selected.Snapshot.DataSetProbeState}; availability={selected.Snapshot.Availability}; RptID={TextOrDash(selected.Snapshot.ReportId)}; currentTrgOps={TextOrDash(selected.Snapshot.TriggerOptions)}; currentOptFlds={TextOrDash(selected.Snapshot.OptionalFields)}; transactionalProofFields=true";
+ return candidate;
+ }
+
+ internal static bool IsLeaseableFreeUrcbForG24(ArMms.MmsRcbAvailabilitySnapshot snapshot, out string reason)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+
+ if (snapshot.Buffered)
+ {
+ reason = "G2.4 first proof permits URCB only.";
+ return false;
+ }
+
+ if (snapshot.DataSetProbeState != ArMms.MmsRcbDataSetProbeState.ReadSucceeded ||
+ !string.IsNullOrWhiteSpace(snapshot.DataSetReference))
+ {
+ reason = "Live DatSet must be positively read and empty before any temporary proof-field mutation.";
+ return false;
+ }
+
+ if (ParseBool(snapshot.EnabledState) != false)
+ {
+ reason = $"RptEna is not explicit false: {TextOrDash(snapshot.EnabledState)}";
+ return false;
+ }
+
+ if (snapshot.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase) &&
+ ParseBool(snapshot.ReservationState) != false)
+ {
+ reason = $"URCB Resv is not explicit false: {TextOrDash(snapshot.ReservationState)}";
+ return false;
+ }
+
+ if (ParseUnsigned(snapshot.ReservationTimeSeconds) is > 0)
+ {
+ reason = $"Reservation time is positive: {snapshot.ReservationTimeSeconds}";
+ return false;
+ }
+
+ if (HasOwner(snapshot.Owner))
+ {
+ reason = $"URCB Owner is non-empty: {snapshot.Owner}";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(snapshot.ReportId))
+ {
+ reason = "RptID is empty, so an actual report cannot be tied to this exact URCB safely.";
+ return false;
+ }
+
+ reason = $"Live URCB is empty/free with usable RptID. Current TrgOps={TextOrDash(snapshot.TriggerOptions)} and OptFlds={TextOrDash(snapshot.OptionalFields)} may be temporarily configured only through an exact capture/write/readback/restore lease.";
+ return true;
+ }
+
+ internal static ArMms.MmsRcbAvailabilityOptions BuildPostLeaseAvailabilityOptions(string selectedRcbReference)
+ {
+ if (string.IsNullOrWhiteSpace(selectedRcbReference))
+ throw new ArgumentException("Selected RCB reference is required for caller-owned post-lease revalidation.", nameof(selectedRcbReference));
+
+ return new ArMms.MmsRcbAvailabilityOptions
+ {
+ MaxReportControls = 1,
+ ReadDataSetDirectories = false,
+ CallerOwnedRcbReferences = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ selectedRcbReference
+ }
+ };
+ }
+
+ internal static bool IsPostLeaseUrcbSafeForG24(
+ ArMms.MmsRcbAvailabilitySnapshot? snapshot,
+ string localTcpAddress,
+ out string reason)
+ {
+ if (snapshot is null)
+ {
+ reason = "Selected URCB was missing from the caller-owned post-lease availability read.";
+ return false;
+ }
+
+ if (snapshot.Buffered)
+ {
+ reason = "G2.4 first proof permits URCB only; the post-lease candidate is buffered.";
+ return false;
+ }
+
+ if (snapshot.DataSetProbeState != ArMms.MmsRcbDataSetProbeState.ReadSucceeded ||
+ !string.IsNullOrWhiteSpace(snapshot.DataSetReference))
+ {
+ reason = "Post-lease live DatSet must still be positively read and empty before DataSet activation.";
+ return false;
+ }
+
+ if (ParseBool(snapshot.EnabledState) != false)
+ {
+ reason = $"Post-lease RptEna is not explicit false: {TextOrDash(snapshot.EnabledState)}";
+ return false;
+ }
+
+ if (snapshot.Availability != ArMms.MmsRcbOperationalAvailability.UsedByCaller)
+ {
+ reason = $"Post-lease URCB ownership is not proven to belong to this G2.4 association: availability={snapshot.Availability}; Resv={TextOrDash(snapshot.ReservationState)}";
+ return false;
+ }
+
+ if (snapshot.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase) &&
+ ParseBool(snapshot.ReservationState) is null)
+ {
+ reason = $"Post-lease URCB Resv is not explicitly readable: {TextOrDash(snapshot.ReservationState)}";
+ return false;
+ }
+
+ if (ParseUnsigned(snapshot.ReservationTimeSeconds) is > 0)
+ {
+ reason = $"Post-lease reservation time is positive: {snapshot.ReservationTimeSeconds}";
+ return false;
+ }
+
+ var ownerEvidence = "Owner is empty/not exposed; exact caller-owned RCB identity remains the ownership evidence.";
+ if (HasOwner(snapshot.Owner) &&
+ !ArMms.MmsRcbOwnerIdentity.MatchesLocalTcpAddress(snapshot.Owner, localTcpAddress, out ownerEvidence))
+ {
+ reason = "Post-lease URCB Owner does not match the active G2.4 MMS association: " + ownerEvidence;
+ return false;
+ }
+
+ if (!HasStrictReportIdentityFields(snapshot))
+ {
+ reason = "Post-lease URCB no longer has strict G2.4 report identity fields (RptID + GI TrgOps + data-set-name OptFlds).";
+ return false;
+ }
+
+ reason = $"Post-lease URCB is caller-owned on this association, DatSet remains empty, RptEna=false, Resv={TextOrDash(snapshot.ReservationState)} is accepted only under UsedByCaller, {ownerEvidence}, and strict report identity fields are present.";
+ return true;
+ }
+
+ private static bool HasStrictReportIdentityFields(ArMms.MmsRcbAvailabilitySnapshot snapshot)
+ {
+ if (string.IsNullOrWhiteSpace(snapshot.ReportId))
+ return false;
+
+ var triggers = ArMms.MmsReportControlFieldCodec.DecodeTriggerOptions(snapshot.TriggerOptions);
+ var fields = ArMms.MmsReportControlFieldCodec.DecodeOptionalFields(snapshot.OptionalFields);
+ return triggers.GeneralInterrogation && fields.DataSetName;
+ }
+
+ private static void ApplyFreshSnapshot(ArMms.MmsReportControlCandidate target, ArMms.MmsRcbAvailabilitySnapshot source)
+ {
+ target.DataSetReference = source.DataSetReference;
+ target.DataSetProbeState = source.DataSetProbeState;
+ target.DataSetProbeMessage = source.DataSetProbeMessage;
+ target.ReportId = source.ReportId;
+ target.ConfRev = source.ConfRev;
+ target.BufferTimeMs = source.BufferTimeMs;
+ target.IntegrityPeriodMs = source.IntegrityPeriodMs;
+ target.TriggerOptions = source.TriggerOptions;
+ target.OptionalFields = source.OptionalFields;
+ target.EnabledState = source.EnabledState;
+ target.ReservationState = source.ReservationState;
+ target.ReservationTimeSeconds = source.ReservationTimeSeconds;
+ target.Owner = source.Owner;
+ target.Attributes = source.Attributes.ToList();
+ }
+
+ private static void EnsureAttribute(ArMms.MmsReportControlCandidate target, string attribute)
+ {
+ if (!target.Attributes.Contains(attribute, StringComparer.OrdinalIgnoreCase))
+ target.Attributes.Add(attribute);
+ }
+
+ private static async Task RestoreProofFieldLeaseAsync(
+ ArMms.MmsClientSession auxiliary,
+ ArMms.MmsDynamicRcbCommissioningFieldLease fieldLease,
+ ICollection evidence,
+ string label)
+ {
+ try
+ {
+ var restore = await auxiliary.RestoreDynamicRcbCommissioningFieldsAsync(
+ fieldLease,
+ CancellationToken.None).ConfigureAwait(false);
+ AppendWriteSteps(evidence, $"G2.4 proof-field restore/{label}", restore.WriteSteps);
+ foreach (var line in restore.Evidence)
+ evidence.Add($"G2.4 proof-field restore/{label}: {line}");
+ evidence.Add($"G2.4 proof-field restore/{label}: success={restore.IsSuccess}; result={restore.Message}");
+ return restore.IsSuccess;
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"G2.4 proof-field restore/{label} exception: {ex.GetType().Name}: {ex.Message}");
+ return false;
+ }
+ }
+
+ private static bool SuccessfulStep(IEnumerable steps, string attribute)
+ => steps.Any(step => step.Attempted && step.IsSuccess && step.Attribute.Equals(attribute, StringComparison.OrdinalIgnoreCase));
+
+ private static bool ExactSequenceEquals(IEnumerable expected, IEnumerable actual)
+ {
+ var left = expected.ToArray();
+ var right = actual.ToArray();
+ return left.Length == right.Length && left.Select(NormalizeReference).SequenceEqual(right.Select(NormalizeReference), StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static bool SameReference(string? left, string? right)
+ => NormalizeReference(left).Equals(NormalizeReference(right), StringComparison.OrdinalIgnoreCase);
+
+ private static string NormalizeReference(string? reference)
+ => (reference ?? string.Empty).Trim().Replace('$', '.');
+
+ private static bool? ParseBool(string? value)
+ {
+ var text = (value ?? string.Empty).Trim();
+ if (text.Length == 0 || text == "-")
+ return null;
+ if (bool.TryParse(text, out var parsed))
+ return parsed;
+ if (text is "1" or "01" || text.Equals("yes", StringComparison.OrdinalIgnoreCase) || text.Equals("on", StringComparison.OrdinalIgnoreCase))
+ return true;
+ if (text is "0" or "00" || text.Equals("no", StringComparison.OrdinalIgnoreCase) || text.Equals("off", StringComparison.OrdinalIgnoreCase))
+ return false;
+ return null;
+ }
+
+ private static ulong? ParseUnsigned(string? value)
+ {
+ var text = (value ?? string.Empty).Trim();
+ return ulong.TryParse(text, out var parsed) ? parsed : null;
+ }
+
+ private static bool HasOwner(string? value)
+ {
+ var text = (value ?? string.Empty).Trim();
+ if (text.Length == 0 || text == "-" || text == "[]" || text.Equals("null", StringComparison.OrdinalIgnoreCase))
+ return false;
+ var compact = text.Replace("0x", string.Empty, StringComparison.OrdinalIgnoreCase)
+ .Replace(":", string.Empty, StringComparison.Ordinal)
+ .Replace("-", string.Empty, StringComparison.Ordinal)
+ .Replace(" ", string.Empty, StringComparison.Ordinal);
+ return compact.Length > 0 && compact.Any(character => character != '0');
+ }
+
+ private static void AppendWriteSteps(
+ ICollection evidence,
+ string label,
+ IEnumerable steps)
+ {
+ foreach (var step in steps)
+ evidence.Add($"{label} write: attribute={step.Attribute}; reference={step.Reference}; attempted={step.Attempted}; success={step.IsSuccess}; result={step.Message}");
+ }
+
+ private static DynamicReportActivationCommissioningResult Blocked(
+ string summary,
+ IReadOnlyList evidence,
+ ArMms.MmsDynamicReportIedIdentity? identity = null,
+ string profilePath = "",
+ ArMms.MmsDynamicReportQualificationProfile? profile = null)
+ => new()
+ {
+ IsBlocked = true,
+ Summary = summary,
+ Identity = identity,
+ InputProfile = profile,
+ ProfilePath = profilePath,
+ EvidenceLines = evidence.ToArray()
+ };
+
+ private static DynamicReportActivationCommissioningResult Failed(
+ string summary,
+ IReadOnlyList evidence,
+ ArMms.MmsDynamicReportIedIdentity identity,
+ ArMms.MmsDynamicReportQualificationProfile profile,
+ string profilePath,
+ IReadOnlyList? memberReferences = null,
+ string rcbReference = "",
+ string dataSetReference = "")
+ => new()
+ {
+ IsSuccess = false,
+ Summary = summary,
+ Identity = identity,
+ InputProfile = profile,
+ MemberReferences = memberReferences?.ToArray() ?? Array.Empty(),
+ RcbReference = rcbReference,
+ DataSetReference = dataSetReference,
+ ProfilePath = profilePath,
+ EvidenceLines = evidence.ToArray()
+ };
+
+ private static string TextOrDash(string? value)
+ => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim();
+}
diff --git a/Services/DynamicReportCleanupClosureCommissioningService.cs b/Services/DynamicReportCleanupClosureCommissioningService.cs
new file mode 100644
index 000000000..3b154f6f1
--- /dev/null
+++ b/Services/DynamicReportCleanupClosureCommissioningService.cs
@@ -0,0 +1,469 @@
+using ArIED61850Tester.Models;
+using ArMms = AR.Iec61850.Mms;
+
+namespace ArIED61850Tester.Services;
+
+internal sealed class DynamicReportCleanupClosureCommissioningResult
+{
+ public bool IsSuccess { get; init; }
+ public bool IsBlocked { get; init; }
+ public string Summary { get; init; } = string.Empty;
+ public ArMms.MmsDynamicReportIedIdentity? Identity { get; init; }
+ public ArMms.MmsDynamicReportQualificationProfile? InputProfile { get; init; }
+ public ArMms.MmsRcbAvailabilitySnapshot? FreshRcbSnapshot { get; init; }
+ public string RcbReference { get; init; } = string.Empty;
+ public string TemporaryDataSetReference { get; init; } = string.Empty;
+ public bool TemporaryDataSetAbsentFromNameList { get; init; }
+ public bool TemporaryDataSetDirectoryAbsent { get; init; }
+ public bool AssociationHealthy { get; init; }
+ public string ProfilePath { get; init; } = string.Empty;
+ public IReadOnlyList EvidenceLines { get; init; } = Array.Empty();
+}
+
+///
+/// G2.4-C closes the only cleanup gap left after a successful one-URCB actual
+/// InformationReport proof. It opens a NEW MMS association and performs READS ONLY.
+/// No RCB attribute, DataSet, report monitor, GI, profile, or production policy is changed.
+///
+internal sealed class DynamicReportCleanupClosureCommissioningService
+{
+ private static readonly TimeSpan AuxiliaryAssociationTimeout = TimeSpan.FromSeconds(10);
+
+ private readonly DynamicReportQualificationProfileStore _profileStore;
+
+ public DynamicReportCleanupClosureCommissioningService(
+ DynamicReportQualificationProfileStore? profileStore = null)
+ {
+ _profileStore = profileStore ?? new DynamicReportQualificationProfileStore();
+ }
+
+ public async Task RunAsync(
+ Iec61850MonitorDevice device,
+ IReadOnlyList fullModelSignals,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+ ArgumentNullException.ThrowIfNull(fullModelSignals);
+
+ var evidence = new List();
+ ArMms.MmsDynamicReportIedIdentity identity;
+ try
+ {
+ identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
+ {
+ return Blocked("G2.4-C identity preflight failed: " + ex.Message, evidence);
+ }
+
+ evidence.Add($"G2.4-C identity stableKey={identity.StableIdentityKey}; fingerprint={identity.ModelFingerprint}; profileRevision={TextOrDash(identity.ProfileRevision)}");
+
+ var loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false);
+ evidence.Add($"G2.4-C persisted profile: exists={loaded.Exists}; valid={loaded.IsValid}; reason={loaded.Reason}");
+ if (!loaded.IsValid || loaded.Profile is null)
+ {
+ return Blocked(
+ "G2.4-C requires the identity-compatible profile produced by the successful G2.4 InformationReport proof.",
+ evidence,
+ identity,
+ loaded.FilePath);
+ }
+
+ var profile = loaded.Profile;
+ if (profile.State < ArMms.MmsDynamicReportQualificationState.InformationReportProven ||
+ profile.RcbActivationProof?.IsSuccess != true ||
+ profile.InformationReportProof?.IsSuccess != true)
+ {
+ return Blocked(
+ $"G2.4-C requires successful InformationReportProven evidence; current profile state is {profile.State}.",
+ evidence,
+ identity,
+ loaded.FilePath,
+ profile);
+ }
+
+ var rcbReference = profile.RcbActivationProof.RcbReference;
+ var temporaryDataSetReference = profile.RcbActivationProof.DataSetReference;
+ if (string.IsNullOrWhiteSpace(rcbReference) || string.IsNullOrWhiteSpace(temporaryDataSetReference))
+ {
+ return Blocked(
+ "G2.4-C profile is missing the exact RCB or temporary DataSet identity from G2.4.",
+ evidence,
+ identity,
+ loaded.FilePath,
+ profile);
+ }
+
+ evidence.Add($"G2.4-C target: profileState={profile.State}; rcb={rcbReference}; temporaryDataSet={temporaryDataSetReference}; members={profile.RcbActivationProof.MemberReferences.Count}");
+ evidence.Add("G2.4-C contract: fresh association + read-only RCB/DataSet inspection only; zero MMS Write, DefineNamedVariableList, DeleteNamedVariableList, RptEna, Resv, DatSet, TrgOps, OptFlds or GI mutation.");
+
+ await using var auxiliary = new ArMms.MmsClientSession();
+ try
+ {
+ await auxiliary.ConnectAsync(
+ device.IpAddress,
+ device.Port,
+ AuxiliaryAssociationTimeout,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException)
+ {
+ evidence.Add($"G2.4-C fresh association failed: {ex.GetType().Name}: {ex.Message}");
+ return Failed(
+ "G2.4-C could not establish the fresh read-only MMS association.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath,
+ rcbReference,
+ temporaryDataSetReference);
+ }
+
+ evidence.Add($"G2.4-C fresh association ready: state={auxiliary.State}; localTcpAddress={TextOrDash(auxiliary.LocalTcpAddress)}; handshake={TextOrDash(auxiliary.LastHandshakeMessage)}");
+
+ ArMms.MmsDiscoveryResult discovery;
+ try
+ {
+ discovery = await auxiliary.DiscoverAsync(
+ probeReportAttributes: true,
+ maxReportAttributeProbes: 64,
+ cancellationToken: cancellationToken,
+ readDataSetDirectories: false,
+ maxDataSetDirectoryReads: 0).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"G2.4-C fresh discovery failed: {ex.GetType().Name}: {ex.Message}");
+ return Failed(
+ "G2.4-C fresh discovery failed. No mutation was attempted.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath,
+ rcbReference,
+ temporaryDataSetReference);
+ }
+
+ evidence.Add("G2.4-C discovery: " + discovery.Summary);
+
+ var exactRcb = discovery.ReportInventory.ReportControls.FirstOrDefault(candidate =>
+ SameReference(candidate.Reference, rcbReference));
+ if (exactRcb is null)
+ {
+ evidence.Add("G2.4-C exact RCB lookup failed: the InformationReport-proven RCB is absent from fresh discovery.");
+ return Failed(
+ "G2.4-C could not re-identify the exact InformationReport-proven URCB on the fresh association.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath,
+ rcbReference,
+ temporaryDataSetReference);
+ }
+
+ var oneRcbInventory = new ArMms.MmsReportInventory();
+ oneRcbInventory.ReportControls.Add(exactRcb);
+
+ ArMms.MmsRcbAvailabilityResult availability;
+ try
+ {
+ availability = await auxiliary.CheckReportControlAvailabilityAsync(
+ oneRcbInventory,
+ discovery.IedDirectory,
+ new ArMms.MmsRcbAvailabilityOptions
+ {
+ MaxReportControls = 1,
+ ReadDataSetDirectories = false
+ },
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"G2.4-C exact RCB read failed: {ex.GetType().Name}: {ex.Message}");
+ return Failed(
+ "G2.4-C could not obtain fresh read-only RCB state.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath,
+ rcbReference,
+ temporaryDataSetReference);
+ }
+
+ var snapshot = availability.ReportControls.SingleOrDefault();
+ evidence.Add("G2.4-C fresh RCB availability: " + availability.Summary);
+ if (snapshot is not null)
+ {
+ evidence.Add(
+ $"G2.4-C fresh RCB: ref={snapshot.Reference}; availability={snapshot.Availability}; probe={snapshot.DataSetProbeState}; " +
+ $"DatSet={TextOrDash(snapshot.DataSetReference)}; RptEna={TextOrDash(snapshot.EnabledState)}; Resv={TextOrDash(snapshot.ReservationState)}; " +
+ $"Owner={TextOrDash(snapshot.Owner)}; ResvTms={TextOrDash(snapshot.ReservationTimeSeconds)}; " +
+ $"TrgOps={TextOrDash(snapshot.TriggerOptions)}; OptFlds={TextOrDash(snapshot.OptionalFields)}; RptID={TextOrDash(snapshot.ReportId)}");
+ }
+
+ var temporaryNameAbsent = IsTemporaryDataSetAbsentFromNameList(
+ discovery.Snapshot,
+ temporaryDataSetReference,
+ out var nameListReason);
+ evidence.Add("G2.4-C temporary DataSet namespace: " + nameListReason);
+
+ ArMms.MmsDataSetDirectoryResult dataSetDirectory;
+ try
+ {
+ dataSetDirectory = await auxiliary.GetDataSetDirectoryAsync(
+ temporaryDataSetReference,
+ discovery.IedDirectory,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"G2.4-C temporary DataSet direct read exception: {ex.GetType().Name}: {ex.Message}");
+ return Failed(
+ "G2.4-C temporary DataSet absence could not be proven because the direct read ended in a transport/protocol exception.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath,
+ rcbReference,
+ temporaryDataSetReference,
+ snapshot,
+ temporaryNameAbsent,
+ false,
+ auxiliary.IsMmsInitiated);
+ }
+
+ var directoryAbsent = !dataSetDirectory.IsSuccess;
+ evidence.Add($"G2.4-C temporary DataSet direct directory: absent={directoryAbsent}; success={dataSetDirectory.IsSuccess}; members={dataSetDirectory.Members.Count}; result={dataSetDirectory.Message}");
+
+ var associationHealthy = auxiliary.IsMmsInitiated;
+ var closureSafe = IsFreshCleanupClosed(
+ snapshot,
+ temporaryNameAbsent,
+ directoryAbsent,
+ associationHealthy,
+ out var closureReason);
+ evidence.Add("G2.4-C closure evaluation: " + closureReason);
+ evidence.Add($"G2.4-C result: success={closureSafe}; associationHealthy={associationHealthy}; temporaryNameAbsent={temporaryNameAbsent}; temporaryDirectoryAbsent={directoryAbsent}");
+ evidence.Add("G2.4-C safety: READ ONLY. The persisted InformationReportProven profile is not modified and production automatic dynamic reporting remains OFF.");
+
+ return new DynamicReportCleanupClosureCommissioningResult
+ {
+ IsSuccess = closureSafe,
+ Summary = closureSafe
+ ? "G2.4-C PASS: a fresh read-only MMS association proved the InformationReport-proven URCB released (RptEna=false, DatSet empty, Resv=false, Owner empty), the temporary G2.4 DataSet is absent, and the association remained healthy. G2.4 cleanup is closed; production dynamic reporting remains OFF."
+ : "G2.4-C did not prove complete fresh-association cleanup closure. No mutation was attempted; production dynamic reporting remains OFF.",
+ Identity = identity,
+ InputProfile = profile,
+ FreshRcbSnapshot = snapshot,
+ RcbReference = rcbReference,
+ TemporaryDataSetReference = temporaryDataSetReference,
+ TemporaryDataSetAbsentFromNameList = temporaryNameAbsent,
+ TemporaryDataSetDirectoryAbsent = directoryAbsent,
+ AssociationHealthy = associationHealthy,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ internal static bool IsFreshCleanupClosed(
+ ArMms.MmsRcbAvailabilitySnapshot? snapshot,
+ bool temporaryDataSetAbsentFromNameList,
+ bool temporaryDataSetDirectoryAbsent,
+ bool associationHealthy,
+ out string reason)
+ {
+ if (snapshot is null)
+ {
+ reason = "Exact proven URCB snapshot is missing.";
+ return false;
+ }
+
+ if (snapshot.Buffered)
+ {
+ reason = "G2.4-C expected the proven URCB, but the fresh snapshot is buffered.";
+ return false;
+ }
+
+ if (snapshot.DataSetProbeState != ArMms.MmsRcbDataSetProbeState.ReadSucceeded ||
+ !string.IsNullOrWhiteSpace(snapshot.DataSetReference))
+ {
+ reason = $"DatSet is not positively proven empty: probe={snapshot.DataSetProbeState}; DatSet={TextOrDash(snapshot.DataSetReference)}.";
+ return false;
+ }
+
+ if (ParseBool(snapshot.EnabledState) != false)
+ {
+ reason = $"RptEna is not explicit false: {TextOrDash(snapshot.EnabledState)}.";
+ return false;
+ }
+
+ if (!snapshot.Attributes.Contains("Resv", StringComparer.OrdinalIgnoreCase) ||
+ ParseBool(snapshot.ReservationState) != false)
+ {
+ reason = $"Fresh-association URCB reservation release is not explicit: Resv={TextOrDash(snapshot.ReservationState)}.";
+ return false;
+ }
+
+ if (HasOwner(snapshot.Owner))
+ {
+ reason = $"Fresh-association URCB Owner is still non-empty: {snapshot.Owner}.";
+ return false;
+ }
+
+ if (ParseUnsigned(snapshot.ReservationTimeSeconds) is > 0)
+ {
+ reason = $"Fresh-association reservation time is still positive: {snapshot.ReservationTimeSeconds}.";
+ return false;
+ }
+
+ if (!temporaryDataSetAbsentFromNameList)
+ {
+ reason = "Temporary G2.4 DataSet is still advertised by fresh NamedVariableList discovery.";
+ return false;
+ }
+
+ if (!temporaryDataSetDirectoryAbsent)
+ {
+ reason = "Temporary G2.4 DataSet still has a readable directory on the fresh association.";
+ return false;
+ }
+
+ if (!associationHealthy)
+ {
+ reason = "Fresh MMS association is not healthy after all read-only closure checks.";
+ return false;
+ }
+
+ reason = $"Fresh URCB cleanup closed: DatSet empty, RptEna=false, Resv=false, Owner empty, temporary DataSet absent by namespace + direct directory, association healthy. Current read-only proof fields: TrgOps={TextOrDash(snapshot.TriggerOptions)}, OptFlds={TextOrDash(snapshot.OptionalFields)}.";
+ return true;
+ }
+
+ internal static bool IsTemporaryDataSetAbsentFromNameList(
+ ArMms.MmsDiscoverySnapshot snapshot,
+ string temporaryDataSetReference,
+ out string reason)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ if (!TryParseDataSetIdentity(temporaryDataSetReference, out var domain, out var itemName))
+ {
+ reason = $"Temporary DataSet reference could not be parsed: {TextOrDash(temporaryDataSetReference)}.";
+ return false;
+ }
+
+ if (!snapshot.DomainVariableLists.TryGetValue(domain, out var names))
+ {
+ reason = $"Fresh NamedVariableList discovery has no domain entry for {domain}; absence cannot be proven.";
+ return false;
+ }
+
+ var present = names.Any(name => NormalizeDataSetItem(name).Equals(itemName, StringComparison.OrdinalIgnoreCase));
+ reason = present
+ ? $"Temporary DataSet is still advertised: domain={domain}; item={itemName}."
+ : $"Temporary DataSet is absent from fresh NamedVariableList discovery: domain={domain}; item={itemName}; advertisedLists={names.Count}.";
+ return !present;
+ }
+
+ private static bool TryParseDataSetIdentity(string? reference, out string domain, out string itemName)
+ {
+ domain = string.Empty;
+ itemName = string.Empty;
+ var text = (reference ?? string.Empty).Trim();
+ var slash = text.IndexOf('/');
+ if (slash <= 0 || slash >= text.Length - 1)
+ return false;
+
+ domain = text[..slash];
+ itemName = NormalizeDataSetItem(text[(slash + 1)..]);
+ if (!itemName.Contains('$', StringComparison.Ordinal))
+ itemName = "LLN0$" + itemName;
+ return domain.Length > 0 && itemName.Length > 0;
+ }
+
+ private static string NormalizeDataSetItem(string? value)
+ => (value ?? string.Empty).Trim().Replace('.', '$');
+
+ private static bool SameReference(string? left, string? right)
+ => NormalizeReference(left).Equals(NormalizeReference(right), StringComparison.OrdinalIgnoreCase);
+
+ private static string NormalizeReference(string? reference)
+ => (reference ?? string.Empty).Trim().Replace('$', '.');
+
+ private static bool? ParseBool(string? value)
+ {
+ var text = (value ?? string.Empty).Trim();
+ if (text.Length == 0 || text == "-")
+ return null;
+ if (bool.TryParse(text, out var parsed))
+ return parsed;
+ if (text is "1" or "01" || text.Equals("yes", StringComparison.OrdinalIgnoreCase) || text.Equals("on", StringComparison.OrdinalIgnoreCase))
+ return true;
+ if (text is "0" or "00" || text.Equals("no", StringComparison.OrdinalIgnoreCase) || text.Equals("off", StringComparison.OrdinalIgnoreCase))
+ return false;
+ return null;
+ }
+
+ private static ulong? ParseUnsigned(string? value)
+ {
+ var text = (value ?? string.Empty).Trim();
+ return ulong.TryParse(text, out var parsed) ? parsed : null;
+ }
+
+ private static bool HasOwner(string? value)
+ {
+ var text = (value ?? string.Empty).Trim();
+ if (text.Length == 0 || text == "-" || text == "[]" || text.Equals("null", StringComparison.OrdinalIgnoreCase))
+ return false;
+ var compact = text.Replace("0x", string.Empty, StringComparison.OrdinalIgnoreCase)
+ .Replace(":", string.Empty, StringComparison.Ordinal)
+ .Replace("-", string.Empty, StringComparison.Ordinal)
+ .Replace(" ", string.Empty, StringComparison.Ordinal);
+ return compact.Length > 0 && compact.Any(character => character != '0');
+ }
+
+ private static DynamicReportCleanupClosureCommissioningResult Blocked(
+ string summary,
+ IReadOnlyList evidence,
+ ArMms.MmsDynamicReportIedIdentity? identity = null,
+ string profilePath = "",
+ ArMms.MmsDynamicReportQualificationProfile? profile = null)
+ => new()
+ {
+ IsBlocked = true,
+ Summary = summary,
+ Identity = identity,
+ InputProfile = profile,
+ ProfilePath = profilePath,
+ EvidenceLines = evidence.ToArray()
+ };
+
+ private static DynamicReportCleanupClosureCommissioningResult Failed(
+ string summary,
+ IReadOnlyList evidence,
+ ArMms.MmsDynamicReportIedIdentity identity,
+ ArMms.MmsDynamicReportQualificationProfile profile,
+ string profilePath,
+ string rcbReference,
+ string temporaryDataSetReference,
+ ArMms.MmsRcbAvailabilitySnapshot? snapshot = null,
+ bool temporaryNameAbsent = false,
+ bool temporaryDirectoryAbsent = false,
+ bool associationHealthy = false)
+ => new()
+ {
+ IsSuccess = false,
+ Summary = summary,
+ Identity = identity,
+ InputProfile = profile,
+ FreshRcbSnapshot = snapshot,
+ RcbReference = rcbReference,
+ TemporaryDataSetReference = temporaryDataSetReference,
+ TemporaryDataSetAbsentFromNameList = temporaryNameAbsent,
+ TemporaryDataSetDirectoryAbsent = temporaryDirectoryAbsent,
+ AssociationHealthy = associationHealthy,
+ ProfilePath = profilePath,
+ EvidenceLines = evidence.ToArray()
+ };
+
+ private static string TextOrDash(string? value)
+ => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim();
+}
diff --git a/Services/DynamicReportOptionalFieldsProbeCommissioningService.cs b/Services/DynamicReportOptionalFieldsProbeCommissioningService.cs
new file mode 100644
index 000000000..177d2a85a
--- /dev/null
+++ b/Services/DynamicReportOptionalFieldsProbeCommissioningService.cs
@@ -0,0 +1,277 @@
+using ArIED61850Tester.Models;
+using ArMms = AR.Iec61850.Mms;
+
+namespace ArIED61850Tester.Services;
+
+internal sealed class DynamicReportOptionalFieldsProbeCommissioningResult
+{
+ public bool IsSuccess { get; init; }
+ public bool IsBlocked { get; init; }
+ public bool CleanupSucceeded { get; init; }
+ public string Summary { get; init; } = string.Empty;
+ public ArMms.MmsDynamicReportIedIdentity? Identity { get; init; }
+ public ArMms.MmsDynamicReportQualificationProfile? InputProfile { get; init; }
+ public ArMms.MmsDynamicRcbOptionalFieldsProbeResult? Probe { get; init; }
+ public string RcbReference { get; init; } = string.Empty;
+ public string ProfilePath { get; init; } = string.Empty;
+ public IReadOnlyList EvidenceLines { get; init; } = Array.Empty();
+}
+
+///
+/// P1 proof-field isolation gate after physical P0 TrgOps PASS. It proves only
+/// reason-for-inclusion + data-set-name OptFlds on exactly one forced-live free
+/// URCB, then restores the captured original OptFlds immediately. It never
+/// touches TrgOps, DatSet, Resv, RptEna, GI, DataSet services, report routing,
+/// or persisted qualification state.
+///
+internal sealed class DynamicReportOptionalFieldsProbeCommissioningService
+{
+ private static readonly TimeSpan AuxiliaryAssociationTimeout = TimeSpan.FromSeconds(10);
+ private const string ProbeOptionalFields = "reason-for-inclusion data-set-name";
+
+ private readonly DynamicReportQualificationProfileStore _profileStore;
+
+ public DynamicReportOptionalFieldsProbeCommissioningService(
+ DynamicReportQualificationProfileStore? profileStore = null)
+ {
+ _profileStore = profileStore ?? new DynamicReportQualificationProfileStore();
+ }
+
+ public async Task RunAsync(
+ Iec61850MonitorDevice device,
+ IReadOnlyList fullModelSignals,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+ ArgumentNullException.ThrowIfNull(fullModelSignals);
+
+ var evidence = new List();
+ ArMms.MmsDynamicReportIedIdentity identity;
+ try
+ {
+ identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
+ {
+ return Blocked("P1 identity preflight failed: " + ex.Message, evidence);
+ }
+
+ evidence.Add($"P1 identity stableKey={identity.StableIdentityKey}; fingerprint={identity.ModelFingerprint}; profileRevision={TextOrDash(identity.ProfileRevision)}");
+
+ var loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false);
+ evidence.Add($"P1 persisted profile: exists={loaded.Exists}; valid={loaded.IsValid}; reason={loaded.Reason}");
+ if (!loaded.IsValid || loaded.Profile is null)
+ {
+ return Blocked(
+ "P1 requires an identity-compatible persisted G2.3 profile before any URCB field write.",
+ evidence,
+ identity,
+ loaded.FilePath);
+ }
+
+ var profile = loaded.Profile;
+ if (profile.State < ArMms.MmsDynamicReportQualificationState.EnvelopeQualified || profile.AcceptedEnvelope is null)
+ {
+ return Blocked(
+ $"P1 requires at least EnvelopeQualified evidence; current profile state is {profile.State}.",
+ evidence,
+ identity,
+ loaded.FilePath,
+ profile);
+ }
+
+ var preferredLogicalDevice = profile.AcceptedEnvelope.ExactProvenMemberReferences
+ .Select(reference => DomainOf(reference))
+ .FirstOrDefault(domain => !string.IsNullOrWhiteSpace(domain)) ?? string.Empty;
+ evidence.Add($"P1 profile gate: state={profile.State}; preferredLD={TextOrDash(preferredLogicalDevice)}; requestedOptFlds={ProbeOptionalFields}; expectedCanonicalRaw=061800");
+
+ await using var auxiliary = new ArMms.MmsClientSession();
+ try
+ {
+ await auxiliary.ConnectAsync(
+ device.IpAddress,
+ device.Port,
+ AuxiliaryAssociationTimeout,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException)
+ {
+ evidence.Add($"P1 auxiliary association failed: {ex.GetType().Name}: {ex.Message}");
+ return Blocked(
+ "P1 auxiliary MMS association was not established. No URCB field was changed.",
+ evidence,
+ identity,
+ loaded.FilePath,
+ profile);
+ }
+
+ evidence.Add($"P1 auxiliary association ready: state={auxiliary.State}; handshake={TextOrDash(auxiliary.LastHandshakeMessage)}");
+
+ ArMms.MmsDiscoveryResult discovery;
+ try
+ {
+ discovery = await auxiliary.DiscoverAsync(
+ probeReportAttributes: true,
+ maxReportAttributeProbes: 64,
+ cancellationToken: cancellationToken,
+ readDataSetDirectories: false,
+ maxDataSetDirectoryReads: 0).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"P1 discovery failed: {ex.GetType().Name}: {ex.Message}");
+ return Failed(
+ "P1 fresh discovery failed before any URCB field mutation.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath);
+ }
+
+ evidence.Add("P1 discovery: " + discovery.Summary);
+
+ ArMms.MmsRcbAvailabilityResult availability;
+ try
+ {
+ availability = await auxiliary.CheckReportControlAvailabilityAsync(
+ discovery.ReportInventory,
+ discovery.IedDirectory,
+ new ArMms.MmsRcbAvailabilityOptions
+ {
+ MaxReportControls = 64,
+ ReadDataSetDirectories = false
+ },
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"P1 forced-live URCB availability failed: {ex.GetType().Name}: {ex.Message}");
+ return Failed(
+ "P1 could not obtain fresh DatSet/RptEna/Resv/Owner evidence. No URCB field was changed.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath);
+ }
+
+ evidence.Add("P1 forced live availability: " + availability.Summary);
+ foreach (var warning in availability.Warnings)
+ evidence.Add("P1 availability warning: " + warning);
+
+ var selectedRcb = DynamicReportActivationCommissioningServiceV2.SelectQualifiedUrcbFromFreshAvailability(
+ availability,
+ discovery.ReportInventory,
+ preferredLogicalDevice,
+ out var selectedSnapshot,
+ out var selectionReason,
+ out var candidateDiagnostics);
+ evidence.Add("P1 URCB selection: " + selectionReason);
+ foreach (var diagnostic in candidateDiagnostics)
+ evidence.Add("P1 URCB candidate: " + diagnostic);
+
+ if (selectedRcb is null || selectedSnapshot is null)
+ {
+ return Failed(
+ "P1 found no forced-live proven-empty/free URCB. No OptFlds write was attempted.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath);
+ }
+
+ ArMms.MmsDynamicRcbOptionalFieldsProbeResult probe;
+ try
+ {
+ probe = await auxiliary.ProbeDynamicRcbOptionalFieldsAsync(
+ selectedRcb,
+ ProbeOptionalFields,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"P1 OptFlds probe exception: {ex.GetType().Name}: {ex.Message}");
+ return new DynamicReportOptionalFieldsProbeCommissioningResult
+ {
+ IsSuccess = false,
+ CleanupSucceeded = false,
+ Summary = "P1 OptFlds probe ended with a protocol/transport exception; inspect the selected URCB from a fresh association before retry.",
+ Identity = identity,
+ InputProfile = profile,
+ RcbReference = selectedRcb.Reference,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ foreach (var step in probe.WriteSteps)
+ evidence.Add($"P1 write: attribute={step.Attribute}; reference={step.Reference}; attempted={step.Attempted}; success={step.IsSuccess}; result={step.Message}");
+ foreach (var line in probe.Evidence)
+ evidence.Add(line);
+
+ var associationHealthy = auxiliary.IsMmsInitiated;
+ evidence.Add($"P1 result: success={probe.IsSuccess}; cleanup={probe.CleanupSucceeded}; associationHealthy={associationHealthy}; original={TextOrDash(probe.OriginalRaw)}; requested={TextOrDash(probe.RequestedRaw)}; readback={TextOrDash(probe.ReadbackRaw)}; restoreReadback={TextOrDash(probe.RestoreReadbackRaw)}");
+ evidence.Add("P1 safety: no TrgOps, DatSet, Resv, RptEna, GI, DefineNamedVariableList, report monitor or profile-state mutation is performed by this action.");
+
+ var success = probe.IsSuccess && probe.CleanupSucceeded && associationHealthy;
+ return new DynamicReportOptionalFieldsProbeCommissioningResult
+ {
+ IsSuccess = success,
+ CleanupSucceeded = probe.CleanupSucceeded,
+ Summary = success
+ ? "P1 PASS: one proven-free URCB accepted reason-for-inclusion + data-set-name OptFlds significant bits and original OptFlds restore was proven. G2.4 may proceed to the one-URCB transactional InformationReport proof; production dynamic reporting remains OFF."
+ : probe.CleanupSucceeded
+ ? "P1 requested OptFlds was not proven, but original OptFlds restore passed. Production dynamic reporting remains OFF."
+ : "P1 did not prove original OptFlds restore. Do not retry active commissioning until a fresh read-only inspection confirms the URCB state.",
+ Identity = identity,
+ InputProfile = profile,
+ Probe = probe,
+ RcbReference = selectedRcb.Reference,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ private static DynamicReportOptionalFieldsProbeCommissioningResult Blocked(
+ string summary,
+ IReadOnlyList evidence,
+ ArMms.MmsDynamicReportIedIdentity? identity = null,
+ string profilePath = "",
+ ArMms.MmsDynamicReportQualificationProfile? profile = null)
+ => new()
+ {
+ IsBlocked = true,
+ CleanupSucceeded = true,
+ Summary = summary,
+ Identity = identity,
+ InputProfile = profile,
+ ProfilePath = profilePath,
+ EvidenceLines = evidence.ToArray()
+ };
+
+ private static DynamicReportOptionalFieldsProbeCommissioningResult Failed(
+ string summary,
+ IReadOnlyList evidence,
+ ArMms.MmsDynamicReportIedIdentity identity,
+ ArMms.MmsDynamicReportQualificationProfile profile,
+ string profilePath)
+ => new()
+ {
+ IsSuccess = false,
+ CleanupSucceeded = true,
+ Summary = summary,
+ Identity = identity,
+ InputProfile = profile,
+ ProfilePath = profilePath,
+ EvidenceLines = evidence.ToArray()
+ };
+
+ private static string DomainOf(string? reference)
+ {
+ var text = (reference ?? string.Empty).Trim();
+ var slash = text.IndexOf('/');
+ return slash > 0 ? text[..slash] : string.Empty;
+ }
+
+ private static string TextOrDash(string? value)
+ => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim();
+}
diff --git a/Services/DynamicReportTriggerOptionsProbeCommissioningService.cs b/Services/DynamicReportTriggerOptionsProbeCommissioningService.cs
new file mode 100644
index 000000000..52a5d6297
--- /dev/null
+++ b/Services/DynamicReportTriggerOptionsProbeCommissioningService.cs
@@ -0,0 +1,278 @@
+using ArIED61850Tester.Models;
+using ArMms = AR.Iec61850.Mms;
+
+namespace ArIED61850Tester.Services;
+
+internal sealed class DynamicReportTriggerOptionsProbeCommissioningResult
+{
+ public bool IsSuccess { get; init; }
+ public bool IsBlocked { get; init; }
+ public bool CleanupSucceeded { get; init; }
+ public string Summary { get; init; } = string.Empty;
+ public ArMms.MmsDynamicReportIedIdentity? Identity { get; init; }
+ public ArMms.MmsDynamicReportQualificationProfile? InputProfile { get; init; }
+ public ArMms.MmsDynamicRcbTriggerOptionsProbeResult? Probe { get; init; }
+ public string RcbReference { get; init; } = string.Empty;
+ public string ProfilePath { get; init; } = string.Empty;
+ public IReadOnlyList EvidenceLines { get; init; } = Array.Empty();
+}
+
+///
+/// P0 protocol-isolation gate before G2.4. It proves the corrected IEC TrgOps
+/// reserved-bit mapping against exactly one forced-live free URCB, then restores
+/// the original TrgOps immediately. It never touches OptFlds, DatSet, Resv,
+/// RptEna, GI, dynamic DataSet services, or persisted qualification state.
+///
+internal sealed class DynamicReportTriggerOptionsProbeCommissioningService
+{
+ private static readonly TimeSpan AuxiliaryAssociationTimeout = TimeSpan.FromSeconds(10);
+ private const string ProbeTriggerOptions = "dchg gi";
+
+ private readonly DynamicReportQualificationProfileStore _profileStore;
+
+ public DynamicReportTriggerOptionsProbeCommissioningService(
+ DynamicReportQualificationProfileStore? profileStore = null)
+ {
+ _profileStore = profileStore ?? new DynamicReportQualificationProfileStore();
+ }
+
+ public async Task RunAsync(
+ Iec61850MonitorDevice device,
+ IReadOnlyList fullModelSignals,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+ ArgumentNullException.ThrowIfNull(fullModelSignals);
+
+ var evidence = new List();
+ ArMms.MmsDynamicReportIedIdentity identity;
+ try
+ {
+ identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
+ {
+ return Blocked("P0 identity preflight failed: " + ex.Message, evidence);
+ }
+
+ evidence.Add($"P0 identity stableKey={identity.StableIdentityKey}; fingerprint={identity.ModelFingerprint}; profileRevision={TextOrDash(identity.ProfileRevision)}");
+
+ var loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false);
+ evidence.Add($"P0 persisted profile: exists={loaded.Exists}; valid={loaded.IsValid}; reason={loaded.Reason}");
+ if (!loaded.IsValid || loaded.Profile is null)
+ {
+ return Blocked(
+ "P0 requires an identity-compatible persisted G2.3 profile before any URCB field write.",
+ evidence,
+ identity,
+ loaded.FilePath);
+ }
+
+ var profile = loaded.Profile;
+ if (profile.State < ArMms.MmsDynamicReportQualificationState.EnvelopeQualified || profile.AcceptedEnvelope is null)
+ {
+ return Blocked(
+ $"P0 requires at least EnvelopeQualified evidence; current profile state is {profile.State}.",
+ evidence,
+ identity,
+ loaded.FilePath,
+ profile);
+ }
+
+ var preferredLogicalDevice = profile.AcceptedEnvelope.ExactProvenMemberReferences
+ .Select(reference => DomainOf(reference))
+ .FirstOrDefault(domain => !string.IsNullOrWhiteSpace(domain)) ?? string.Empty;
+ evidence.Add($"P0 profile gate: state={profile.State}; preferredLD={TextOrDash(preferredLogicalDevice)}; requestedTrgOps={ProbeTriggerOptions}; expectedCanonicalRaw=0244");
+
+ await using var auxiliary = new ArMms.MmsClientSession();
+ try
+ {
+ await auxiliary.ConnectAsync(
+ device.IpAddress,
+ device.Port,
+ AuxiliaryAssociationTimeout,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException)
+ {
+ evidence.Add($"P0 auxiliary association failed: {ex.GetType().Name}: {ex.Message}");
+ return Blocked(
+ "P0 auxiliary MMS association was not established. No URCB field was changed.",
+ evidence,
+ identity,
+ loaded.FilePath,
+ profile);
+ }
+
+ evidence.Add($"P0 auxiliary association ready: state={auxiliary.State}; handshake={TextOrDash(auxiliary.LastHandshakeMessage)}");
+
+ ArMms.MmsDiscoveryResult discovery;
+ try
+ {
+ discovery = await auxiliary.DiscoverAsync(
+ probeReportAttributes: true,
+ maxReportAttributeProbes: 64,
+ cancellationToken: cancellationToken,
+ readDataSetDirectories: false,
+ maxDataSetDirectoryReads: 0).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"P0 discovery failed: {ex.GetType().Name}: {ex.Message}");
+ return Failed(
+ "P0 fresh discovery failed before any URCB field mutation.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath);
+ }
+
+ evidence.Add("P0 discovery: " + discovery.Summary);
+
+ ArMms.MmsRcbAvailabilityResult availability;
+ try
+ {
+ availability = await auxiliary.CheckReportControlAvailabilityAsync(
+ discovery.ReportInventory,
+ discovery.IedDirectory,
+ new ArMms.MmsRcbAvailabilityOptions
+ {
+ MaxReportControls = 64,
+ ReadDataSetDirectories = false
+ },
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"P0 forced-live URCB availability failed: {ex.GetType().Name}: {ex.Message}");
+ return Failed(
+ "P0 could not obtain fresh DatSet/RptEna/Resv/Owner evidence. No URCB field was changed.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath);
+ }
+
+ evidence.Add("P0 forced live availability: " + availability.Summary);
+ foreach (var warning in availability.Warnings)
+ evidence.Add("P0 availability warning: " + warning);
+
+ var selectedRcb = DynamicReportActivationCommissioningServiceV2.SelectQualifiedUrcbFromFreshAvailability(
+ availability,
+ discovery.ReportInventory,
+ preferredLogicalDevice,
+ out var selectedSnapshot,
+ out var selectionReason,
+ out var candidateDiagnostics);
+ evidence.Add("P0 URCB selection: " + selectionReason);
+ foreach (var diagnostic in candidateDiagnostics)
+ evidence.Add("P0 URCB candidate: " + diagnostic);
+
+ if (selectedRcb is null || selectedSnapshot is null)
+ {
+ return Failed(
+ "P0 found no forced-live proven-empty/free URCB. No TrgOps write was attempted.",
+ evidence,
+ identity,
+ profile,
+ loaded.FilePath);
+ }
+
+ // Reuse the exact live RCB identity only. The engine probe itself touches TrgOps
+ // and nothing else; all availability gates above are read-only.
+ ArMms.MmsDynamicRcbTriggerOptionsProbeResult probe;
+ try
+ {
+ probe = await auxiliary.ProbeDynamicRcbTriggerOptionsAsync(
+ selectedRcb,
+ ProbeTriggerOptions,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException)
+ {
+ evidence.Add($"P0 TrgOps probe exception: {ex.GetType().Name}: {ex.Message}");
+ return new DynamicReportTriggerOptionsProbeCommissioningResult
+ {
+ IsSuccess = false,
+ CleanupSucceeded = false,
+ Summary = "P0 TrgOps probe ended with a protocol/transport exception; inspect the selected URCB from a fresh association before retry.",
+ Identity = identity,
+ InputProfile = profile,
+ RcbReference = selectedRcb.Reference,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ foreach (var step in probe.WriteSteps)
+ evidence.Add($"P0 write: attribute={step.Attribute}; reference={step.Reference}; attempted={step.Attempted}; success={step.IsSuccess}; result={step.Message}");
+ foreach (var line in probe.Evidence)
+ evidence.Add(line);
+
+ var associationHealthy = auxiliary.IsMmsInitiated;
+ evidence.Add($"P0 result: success={probe.IsSuccess}; cleanup={probe.CleanupSucceeded}; associationHealthy={associationHealthy}; original={TextOrDash(probe.OriginalRaw)}; requested={TextOrDash(probe.RequestedRaw)}; readback={TextOrDash(probe.ReadbackRaw)}; restoreReadback={TextOrDash(probe.RestoreReadbackRaw)}");
+ evidence.Add("P0 safety: no OptFlds, DatSet, Resv, RptEna, GI, DefineNamedVariableList or profile-state mutation is performed by this action.");
+
+ var success = probe.IsSuccess && probe.CleanupSucceeded && associationHealthy;
+ return new DynamicReportTriggerOptionsProbeCommissioningResult
+ {
+ IsSuccess = success,
+ CleanupSucceeded = probe.CleanupSucceeded,
+ Summary = success
+ ? "P0 PASS: one proven-free URCB accepted corrected dchg+GI TrgOps significant bits and original TrgOps restore was proven. G2.4 may proceed to the next isolated proof-field gate; production dynamic reporting remains OFF."
+ : probe.CleanupSucceeded
+ ? "P0 requested TrgOps was not proven, but original TrgOps restore passed. Production dynamic reporting remains OFF."
+ : "P0 did not prove original TrgOps restore. Do not retry active commissioning until a fresh read-only inspection confirms the URCB state.",
+ Identity = identity,
+ InputProfile = profile,
+ Probe = probe,
+ RcbReference = selectedRcb.Reference,
+ ProfilePath = loaded.FilePath,
+ EvidenceLines = evidence
+ };
+ }
+
+ private static DynamicReportTriggerOptionsProbeCommissioningResult Blocked(
+ string summary,
+ IReadOnlyList evidence,
+ ArMms.MmsDynamicReportIedIdentity? identity = null,
+ string profilePath = "",
+ ArMms.MmsDynamicReportQualificationProfile? profile = null)
+ => new()
+ {
+ IsBlocked = true,
+ CleanupSucceeded = true,
+ Summary = summary,
+ Identity = identity,
+ InputProfile = profile,
+ ProfilePath = profilePath,
+ EvidenceLines = evidence.ToArray()
+ };
+
+ private static DynamicReportTriggerOptionsProbeCommissioningResult Failed(
+ string summary,
+ IReadOnlyList evidence,
+ ArMms.MmsDynamicReportIedIdentity identity,
+ ArMms.MmsDynamicReportQualificationProfile profile,
+ string profilePath)
+ => new()
+ {
+ IsSuccess = false,
+ CleanupSucceeded = true,
+ Summary = summary,
+ Identity = identity,
+ InputProfile = profile,
+ ProfilePath = profilePath,
+ EvidenceLines = evidence.ToArray()
+ };
+
+ private static string DomainOf(string? reference)
+ {
+ var text = (reference ?? string.Empty).Trim();
+ var slash = text.IndexOf('/');
+ return slash > 0 ? text[..slash] : string.Empty;
+ }
+
+ private static string TextOrDash(string? value)
+ => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim();
+}
diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json
index fd1459f4f..268797b4a 100644
--- a/engines/ARIEC61850.lock.json
+++ b/engines/ARIEC61850.lock.json
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"repository": "masarray/ARIEC61850",
"ref": "main",
- "commit": "c0e146a6111e67b2bfe85c446bd6cef4933f4b37",
- "sourcePullRequest": 94,
- "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. Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase."
+ "commit": "26c85400a4da230c4429e6302847f230385b6687",
+ "sourcePullRequest": 95,
+ "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."
}
diff --git a/tests/ARSAS.Tests/DynamicReportActivationCommissioningPostLeaseTests.cs b/tests/ARSAS.Tests/DynamicReportActivationCommissioningPostLeaseTests.cs
new file mode 100644
index 000000000..ea6bf7526
--- /dev/null
+++ b/tests/ARSAS.Tests/DynamicReportActivationCommissioningPostLeaseTests.cs
@@ -0,0 +1,164 @@
+using ArIED61850Tester.Services;
+using ArMms = AR.Iec61850.Mms;
+
+namespace ARSAS.Tests;
+
+public sealed class DynamicReportActivationCommissioningPostLeaseTests
+{
+ [Fact]
+ public void PostLeaseAvailabilityOptions_MarkOnlyExactSelectedUrcbCallerOwned()
+ {
+ const string selected = "LD0/LLN0.RP01";
+
+ var options = DynamicReportActivationCommissioningServiceV2.BuildPostLeaseAvailabilityOptions(selected);
+
+ Assert.Equal(1, options.MaxReportControls);
+ Assert.False(options.ReadDataSetDirectories);
+ Assert.Single(options.CallerOwnedRcbReferences);
+ Assert.Contains(selected, options.CallerOwnedRcbReferences, StringComparer.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void PostLeaseGate_AcceptsSameAssociationAutoReservation_WhenOwnerMatchesLocalEndpoint()
+ {
+ var snapshot = Snapshot(
+ reservationState: "true",
+ availability: ArMms.MmsRcbOperationalAvailability.UsedByCaller,
+ triggerOptions: "0244",
+ optionalFields: "061800",
+ owner: "C0A851F0");
+
+ var safe = DynamicReportActivationCommissioningServiceV2.IsPostLeaseUrcbSafeForG24(
+ snapshot,
+ "192.168.81.240",
+ out var reason);
+
+ Assert.True(safe, reason);
+ Assert.Contains("caller-owned", reason, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("Resv=true", reason, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("192.168.81.240", reason, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("matches", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void PostLeaseGate_AcceptsUsedByCaller_WhenOwnerIsNotExposed()
+ {
+ var snapshot = Snapshot(
+ reservationState: "true",
+ availability: ArMms.MmsRcbOperationalAvailability.UsedByCaller,
+ triggerOptions: "0244",
+ optionalFields: "061800");
+
+ var safe = DynamicReportActivationCommissioningServiceV2.IsPostLeaseUrcbSafeForG24(
+ snapshot,
+ "192.168.81.240",
+ out var reason);
+
+ Assert.True(safe, reason);
+ Assert.Contains("Owner is empty/not exposed", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void PostLeaseGate_RejectsReservedUrcbNotCallerOwned()
+ {
+ var snapshot = Snapshot(
+ reservationState: "true",
+ availability: ArMms.MmsRcbOperationalAvailability.InUse,
+ triggerOptions: "0244",
+ optionalFields: "061800",
+ owner: "C0A851F0");
+
+ var safe = DynamicReportActivationCommissioningServiceV2.IsPostLeaseUrcbSafeForG24(
+ snapshot,
+ "192.168.81.240",
+ out var reason);
+
+ Assert.False(safe);
+ Assert.Contains("ownership is not proven", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void PostLeaseGate_RejectsMissingStrictIdentityBits()
+ {
+ var snapshot = Snapshot(
+ reservationState: "true",
+ availability: ArMms.MmsRcbOperationalAvailability.UsedByCaller,
+ triggerOptions: "0204",
+ optionalFields: "060000",
+ owner: "C0A851F0");
+
+ var safe = DynamicReportActivationCommissioningServiceV2.IsPostLeaseUrcbSafeForG24(
+ snapshot,
+ "192.168.81.240",
+ out var reason);
+
+ Assert.False(safe);
+ Assert.Contains("strict G2.4 report identity fields", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void PostLeaseGate_RejectsOwnerThatDoesNotMatchLocalEndpoint()
+ {
+ var snapshot = Snapshot(
+ reservationState: "true",
+ availability: ArMms.MmsRcbOperationalAvailability.UsedByCaller,
+ triggerOptions: "0244",
+ optionalFields: "061800",
+ owner: "C0A851F0");
+
+ var safe = DynamicReportActivationCommissioningServiceV2.IsPostLeaseUrcbSafeForG24(
+ snapshot,
+ "192.168.81.241",
+ out var reason);
+
+ Assert.False(safe);
+ Assert.Contains("does not match", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void PostLeaseGate_RejectsUnknownNonEmptyOwnerEncoding()
+ {
+ var snapshot = Snapshot(
+ reservationState: "true",
+ availability: ArMms.MmsRcbOperationalAvailability.UsedByCaller,
+ triggerOptions: "0244",
+ optionalFields: "061800",
+ owner: "010203");
+
+ var safe = DynamicReportActivationCommissioningServiceV2.IsPostLeaseUrcbSafeForG24(
+ snapshot,
+ "192.168.81.240",
+ out var reason);
+
+ Assert.False(safe);
+ Assert.Contains("not a supported", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static ArMms.MmsRcbAvailabilitySnapshot Snapshot(
+ string reservationState,
+ ArMms.MmsRcbOperationalAvailability availability,
+ string triggerOptions,
+ string optionalFields,
+ string owner = "")
+ => new()
+ {
+ Reference = "LD0/LLN0.RP01",
+ Domain = "LD0",
+ LogicalNode = "LLN0",
+ Name = "RP01",
+ Buffered = false,
+ DataSetReference = string.Empty,
+ DataSetProbeState = ArMms.MmsRcbDataSetProbeState.ReadSucceeded,
+ DataSetProbeMessage = "DatSet item: OK",
+ ReportId = "RPT-01",
+ TriggerOptions = triggerOptions,
+ OptionalFields = optionalFields,
+ EnabledState = "false",
+ ReservationState = reservationState,
+ ReservationTimeSeconds = "0",
+ Owner = owner,
+ Availability = availability,
+ Confidence = ArMms.MmsRcbAvailabilityConfidence.Exact,
+ Attributes = ["DatSet", "RptID", "RptEna", "Resv", "TrgOps", "OptFlds", "GI"]
+ };
+}
diff --git a/tests/ARSAS.Tests/DynamicReportActivationCommissioningServiceTests.cs b/tests/ARSAS.Tests/DynamicReportActivationCommissioningServiceTests.cs
new file mode 100644
index 000000000..0db646fca
--- /dev/null
+++ b/tests/ARSAS.Tests/DynamicReportActivationCommissioningServiceTests.cs
@@ -0,0 +1,271 @@
+using ArIED61850Tester.Services;
+using ArMms = AR.Iec61850.Mms;
+
+namespace ARSAS.Tests;
+
+public sealed class DynamicReportActivationCommissioningServiceTests
+{
+ [Fact]
+ public void ExactQualifiedMemberResolution_UsesMmsIdentityWithoutSuffixFallback()
+ {
+ var point = Point("LD0", "GGIO1$ST$Ind1$stVal", "GGIO1", "ST", "Ind1.stVal");
+ var directory = new ArMms.MmsIedModelDirectory([point]);
+
+ var ok = DynamicReportActivationCommissioningService.TryResolveExactQualifiedMembers(
+ directory,
+ ["LD0/GGIO1$ST$Ind1$stVal"],
+ out var resolved,
+ out _);
+ var fuzzy = DynamicReportActivationCommissioningService.TryResolveExactQualifiedMembers(
+ directory,
+ ["LD0/Ind1$stVal"],
+ out _,
+ out _);
+
+ Assert.True(ok);
+ Assert.Single(resolved);
+ Assert.False(fuzzy);
+ }
+
+ [Fact]
+ public void UrcbSelection_RejectsBrcbAndRequiresGiDataSetNameAndRptId()
+ {
+ var inventory = new ArMms.MmsReportInventory();
+ inventory.ReportControls.Add(Rcb("LD0/LLN0.BR01", buffered: true, rptId: "B1", trgOps: "gi", optFlds: "data-set-name"));
+ inventory.ReportControls.Add(Rcb("LD0/LLN0.RP01", buffered: false, rptId: "R1", trgOps: "dchg", optFlds: "data-set-name"));
+ inventory.ReportControls.Add(Rcb("LD0/LLN0.RP02", buffered: false, rptId: "R2", trgOps: "gi", optFlds: "sequence-number"));
+ inventory.ReportControls.Add(Rcb("LD0/LLN0.RP03", buffered: false, rptId: "R3", trgOps: "dchg gi", optFlds: "data-set-name reason-for-inclusion"));
+
+ var selected = DynamicReportActivationCommissioningService.SelectQualifiedUrcb(inventory, "LD0", out var reason);
+
+ Assert.NotNull(selected);
+ Assert.False(selected!.Buffered);
+ Assert.Equal("LD0/LLN0.RP03", selected.Reference);
+ Assert.Contains("selected=LD0/LLN0.RP03", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void FreshUrcbGate_RequiresExplicitFreeStateWhenResvIsExposed()
+ {
+ var safe = Snapshot(resv: "false", rptEna: "false", dataSet: "", owner: "");
+ var reserved = Snapshot(resv: "true", rptEna: "false", dataSet: "", owner: "");
+ var occupied = Snapshot(resv: "false", rptEna: "true", dataSet: "", owner: "");
+ var bound = Snapshot(resv: "false", rptEna: "false", dataSet: "LD0/LLN0.Static", owner: "");
+
+ Assert.True(DynamicReportActivationCommissioningService.IsFreshUrcbSafeForG24(safe, out _));
+ Assert.False(DynamicReportActivationCommissioningService.IsFreshUrcbSafeForG24(reserved, out _));
+ Assert.False(DynamicReportActivationCommissioningService.IsFreshUrcbSafeForG24(occupied, out _));
+ Assert.False(DynamicReportActivationCommissioningService.IsFreshUrcbSafeForG24(bound, out _));
+ }
+
+ [Fact]
+ public void PlanGate_RequiresExactOrderedQualifiedMembersAndOneUrcb()
+ {
+ var refs = new[]
+ {
+ "LD0/GGIO1$ST$Ind1$stVal",
+ "LD0/GGIO1$ST$Ind2$stVal"
+ };
+ var p1 = Point("LD0", "GGIO1$ST$Ind1$stVal", "GGIO1", "ST", "Ind1.stVal");
+ var p2 = Point("LD0", "GGIO1$ST$Ind2$stVal", "GGIO1", "ST", "Ind2.stVal");
+ var rcb = Rcb("LD0/LLN0.RP01", buffered: false, rptId: "R1", trgOps: "gi", optFlds: "data-set-name");
+ var plan = new ArMms.MmsReportSubscriptionPlan
+ {
+ Mode = ArMms.MmsReportSubscriptionPlanMode.DynamicDataSet,
+ Status = ArMms.MmsReportSubscriptionPlanStatus.ReadyRequiresWrite,
+ ReportControl = rcb,
+ DataSetReference = "LD0/LLN0.AR_G24_TEST",
+ DynamicPoints = [p1, p2],
+ Members = [Member(p1), Member(p2)]
+ };
+
+ Assert.True(DynamicReportActivationCommissioningService.ValidatePlanAgainstEnvelope(plan, rcb.Reference, refs, out _));
+ Assert.False(DynamicReportActivationCommissioningService.ValidatePlanAgainstEnvelope(plan, rcb.Reference, refs.Reverse().ToArray(), out _));
+ }
+
+ [Fact]
+ public void InformationReportGate_AcceptsOnlyFullExactOrderedMappedFrame()
+ {
+ var refs = new[]
+ {
+ "LD0/GGIO1$ST$Ind1$stVal",
+ "LD0/GGIO1$ST$Ind2$stVal"
+ };
+ var p1 = Point("LD0", "GGIO1$ST$Ind1$stVal", "GGIO1", "ST", "Ind1.stVal");
+ var p2 = Point("LD0", "GGIO1$ST$Ind2$stVal", "GGIO1", "ST", "Ind2.stVal");
+ var valid = Frame(
+ "R1",
+ "LD0/LLN0.AR_G24_TEST",
+ [Member(p1), Member(p2)],
+ [0, 1],
+ reasons: ["general-interrogation"]);
+
+ var result = DynamicReportActivationCommissioningService.ValidateInformationReportFrame(
+ valid,
+ "R1",
+ "LD0/LLN0.AR_G24_TEST",
+ refs);
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(ArMms.MmsDynamicInformationReportKind.GeneralInterrogation, result.Kind);
+ Assert.Equal(2, result.AuthoritativePointCount);
+ }
+
+ [Fact]
+ public void InformationReportGate_RejectsWrongDataSetPartialAndReorderedFrames()
+ {
+ var refs = new[]
+ {
+ "LD0/GGIO1$ST$Ind1$stVal",
+ "LD0/GGIO1$ST$Ind2$stVal"
+ };
+ var p1 = Point("LD0", "GGIO1$ST$Ind1$stVal", "GGIO1", "ST", "Ind1.stVal");
+ var p2 = Point("LD0", "GGIO1$ST$Ind2$stVal", "GGIO1", "ST", "Ind2.stVal");
+ var members = new[] { Member(p1), Member(p2) };
+
+ var wrongDataSet = Frame("R1", "LD0/LLN0.OTHER", members, [0, 1]);
+ var partial = Frame("R1", "LD0/LLN0.AR_G24_TEST", [members[0]], [0]);
+ var reordered = Frame("R1", "LD0/LLN0.AR_G24_TEST", [members[1], members[0]], [0, 1]);
+
+ Assert.False(DynamicReportActivationCommissioningService.ValidateInformationReportFrame(wrongDataSet, "R1", "LD0/LLN0.AR_G24_TEST", refs).IsSuccess);
+ Assert.False(DynamicReportActivationCommissioningService.ValidateInformationReportFrame(partial, "R1", "LD0/LLN0.AR_G24_TEST", refs).IsSuccess);
+ Assert.False(DynamicReportActivationCommissioningService.ValidateInformationReportFrame(reordered, "R1", "LD0/LLN0.AR_G24_TEST", refs).IsSuccess);
+ }
+
+ [Fact]
+ public void InformationReportGate_RejectsDecoderQuarantineAndAccessFailure()
+ {
+ var reference = "LD0/GGIO1$ST$Ind1$stVal";
+ var point = Point("LD0", "GGIO1$ST$Ind1$stVal", "GGIO1", "ST", "Ind1.stVal");
+ var member = Member(point);
+ var quarantined = WithDecoder(Frame("R1", "LD0/LLN0.AR_G24_TEST", [member], [0]), "rejected-unmapped");
+ var failed = new ArMms.MmsReportFrame
+ {
+ Header = new ArMms.MmsReportHeader { ReportId = "R1", DataSetReference = "LD0/LLN0.AR_G24_TEST" },
+ DecoderMode = "optflds-driven",
+ IncludedDataSetIndexes = [0],
+ Values =
+ [
+ new ArMms.MmsReportValue
+ {
+ Index = 0,
+ Member = member,
+ FailureCode = 3
+ }
+ ]
+ };
+
+ Assert.False(DynamicReportActivationCommissioningService.ValidateInformationReportFrame(quarantined, "R1", "LD0/LLN0.AR_G24_TEST", [reference]).IsSuccess);
+ Assert.False(DynamicReportActivationCommissioningService.ValidateInformationReportFrame(failed, "R1", "LD0/LLN0.AR_G24_TEST", [reference]).IsSuccess);
+ }
+
+ private static ArMms.MmsFcResolvedPoint Point(string domain, string item, string ln, string fc, string path)
+ => new()
+ {
+ Domain = domain,
+ MmsItemName = item,
+ LogicalNode = ln,
+ FunctionalConstraint = fc,
+ DataObjectPath = path
+ };
+
+ private static ArMms.MmsDataSetDirectoryMember Member(ArMms.MmsFcResolvedPoint point)
+ => new()
+ {
+ Domain = point.Domain,
+ MmsItemName = point.MmsItemName,
+ UserReference = point.UserReference,
+ FunctionalConstraint = point.FunctionalConstraint,
+ LogicalNode = point.LogicalNode,
+ DataObjectPath = point.DataObjectPath
+ };
+
+ private static ArMms.MmsReportControlCandidate Rcb(
+ string reference,
+ bool buffered,
+ string rptId,
+ string trgOps,
+ string optFlds)
+ {
+ var slash = reference.IndexOf('/');
+ var dot = reference.IndexOf('.', slash + 1);
+ return new ArMms.MmsReportControlCandidate
+ {
+ Domain = reference[..slash],
+ LogicalNode = reference[(slash + 1)..dot],
+ Name = reference[(dot + 1)..],
+ Reference = reference,
+ Buffered = buffered,
+ DataSetReference = string.Empty,
+ DataSetProbeState = ArMms.MmsRcbDataSetProbeState.ReadSucceeded,
+ ReportId = rptId,
+ TriggerOptions = trgOps,
+ OptionalFields = optFlds,
+ EnabledState = "false",
+ ReservationState = "false",
+ ReservationTimeSeconds = "0",
+ Status = "AttributeProbe",
+ Attributes = ["DatSet", "RptEna", "Resv", "TrgOps", "OptFlds", "GI"]
+ };
+ }
+
+ private static ArMms.MmsRcbAvailabilitySnapshot Snapshot(string resv, string rptEna, string dataSet, string owner)
+ => new()
+ {
+ Reference = "LD0/LLN0.RP01",
+ Domain = "LD0",
+ LogicalNode = "LLN0",
+ Name = "RP01",
+ Buffered = false,
+ DataSetReference = dataSet,
+ DataSetProbeState = ArMms.MmsRcbDataSetProbeState.ReadSucceeded,
+ ReportId = "R1",
+ TriggerOptions = "dchg gi",
+ OptionalFields = "data-set-name reason-for-inclusion",
+ EnabledState = rptEna,
+ ReservationState = resv,
+ ReservationTimeSeconds = "0",
+ Owner = owner,
+ Attributes = ["DatSet", "RptEna", "Resv", "TrgOps", "OptFlds", "GI"]
+ };
+
+ private static ArMms.MmsReportFrame Frame(
+ string reportId,
+ string dataSet,
+ IReadOnlyList members,
+ IReadOnlyList included,
+ IReadOnlyList? reasons = null)
+ => new()
+ {
+ ReceivedAt = DateTimeOffset.UtcNow,
+ Header = new ArMms.MmsReportHeader
+ {
+ ReportId = reportId,
+ DataSetReference = dataSet
+ },
+ DecoderMode = "optflds-driven",
+ IncludedDataSetIndexes = included.ToArray(),
+ Values = members.Select((member, index) => new ArMms.MmsReportValue
+ {
+ Index = included[index],
+ Member = member,
+ Value = ArMms.MmsDataValue.Boolean(true),
+ ReasonForInclusion = reasons ?? Array.Empty()
+ }).ToArray()
+ };
+
+ private static ArMms.MmsReportFrame WithDecoder(ArMms.MmsReportFrame source, string decoder)
+ => new()
+ {
+ ReceivedAt = source.ReceivedAt,
+ Header = source.Header,
+ Values = source.Values,
+ RawAccessResultCount = source.RawAccessResultCount,
+ InclusionBitstringItemIndex = source.InclusionBitstringItemIndex,
+ IncludedDataSetIndexes = source.IncludedDataSetIndexes,
+ DecoderMode = decoder,
+ ParseWarnings = source.ParseWarnings,
+ Message = source.Message,
+ ResponseHexPreview = source.ResponseHexPreview
+ };
+}
diff --git a/tests/ARSAS.Tests/DynamicReportActivationCommissioningServiceV2Tests.cs b/tests/ARSAS.Tests/DynamicReportActivationCommissioningServiceV2Tests.cs
new file mode 100644
index 000000000..46a9fd273
--- /dev/null
+++ b/tests/ARSAS.Tests/DynamicReportActivationCommissioningServiceV2Tests.cs
@@ -0,0 +1,205 @@
+using ArIED61850Tester.Services;
+using ArMms = AR.Iec61850.Mms;
+
+namespace ARSAS.Tests;
+
+public sealed class DynamicReportActivationCommissioningServiceV2Tests
+{
+ [Fact]
+ public void FreshSelection_UsesForcedLiveSnapshot_WhenDiscoveryProbeFlagWasNotSet()
+ {
+ var inventory = Inventory(Candidate("LD0/LLN0.RP01"));
+ var availability = Availability(Snapshot("LD0/LLN0.RP01", "LD0"));
+
+ var selected = DynamicReportActivationCommissioningServiceV2.SelectQualifiedUrcbFromFreshAvailability(
+ availability,
+ inventory,
+ "LD0",
+ out var snapshot,
+ out var reason,
+ out _);
+
+ Assert.NotNull(selected);
+ Assert.NotNull(snapshot);
+ Assert.Equal("LD0/LLN0.RP01", selected!.Reference);
+ Assert.Equal(ArMms.MmsRcbDataSetProbeState.ReadSucceeded, snapshot!.DataSetProbeState);
+ Assert.Contains("forcedLiveProbe=ReadSucceeded", reason, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("transactionalProofFields=true", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void FreshSelection_RejectsUnreadDatSet()
+ {
+ var inventory = Inventory(Candidate("LD0/LLN0.RP01"));
+ var availability = Availability(Snapshot(
+ "LD0/LLN0.RP01",
+ "LD0",
+ probeState: ArMms.MmsRcbDataSetProbeState.ReadFailed));
+
+ var selected = DynamicReportActivationCommissioningServiceV2.SelectQualifiedUrcbFromFreshAvailability(
+ availability,
+ inventory,
+ "LD0",
+ out var snapshot,
+ out var reason,
+ out var diagnostics);
+
+ Assert.Null(selected);
+ Assert.Null(snapshot);
+ Assert.Contains("transactionalLeaseEligible=0", reason, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains(diagnostics, line => line.Contains("probe=ReadFailed", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void FreshSelection_PrefersSameLogicalDevice()
+ {
+ var inventory = Inventory(
+ Candidate("LD_OTHER/LLN0.RP01"),
+ Candidate("LD_TARGET/LLN0.RP02"));
+ var availability = Availability(
+ Snapshot("LD_OTHER/LLN0.RP01", "LD_OTHER"),
+ Snapshot("LD_TARGET/LLN0.RP02", "LD_TARGET"));
+
+ var selected = DynamicReportActivationCommissioningServiceV2.SelectQualifiedUrcbFromFreshAvailability(
+ availability,
+ inventory,
+ "LD_TARGET",
+ out _,
+ out _,
+ out _);
+
+ Assert.NotNull(selected);
+ Assert.Equal("LD_TARGET/LLN0.RP02", selected!.Reference);
+ }
+
+ [Fact]
+ public void FreshSelection_FieldObserved0204_060000_IsLeaseEligible()
+ {
+ var inventory = Inventory(Candidate("LD0/LLN0.RP01"));
+ var availability = Availability(Snapshot(
+ "LD0/LLN0.RP01",
+ "LD0",
+ triggerOptions: "0204",
+ optionalFields: "060000"));
+
+ var selected = DynamicReportActivationCommissioningServiceV2.SelectQualifiedUrcbFromFreshAvailability(
+ availability,
+ inventory,
+ "LD0",
+ out var snapshot,
+ out var reason,
+ out var diagnostics);
+
+ Assert.NotNull(selected);
+ Assert.NotNull(snapshot);
+ Assert.Equal("0204", snapshot!.TriggerOptions);
+ Assert.Equal("060000", snapshot.OptionalFields);
+ Assert.Contains("transactionalProofFields=true", reason, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains(diagnostics, line => line.Contains("leaseable=True", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void LeaseGate_RejectsMissingRptId()
+ {
+ var snapshot = Snapshot("LD0/LLN0.RP01", "LD0", reportId: string.Empty);
+
+ var safe = DynamicReportActivationCommissioningServiceV2.IsLeaseableFreeUrcbForG24(snapshot, out var reason);
+
+ Assert.False(safe);
+ Assert.Contains("RptID is empty", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void LeaseGate_RejectsReservedUrcb()
+ {
+ var snapshot = Snapshot("LD0/LLN0.RP01", "LD0", reservationState: "true");
+
+ var safe = DynamicReportActivationCommissioningServiceV2.IsLeaseableFreeUrcbForG24(snapshot, out var reason);
+
+ Assert.False(safe);
+ Assert.Contains("Resv is not explicit false", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void LeaseGate_RejectsNonEmptyOwner()
+ {
+ var snapshot = Snapshot("LD0/LLN0.RP01", "LD0", owner: "01020304");
+
+ var safe = DynamicReportActivationCommissioningServiceV2.IsLeaseableFreeUrcbForG24(snapshot, out var reason);
+
+ Assert.False(safe);
+ Assert.Contains("Owner is non-empty", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static ArMms.MmsReportInventory Inventory(params ArMms.MmsReportControlCandidate[] candidates)
+ {
+ var inventory = new ArMms.MmsReportInventory();
+ foreach (var candidate in candidates)
+ inventory.ReportControls.Add(candidate);
+ return inventory;
+ }
+
+ private static ArMms.MmsRcbAvailabilityResult Availability(params ArMms.MmsRcbAvailabilitySnapshot[] snapshots)
+ => new() { ReportControls = snapshots };
+
+ private static ArMms.MmsReportControlCandidate Candidate(string reference)
+ {
+ var slash = reference.IndexOf('/');
+ var dot = reference.IndexOf('.', slash + 1);
+ return new ArMms.MmsReportControlCandidate
+ {
+ Domain = reference[..slash],
+ LogicalNode = reference[(slash + 1)..dot],
+ Name = reference[(dot + 1)..],
+ Reference = reference,
+ Buffered = false,
+ DataSetReference = string.Empty,
+ DataSetProbeState = ArMms.MmsRcbDataSetProbeState.NotAttempted,
+ ReportId = "RPT-01",
+ TriggerOptions = "0204",
+ OptionalFields = "060000",
+ EnabledState = "false",
+ ReservationState = "false",
+ ReservationTimeSeconds = "0",
+ Status = "Attribute-probed",
+ Attributes = ["DatSet", "RptID", "RptEna", "Resv", "TrgOps", "OptFlds", "GI"]
+ };
+ }
+
+ private static ArMms.MmsRcbAvailabilitySnapshot Snapshot(
+ string reference,
+ string domain,
+ ArMms.MmsRcbDataSetProbeState probeState = ArMms.MmsRcbDataSetProbeState.ReadSucceeded,
+ string triggerOptions = "0204",
+ string optionalFields = "060000",
+ string reportId = "RPT-01",
+ string reservationState = "false",
+ string owner = "")
+ => new()
+ {
+ Reference = reference,
+ Domain = domain,
+ LogicalNode = "LLN0",
+ Name = reference[(reference.LastIndexOf('.') + 1)..],
+ Buffered = false,
+ DataSetReference = string.Empty,
+ DataSetProbeState = probeState,
+ DataSetProbeMessage = probeState == ArMms.MmsRcbDataSetProbeState.ReadSucceeded
+ ? "DatSet item: OK"
+ : "DatSet read rejected",
+ ReportId = reportId,
+ TriggerOptions = triggerOptions,
+ OptionalFields = optionalFields,
+ EnabledState = "false",
+ ReservationState = reservationState,
+ ReservationTimeSeconds = "0",
+ Owner = owner,
+ Availability = probeState == ArMms.MmsRcbDataSetProbeState.ReadSucceeded
+ ? ArMms.MmsRcbOperationalAvailability.NoDataSet
+ : ArMms.MmsRcbOperationalAvailability.Unknown,
+ Confidence = probeState == ArMms.MmsRcbDataSetProbeState.ReadSucceeded
+ ? ArMms.MmsRcbAvailabilityConfidence.Exact
+ : ArMms.MmsRcbAvailabilityConfidence.Reduced,
+ Attributes = ["DatSet", "RptID", "RptEna", "Resv", "TrgOps", "OptFlds", "GI"]
+ };
+}
diff --git a/tests/ARSAS.Tests/DynamicReportCleanupClosureCommissioningServiceTests.cs b/tests/ARSAS.Tests/DynamicReportCleanupClosureCommissioningServiceTests.cs
new file mode 100644
index 000000000..e54048d80
--- /dev/null
+++ b/tests/ARSAS.Tests/DynamicReportCleanupClosureCommissioningServiceTests.cs
@@ -0,0 +1,210 @@
+using ArIED61850Tester.Services;
+using ArMms = AR.Iec61850.Mms;
+
+namespace ARSAS.Tests;
+
+public sealed class DynamicReportCleanupClosureCommissioningServiceTests
+{
+ [Fact]
+ public void FreshClosure_AcceptsFullyReleasedUrcbAndAbsentTemporaryDataSet()
+ {
+ var snapshot = Snapshot();
+
+ var success = DynamicReportCleanupClosureCommissioningService.IsFreshCleanupClosed(
+ snapshot,
+ temporaryDataSetAbsentFromNameList: true,
+ temporaryDataSetDirectoryAbsent: true,
+ associationHealthy: true,
+ out var reason);
+
+ Assert.True(success, reason);
+ Assert.Contains("DatSet empty", reason, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("Resv=false", reason, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("Owner empty", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void FreshClosure_RejectsReservationStillHeld()
+ {
+ var snapshot = Snapshot(reservationState: "true");
+
+ var success = DynamicReportCleanupClosureCommissioningService.IsFreshCleanupClosed(
+ snapshot, true, true, true, out var reason);
+
+ Assert.False(success);
+ Assert.Contains("reservation release is not explicit", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void FreshClosure_RejectsOwnerStillPresent()
+ {
+ var snapshot = Snapshot(owner: "C0A851F0");
+
+ var success = DynamicReportCleanupClosureCommissioningService.IsFreshCleanupClosed(
+ snapshot, true, true, true, out var reason);
+
+ Assert.False(success);
+ Assert.Contains("Owner is still non-empty", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void FreshClosure_RejectsRptEnaStillTrue()
+ {
+ var snapshot = Snapshot(enabledState: "true");
+
+ var success = DynamicReportCleanupClosureCommissioningService.IsFreshCleanupClosed(
+ snapshot, true, true, true, out var reason);
+
+ Assert.False(success);
+ Assert.Contains("RptEna is not explicit false", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void FreshClosure_RejectsResidualRcbDataSetBinding()
+ {
+ var snapshot = Snapshot(dataSetReference: "LD0/LLN0.AR_G24_LEFTOVER");
+
+ var success = DynamicReportCleanupClosureCommissioningService.IsFreshCleanupClosed(
+ snapshot, true, true, true, out var reason);
+
+ Assert.False(success);
+ Assert.Contains("DatSet is not positively proven empty", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void FreshClosure_RejectsTemporaryDataSetStillAdvertised()
+ {
+ var success = DynamicReportCleanupClosureCommissioningService.IsFreshCleanupClosed(
+ Snapshot(), false, true, true, out var reason);
+
+ Assert.False(success);
+ Assert.Contains("still advertised", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void FreshClosure_RejectsTemporaryDataSetDirectoryStillReadable()
+ {
+ var success = DynamicReportCleanupClosureCommissioningService.IsFreshCleanupClosed(
+ Snapshot(), true, false, true, out var reason);
+
+ Assert.False(success);
+ Assert.Contains("still has a readable directory", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void FreshClosure_RejectsAssociationFault()
+ {
+ var success = DynamicReportCleanupClosureCommissioningService.IsFreshCleanupClosed(
+ Snapshot(), true, true, false, out var reason);
+
+ Assert.False(success);
+ Assert.Contains("association is not healthy", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void TemporaryDataSetNameList_RecognizesIecAndMmsForms()
+ {
+ var snapshot = new ArMms.MmsDiscoverySnapshot
+ {
+ DomainVariableLists = new Dictionary>(StringComparer.OrdinalIgnoreCase)
+ {
+ ["LD0"] = ["LLN0$STATIC", "LLN0$OTHER"]
+ }
+ };
+
+ var absent = DynamicReportCleanupClosureCommissioningService.IsTemporaryDataSetAbsentFromNameList(
+ snapshot,
+ "LD0/LLN0.AR_G24_BFD53D73",
+ out var reason);
+
+ Assert.True(absent, reason);
+
+ snapshot = new ArMms.MmsDiscoverySnapshot
+ {
+ DomainVariableLists = new Dictionary>(StringComparer.OrdinalIgnoreCase)
+ {
+ ["LD0"] = ["LLN0$AR_G24_BFD53D73"]
+ }
+ };
+
+ absent = DynamicReportCleanupClosureCommissioningService.IsTemporaryDataSetAbsentFromNameList(
+ snapshot,
+ "LD0/LLN0.AR_G24_BFD53D73",
+ out reason);
+
+ Assert.False(absent);
+ Assert.Contains("still advertised", reason, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void G24C_SourceBoundary_IsReadOnlyAndDoesNotAdvanceProfile()
+ {
+ var source = File.ReadAllText(Path.Combine(
+ RepoRoot(),
+ "Services",
+ "DynamicReportCleanupClosureCommissioningService.cs"));
+
+ Assert.Contains("fresh association", source, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("CheckReportControlAvailabilityAsync", source, StringComparison.Ordinal);
+ Assert.Contains("GetDataSetDirectoryAsync", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("WriteReportAttributeAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("WriteSingleVariableAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("PrepareDynamicRcbCommissioningFieldsAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("ProbeDynamicRcbTriggerOptionsAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("ProbeDynamicRcbOptionalFieldsAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("DefineNamedVariableListAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("DeleteNamedVariableListAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("StartPersistentReportMonitor", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("SaveAsync(", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void G24C_UiUsesDedicatedReadOnlyShortcut()
+ {
+ var source = File.ReadAllText(Path.Combine(RepoRoot(), "DynamicReportQualificationUiBehavior.cs"));
+
+ Assert.Contains("e.Key != Key.C", source, StringComparison.Ordinal);
+ Assert.Contains("RunG24CleanupClosureAsync", source, StringComparison.Ordinal);
+ Assert.Contains("READ-ONLY PHYSICAL MERGE GATE", source, StringComparison.Ordinal);
+ Assert.Contains("ZERO MMS writes", source, StringComparison.Ordinal);
+ }
+
+ private static ArMms.MmsRcbAvailabilitySnapshot Snapshot(
+ string dataSetReference = "",
+ string enabledState = "false",
+ string reservationState = "false",
+ string owner = "")
+ => new()
+ {
+ Reference = "LD0/LLN0.RP01",
+ Domain = "LD0",
+ LogicalNode = "LLN0",
+ Name = "RP01",
+ Buffered = false,
+ DataSetReference = dataSetReference,
+ DataSetProbeState = ArMms.MmsRcbDataSetProbeState.ReadSucceeded,
+ DataSetProbeMessage = "DatSet item: OK",
+ ReportId = "RPT-01",
+ TriggerOptions = "0204",
+ OptionalFields = "060000",
+ EnabledState = enabledState,
+ ReservationState = reservationState,
+ ReservationTimeSeconds = "0",
+ Owner = owner,
+ Availability = ArMms.MmsRcbOperationalAvailability.NoDataSet,
+ Confidence = ArMms.MmsRcbAvailabilityConfidence.Exact,
+ Attributes = ["DatSet", "RptID", "RptEna", "Resv", "TrgOps", "OptFlds", "GI"]
+ };
+
+ private static string RepoRoot()
+ {
+ var current = new DirectoryInfo(AppContext.BaseDirectory);
+ while (current != null)
+ {
+ if (File.Exists(Path.Combine(current.FullName, "ArIED61850Tester.csproj")))
+ return current.FullName;
+ current = current.Parent;
+ }
+ throw new DirectoryNotFoundException("ARSAS repository root not found.");
+ }
+}
diff --git a/tests/ARSAS.Tests/DynamicReportOptionalFieldsProbeCommissioningServiceTests.cs b/tests/ARSAS.Tests/DynamicReportOptionalFieldsProbeCommissioningServiceTests.cs
new file mode 100644
index 000000000..99a3f82ac
--- /dev/null
+++ b/tests/ARSAS.Tests/DynamicReportOptionalFieldsProbeCommissioningServiceTests.cs
@@ -0,0 +1,73 @@
+namespace ARSAS.Tests;
+
+public sealed class DynamicReportOptionalFieldsProbeCommissioningServiceTests
+{
+ [Fact]
+ public void P1_Service_Uses_Isolated_Engine_Probe_And_Correct_061800_Target()
+ {
+ var source = File.ReadAllText(Path.Combine(
+ RepoRoot(),
+ "Services",
+ "DynamicReportOptionalFieldsProbeCommissioningService.cs"));
+
+ Assert.Contains("ProbeDynamicRcbOptionalFieldsAsync(", source, StringComparison.Ordinal);
+ Assert.Contains("ProbeOptionalFields = \"reason-for-inclusion data-set-name\"", source, StringComparison.Ordinal);
+ Assert.Contains("expectedCanonicalRaw=061800", source, StringComparison.Ordinal);
+ Assert.Contains("forced-live proven-empty/free URCB", source, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void P1_Service_Does_Not_Invoke_TrgOps_DataSet_Or_Report_Activation_APIs()
+ {
+ var source = File.ReadAllText(Path.Combine(
+ RepoRoot(),
+ "Services",
+ "DynamicReportOptionalFieldsProbeCommissioningService.cs"));
+
+ Assert.DoesNotContain("ProbeDynamicRcbTriggerOptionsAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain(".PrepareDynamicRcbCommissioningFieldsAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain(".StartPersistentReportMonitor", source, StringComparison.Ordinal);
+ Assert.DoesNotContain(".DefineNamedVariableListAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain(".DeleteNamedVariableListAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain(".BuildDynamicPlan(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain(".SaveAsync(", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void P1_UI_Has_Dedicated_CtrlShiftO_And_Explicit_OptFldsOnly_Warning()
+ {
+ var source = File.ReadAllText(Path.Combine(RepoRoot(), "DynamicReportQualificationUiBehavior.cs"));
+
+ Assert.Contains("e.Key != Key.O", source, StringComparison.Ordinal);
+ Assert.Contains("RunP1OptionalFieldsProbeAsync", source, StringComparison.Ordinal);
+ Assert.Contains("OptFlds ONLY", source, StringComparison.Ordinal);
+ Assert.Contains("canonical raw target 061800", source, StringComparison.Ordinal);
+ Assert.Contains("does NOT write TrgOps, DatSet, Resv, RptEna or GI", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void P1_Evidence_Separates_Semantic_And_Raw_Equality()
+ {
+ var source = File.ReadAllText(Path.Combine(RepoRoot(), "DynamicReportQualificationResultWindow.P1.cs"));
+
+ Assert.Contains("Requested semantic match", source, StringComparison.Ordinal);
+ Assert.Contains("Requested raw exact", source, StringComparison.Ordinal);
+ Assert.Contains("Requested padding-only diff", source, StringComparison.Ordinal);
+ Assert.Contains("Restore semantic match", source, StringComparison.Ordinal);
+ Assert.Contains("Restore raw exact", source, StringComparison.Ordinal);
+ Assert.Contains("Restore padding-only diff", source, StringComparison.Ordinal);
+ }
+
+ private static string RepoRoot()
+ {
+ var current = new DirectoryInfo(AppContext.BaseDirectory);
+ while (current != null)
+ {
+ if (File.Exists(Path.Combine(current.FullName, "ArIED61850Tester.csproj")))
+ return current.FullName;
+ current = current.Parent;
+ }
+
+ throw new DirectoryNotFoundException("ARSAS repository root not found.");
+ }
+}
diff --git a/tests/ARSAS.Tests/DynamicReportTriggerOptionsProbeCommissioningServiceTests.cs b/tests/ARSAS.Tests/DynamicReportTriggerOptionsProbeCommissioningServiceTests.cs
new file mode 100644
index 000000000..67aed03b1
--- /dev/null
+++ b/tests/ARSAS.Tests/DynamicReportTriggerOptionsProbeCommissioningServiceTests.cs
@@ -0,0 +1,72 @@
+namespace ARSAS.Tests;
+
+public sealed class DynamicReportTriggerOptionsProbeCommissioningServiceTests
+{
+ [Fact]
+ public void P0_Service_Uses_Isolated_Engine_Probe_And_Correct_0244_Target()
+ {
+ var source = File.ReadAllText(Path.Combine(
+ RepoRoot(),
+ "Services",
+ "DynamicReportTriggerOptionsProbeCommissioningService.cs"));
+
+ Assert.Contains("ProbeDynamicRcbTriggerOptionsAsync(", source, StringComparison.Ordinal);
+ Assert.Contains("ProbeTriggerOptions = \"dchg gi\"", source, StringComparison.Ordinal);
+ Assert.Contains("expectedCanonicalRaw=0244", source, StringComparison.Ordinal);
+ Assert.Contains("forced-live proven-empty/free URCB", source, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void P0_Service_Does_Not_Invoke_DataSet_Or_Report_Activation_APIs()
+ {
+ var source = File.ReadAllText(Path.Combine(
+ RepoRoot(),
+ "Services",
+ "DynamicReportTriggerOptionsProbeCommissioningService.cs"));
+
+ Assert.DoesNotContain(".PrepareDynamicRcbCommissioningFieldsAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain(".StartPersistentReportMonitor", source, StringComparison.Ordinal);
+ Assert.DoesNotContain(".DefineNamedVariableListAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain(".DeleteNamedVariableListAsync(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain(".BuildDynamicPlan(", source, StringComparison.Ordinal);
+ Assert.DoesNotContain(".SaveAsync(", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void P0_UI_Has_Dedicated_CtrlShiftT_And_Explicit_TrgOpsOnly_Warning()
+ {
+ var source = File.ReadAllText(Path.Combine(RepoRoot(), "DynamicReportQualificationUiBehavior.cs"));
+
+ Assert.Contains("e.Key != Key.T", source, StringComparison.Ordinal);
+ Assert.Contains("RunP0TriggerProbeAsync", source, StringComparison.Ordinal);
+ Assert.Contains("TrgOps ONLY", source, StringComparison.Ordinal);
+ Assert.Contains("canonical raw target 0244", source, StringComparison.Ordinal);
+ Assert.Contains("does NOT write OptFlds, DatSet, Resv, RptEna or GI", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void P0_Evidence_Separates_Semantic_And_Raw_Equality()
+ {
+ var source = File.ReadAllText(Path.Combine(RepoRoot(), "DynamicReportQualificationResultWindow.xaml.cs"));
+
+ Assert.Contains("Requested semantic match", source, StringComparison.Ordinal);
+ Assert.Contains("Requested raw exact", source, StringComparison.Ordinal);
+ Assert.Contains("Requested padding-only diff", source, StringComparison.Ordinal);
+ Assert.Contains("Restore semantic match", source, StringComparison.Ordinal);
+ Assert.Contains("Restore raw exact", source, StringComparison.Ordinal);
+ Assert.Contains("Restore padding-only diff", source, StringComparison.Ordinal);
+ }
+
+ private static string RepoRoot()
+ {
+ var current = new DirectoryInfo(AppContext.BaseDirectory);
+ while (current != null)
+ {
+ if (File.Exists(Path.Combine(current.FullName, "ArIED61850Tester.csproj")))
+ return current.FullName;
+ current = current.Parent;
+ }
+
+ throw new DirectoryNotFoundException("ARSAS repository root not found.");
+ }
+}
diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs
index 0e0ee5dbb..f30b60e40 100644
--- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs
+++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs
@@ -5,22 +5,39 @@ namespace ARSAS.Tests;
public sealed class G1ControlCorrectnessRegressionTests
{
[Fact]
- public void EngineLock_PinsReviewedG2EngineAndPreservesExactG1FieldProvenAncestry()
+ public void EngineLock_PinsReviewedG24P1EngineAndPreservesExactG1FieldProvenAncestry()
{
var root = RepoRoot();
using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "engines", "ARIEC61850.lock.json")));
var json = doc.RootElement;
Assert.Equal("masarray/ARIEC61850", json.GetProperty("repository").GetString());
Assert.Equal("main", json.GetProperty("ref").GetString());
- Assert.Equal("c0e146a6111e67b2bfe85c446bd6cef4933f4b37", json.GetProperty("commit").GetString());
- Assert.Equal(94, json.GetProperty("sourcePullRequest").GetInt32());
+ Assert.Equal("26c85400a4da230c4429e6302847f230385b6687", json.GetProperty("commit").GetString());
+ Assert.Equal(95, json.GetProperty("sourcePullRequest").GetInt32());
var purpose = json.GetProperty("purpose").GetString() ?? string.Empty;
+
+ // G2.4 may advance the engine pin only while the field-proven G1/G2.3/P0/P1 ancestry
+ // and all non-regression reporting/control safety statements remain explicit.
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);
Assert.Contains("object-access-denied", purpose, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("PR #94 adds identity-bound qualification profiles", purpose, StringComparison.OrdinalIgnoreCase);
Assert.Contains("ProductionEligible", purpose, StringComparison.OrdinalIgnoreCase);
Assert.Contains("actual correctly mapped InformationReport", purpose, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("PR #95", purpose, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("bit 0 reserved", purpose, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("bits 1..5 dchg/qchg/dupd/integrity/GI", purpose, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("dchg+GI encodes canonically as 0244", purpose, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("TrgOps-only micro-probe", purpose, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("P1 adds a dedicated one-URCB OptFlds-only", purpose, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("canonical target 061800", purpose, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("never writing TrgOps, DatSet, Resv, RptEna, GI", purpose, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("exact local TCP address", purpose, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("C0A851F0", purpose, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("192.168.81.240", purpose, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("Owner mismatch or unsupported encoding remains a hard failure", purpose, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("Production automatic dynamic BRCB/URCB activation remains quarantined", purpose, StringComparison.OrdinalIgnoreCase);
}
[Fact]
@@ -128,4 +145,4 @@ private static string RepoRoot()
}
throw new DirectoryNotFoundException("ARSAS repository root not found.");
}
-}
\ No newline at end of file
+}