diff --git a/DynamicReportCommandBoundWitnessUiBehavior.cs b/DynamicReportCommandBoundWitnessUiBehavior.cs index f58e4e92..b7e6f2d4 100644 --- a/DynamicReportCommandBoundWitnessUiBehavior.cs +++ b/DynamicReportCommandBoundWitnessUiBehavior.cs @@ -23,7 +23,7 @@ public static void Install() handledEventsToo: true); // Legacy observer-only fallback for the dedicated ControlCommandWindow path. - // V3 command authority is the already-existing runtime Diagnostic event; this + // V3/A3 command authority is the already-existing runtime Diagnostic event; this // routed observer is retained only as non-authoritative fallback evidence. EventManager.RegisterClassHandler( typeof(Button), @@ -57,17 +57,28 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) { if (sender is not MainWindow window || Keyboard.Modifiers != (ModifierKeys.Control | ModifierKeys.Shift) || - e.Key != Key.F) + (e.Key != Key.F && e.Key != Key.A && e.Key != Key.S)) return; e.Handled = true; var device = window.SelectedDevice; + var a3 = e.Key == Key.A; + var shadow = e.Key == Key.S; + var title = shadow + ? "G2.6 Physical Shadow Verification" + : a3 + ? "G2.6-P1 Q0 Target-Locked Auto A3" + : "G2.5-A2.1 Command-Bound Witness"; if (device is null) { MessageBox.Show( window, - "Select one IEC 61850 IED first. G2.5-A2.1 is intentionally bound to one explicit IED and one explicit ARSAS command.", - "G2.5-A2.1 Command-Bound Witness", + shadow + ? "Select the exact identity-compatible InformationReportProven IEC 61850 IED first. G2.6 shadow is bound to the persisted proven RCB/member envelope and will not guess another target." + : a3 + ? "Select the qualified AA1C1F08R4 IEC 61850 IED first. Q0 Auto A3 is hard-bound to the exact proven field identity and AA1C1F08R4Q0/CSWI1.Pos." + : "Select one IEC 61850 IED first. G2.5-A2.1 is intentionally bound to one explicit IED and one explicit ARSAS command.", + title, MessageBoxButton.OK, MessageBoxImage.Information); return; @@ -77,8 +88,8 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) { MessageBox.Show( window, - "G2.5-A2.1 is already armed/running.", - "G2.5-A2.1 Command-Bound Witness", + "A G2 commissioning witness/shadow action is already armed or running.", + title, MessageBoxButton.OK, MessageBoxImage.Information); return; @@ -86,43 +97,28 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) try { - var answer = MessageBox.Show( - window, - $"Arm G2.5-A2.1 V3 command-bound high-speed stimulus witness for {device.Name} ({device.EndpointText})?\n\n" + - "READ-ONLY MMS WITNESS + ONE EXISTING ARSAS CONTROL COMMAND\n\n" + - "V3 captures the exact command from the ALREADY-EXISTING Iec61850MonitorRuntime Diagnostic event 'Control execution requested:' that is emitted before native control execution. It does not add a hook to the SBOw/Operate transaction.\n\n" + - "After the status shows 'G2.5-A2.1 READY — ISSUE ONE ARSAS COMMAND', issue exactly ONE already-proven safe OPEN or CLOSE using the normal ARSAS control UI you normally use. Do NOT use an external/manual stimulus for this phase.\n\n" + - "Once the runtime diagnostic identifies the exact control object, the isolated MMS witness narrows read-only sampling to at most six points around the exact ControlStatusReference. Existing ExecuteControlAsync, SBOw, Operate and CommandTermination behavior is NOT modified, delayed, wrapped or re-issued.\n\n" + - "Once 'G2.5-A2.1 COMMAND CAPTURED' appears, do not issue another command. If a transition is seen, A2.1 samples briefly to classify persistent/latched versus momentary/pulse behavior.\n\n" + - "The witness does not access/mutate RCB or DataSet state, does not send GI, does not save/advance the qualification profile, and production dynamic reporting remains OFF. Do not run another G2 hotkey while A2.1 is armed.\n\n" + - "Continue?", - "G2.5-A2.1 V3 Runtime-Diagnostic Witness", - MessageBoxButton.YesNo, - MessageBoxImage.Warning, - MessageBoxResult.No); - if (answer != MessageBoxResult.Yes) - return; - - window.LastStatusText = $"G2.5-A2.1 V3: opening isolated read-only MMS witness for {device.Name} and preparing pre-command baseline…"; - var progress = new Progress(text => window.LastStatusText = text); - var service = new DynamicReportCommandBoundStimulusWitnessServiceV3(); - var result = await service.RunAsync( - window.A21WitnessRuntime, - device, - device.Signals.ToArray(), - progress, - CancellationToken.None); - window.LastStatusText = result.Summary; - var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; - evidenceWindow.ShowDialog(); + if (shadow) + await RunPhysicalShadowAsync(window, device); + else if (a3) + await RunDeterministicA3Async(window, device); + else + await RunA21Async(window, device); } catch (Exception ex) { - window.LastStatusText = "G2.5-A2.1 V3 stopped locally; production dynamic reporting remains OFF."; + window.LastStatusText = shadow + ? "G2.6 physical shadow stopped fail-closed. Cleanup evidence is retained; profile remains InformationReportProven and ProductionEligible remains OFF." + : a3 + ? "G2.6-P1 Q0 Auto A3 stopped fail-closed. No retry/CLOSE/toggle is issued; ProductionEligible remains OFF." + : "G2.5-A2.1 V3 stopped locally; production dynamic reporting remains OFF."; MessageBox.Show( window, - "G2.5-A2.1 V3 stopped. The witness did not change production reporting policy.\n\n" + ex, - "G2.5-A2.1 V3 Runtime-Diagnostic Witness", + (shadow + ? "G2.6 physical shadow stopped. The collector issues zero automatic control commands and cannot promote the profile. Inspect cleanup/reconnect evidence before retry. Production automatic dynamic reporting remains OFF.\n\n" + : a3 + ? "G2.6-P1 Q0 target-locked Auto A3 stopped. Ctrl+Shift+A is the explicit commissioning action, but the one-shot OPEN is dispatched only after exact identity, Q0 status, Closed-state, command-focus, dchg-arm and final-baseline gates close. A blocked/ambiguous command is never retried and no CLOSE/toggle/auto-restore is issued. Recovery remains transactional and A3 cleanup remains mandatory. ProductionEligible stays OFF.\n\n" + : "G2.5-A2.1 V3 stopped. The witness did not change production reporting policy.\n\n") + ex, + title, MessageBoxButton.OK, MessageBoxImage.Error); } @@ -131,4 +127,96 @@ private static async void OnPreviewKeyDown(object sender, KeyEventArgs e) Interlocked.Exchange(ref _busy, 0); } } + + private static async Task RunA21Async(MainWindow window, Models.Iec61850MonitorDevice device) + { + var answer = MessageBox.Show( + window, + $"Arm G2.5-A2.1 V3 command-bound high-speed stimulus witness for {device.Name} ({device.EndpointText})?\n\n" + + "READ-ONLY MMS WITNESS + ONE EXISTING ARSAS CONTROL COMMAND\n\n" + + "V3 captures the exact command from the ALREADY-EXISTING Iec61850MonitorRuntime Diagnostic event 'Control execution requested:' that is emitted before native control execution. It does not add a hook to the SBOw/Operate transaction.\n\n" + + "After the status shows 'G2.5-A2.1 READY — ISSUE ONE ARSAS COMMAND', issue exactly ONE already-proven safe OPEN or CLOSE using the normal ARSAS control UI you normally use. Do NOT use an external/manual stimulus for this phase.\n\n" + + "Once the runtime diagnostic identifies the exact control object, the isolated MMS witness narrows read-only sampling to at most six points around the exact ControlStatusReference. Existing ExecuteControlAsync, SBOw, Operate and CommandTermination behavior is NOT modified, delayed, wrapped or re-issued.\n\n" + + "Once 'G2.5-A2.1 COMMAND CAPTURED' appears, do not issue another command. If a transition is seen, A2.1 samples briefly to classify persistent/latched versus momentary/pulse behavior.\n\n" + + "The witness does not access/mutate RCB or DataSet state, does not send GI, does not save/advance the qualification profile, and production dynamic reporting remains OFF. Do not run another G2 hotkey while A2.1 is armed.\n\n" + + "Continue?", + "G2.5-A2.1 V3 Runtime-Diagnostic Witness", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); + if (answer != MessageBoxResult.Yes) + return; + + window.LastStatusText = $"G2.5-A2.1 V3: opening isolated read-only MMS witness for {device.Name} and preparing pre-command baseline…"; + var progress = new Progress(text => window.LastStatusText = text); + var service = new DynamicReportCommandBoundStimulusWitnessServiceV3(); + var result = await service.RunAsync( + window.A21WitnessRuntime, + device, + device.Signals.ToArray(), + progress, + CancellationToken.None); + window.LastStatusText = result.Summary; + var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; + evidenceWindow.ShowDialog(); + } + + private static async Task RunDeterministicA3Async(MainWindow window, Models.Iec61850MonitorDevice device) + { + // Ctrl+Shift+A itself is the explicit commissioning action. There are deliberately + // no modal arm/recovery/command dialogs in the successful path: the coordinator is + // hard-bound to the already-proven field identity and Q0 CSWI1.Pos, performs all + // read-only/transactional gates first, and dispatches one OPEN only after the A3 + // final baseline is ready. Any failed gate sends zero commands. + window.LastStatusText = + $"G2.6-P1 Q0 AUTO starting for {device.Name}: exact target {DynamicReportQ0TargetLockedAutoA3CommissioningService.TargetControlReference}; one-shot OPEN only from Closed; no retry/CLOSE/toggle/auto-restore…"; + + var progress = new Progress(text => window.LastStatusText = text); + var service = new DynamicReportQ0TargetLockedAutoA3CommissioningService(); + var result = await service.RunAsync( + window.A21WitnessRuntime, + device, + device.Signals.ToArray(), + progress, + CancellationToken.None); + + window.LastStatusText = result.Summary; + var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; + evidenceWindow.ShowDialog(); + } + + private static async Task RunPhysicalShadowAsync(MainWindow window, Models.Iec61850MonitorDevice device) + { + var answer = MessageBox.Show( + window, + $"Start G2.6 physical shadow verification for {device.Name} ({device.EndpointText})?\n\n" + + "TWO PHYSICAL PHASES + ONE DELIBERATE RECONNECT\n\n" + + "The collector will use the exact persisted InformationReportProven URCB/member envelope. Each phase opens an independent READ-ONLY MMS polling association plus one transactional dchg-only report association.\n\n" + + "When the status shows 'G2.6 SHADOW PHASE 1 READY — CAUSE ONE SAFE CHANGE', cause exactly ONE already-approved safe process/status change affecting the proven envelope. After cleanup, the collector deliberately reconnects both paths and will show a second READY marker for one more safe change.\n\n" + + "Ctrl+Shift+S issues ZERO automatic control commands. It sends no GI, never edits the persisted qualification profile, never marks ProductionEligible, and retains mandatory monitor/proof-field/fresh-association cleanup.\n\n" + + "Quality/timestamp evidence is never invented. If the scalar report envelope does not physically carry paired q/t, the strict PR #99 shadow gate will remain BLOCKED/FAIL and that field finding is intentional.\n\n" + + "Independent Smart Control/static-report regressions are NOT assumed by this action, so even a shadow PASS is not an automatic production promotion.\n\n" + + "Continue?", + "G2.6 Physical Shadow Verification", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); + if (answer != MessageBoxResult.Yes) + return; + + window.LastStatusText = $"G2.6 physical shadow starting for {device.Name}: validating exact profile, opening read-only polling reference and transactional dchg report path…"; + var progress = new Progress(text => window.LastStatusText = text); + var service = new DynamicReportShadowVerificationCommissioningService(); + var result = await service.RunAsync( + device, + device.Signals.ToArray(), + progress, + controlRegressionPassed: false, + staticReportingRegressionPassed: false, + CancellationToken.None); + + window.LastStatusText = result.Summary; + var evidenceWindow = new DynamicReportQualificationResultWindow(result) { Owner = window }; + evidenceWindow.ShowDialog(); + } } diff --git a/DynamicReportQualificationResultWindow.G26P1A3.cs b/DynamicReportQualificationResultWindow.G26P1A3.cs new file mode 100644 index 00000000..3c4ff462 --- /dev/null +++ b/DynamicReportQualificationResultWindow.G26P1A3.cs @@ -0,0 +1,93 @@ +using System.Text; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +internal partial class DynamicReportQualificationResultWindow +{ + internal DynamicReportQualificationResultWindow(DynamicReportCommandBoundA3CommissioningResult result) + { + ArgumentNullException.ThrowIfNull(result); + InitializeComponent(); + + Title = "G2.6-P1 Deterministic A3 dchg Proof Evidence"; + HeaderText.Text = "G2.6-P1 Deterministic A3 — Command → dchg Report"; + SummaryText.Text = result.Summary; + StateText.Text = result.IsSuccess + ? "A3 Command-Bound dchg Proven" + : result.IsBlocked + ? "Blocked" + : "A3 Not Proven"; + EvidenceTextBox.Text = BuildG26P1A3Evidence(result); + + if (result.IsSuccess) + SetPassBadge(); + } + + private static string BuildG26P1A3Evidence(DynamicReportCommandBoundA3CommissioningResult result) + { + var builder = new StringBuilder(); + builder.AppendLine("ARSAS G2.6-P1 DETERMINISTIC A3 COMMAND-BOUND DCHG EVIDENCE"); + builder.AppendLine(new string('=', 76)); + builder.AppendLine($"Result: {result.Summary}"); + builder.AppendLine($"Blocked: {result.IsBlocked}"); + builder.AppendLine($"A3 success: {result.IsSuccess}"); + builder.AppendLine($"Command/report correlation: {result.CommandBoundReportCorrelationProven}"); + + builder.AppendLine(); + builder.AppendLine("EXISTING ARSAS COMMAND"); + builder.AppendLine($"Captured: {result.Witness.CommandCaptured}"); + builder.AppendLine($"Object: {TextOrDash(result.Witness.CommandSignalReference)}"); + builder.AppendLine($"Control status: {TextOrDash(result.Witness.ControlStatusReference)}"); + builder.AppendLine($"Requested value: {TextOrDash(result.Witness.RequestedValue)}"); + builder.AppendLine($"Source: {TextOrDash(result.Witness.CommandSource)}"); + builder.AppendLine($"Observed at UTC: {result.Witness.CommandObservedAtUtc?.ToString("O") ?? "-"}"); + builder.AppendLine($"Command-bound transition proven: {result.Witness.CommandBoundTransitionProven}"); + builder.AppendLine($"Read-only witness association healthy: {result.Witness.AssociationHealthy}"); + builder.AppendLine($"Witness cycles/read failures: {result.Witness.SampleCycles}/{result.Witness.ReadFailures}"); + + if (result.Witness.Transitions.Count > 0) + { + builder.AppendLine(); + builder.AppendLine("COMMAND-BOUND QUALIFIED-MEMBER TRANSITIONS"); + foreach (var transition in result.Witness.Transitions) + { + builder.AppendLine( + $"[{transition.Index}] {transition.MemberReference} ({transition.PointReference}) {transition.BeforeValue} -> {transition.AfterValue} at {transition.ObservedAtUtc:O}"); + } + } + + builder.AppendLine(); + builder.AppendLine("DCHG INFORMATIONREPORT"); + builder.AppendLine($"Core success: {result.CoreResult.IsSuccess}"); + builder.AppendLine($"Activation proven: {result.CoreResult.ActivationProven}"); + builder.AppendLine($"Spontaneous dchg proven: {result.CoreResult.SpontaneousDataChangeProven}"); + builder.AppendLine($"URCB: {TextOrDash(result.CoreResult.RcbReference)}"); + builder.AppendLine($"Temporary DataSet: {TextOrDash(result.CoreResult.DataSetReference)}"); + builder.AppendLine($"RptID: {TextOrDash(result.CoreResult.ReportId)}"); + builder.AppendLine($"Report included indexes: [{string.Join(",", result.CoreResult.IncludedIndexes)}]"); + builder.AppendLine($"Report reasons: [{string.Join(",", result.CoreResult.Reasons)}]"); + builder.AppendLine($"Correlated command/report indexes: [{string.Join(",", result.CorrelatedIndexes)}]"); + foreach (var member in result.CorrelatedMemberReferences) + builder.AppendLine("- correlated member: " + member); + + builder.AppendLine(); + builder.AppendLine("CLEANUP / RELEASE"); + builder.AppendLine($"Monitor cleanup: {result.CoreResult.MonitorCleanupSucceeded}"); + builder.AppendLine($"TrgOps/OptFlds restore: {result.CoreResult.ProofFieldRestoreSucceeded}"); + builder.AppendLine($"Fresh-association cleanup closure: {result.CoreResult.FreshCleanupClosureSucceeded}"); + builder.AppendLine($"Report association healthy after proof: {result.CoreResult.AssociationHealthyAfterReport}"); + + builder.AppendLine(); + builder.AppendLine("FULL EVIDENCE"); + foreach (var line in result.EvidenceLines) + builder.AppendLine(line); + + builder.AppendLine(); + builder.AppendLine("SAFETY STATE"); + builder.AppendLine("A3 command-bound dchg PASS != ProductionEligible."); + builder.AppendLine("This commissioning action does not save or advance the persisted profile."); + builder.AppendLine("Production automatic dynamic reporting remains OFF until later shadow verification and G2.6 regression acceptance explicitly mark the identity ProductionEligible."); + return builder.ToString(); + } +} diff --git a/DynamicReportQualificationResultWindow.G26Shadow.cs b/DynamicReportQualificationResultWindow.G26Shadow.cs new file mode 100644 index 00000000..be1522d6 --- /dev/null +++ b/DynamicReportQualificationResultWindow.G26Shadow.cs @@ -0,0 +1,115 @@ +using System.Text; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +internal partial class DynamicReportQualificationResultWindow +{ + internal DynamicReportQualificationResultWindow(DynamicReportShadowVerificationCommissioningResult result) + { + ArgumentNullException.ThrowIfNull(result); + InitializeComponent(); + + Title = "G2.6 Physical Shadow Verification Evidence"; + HeaderText.Text = "G2.6 Report vs Independent MMS Shadow"; + SummaryText.Text = result.Summary; + StateText.Text = result.IsBlocked + ? "Blocked" + : result.ShadowPassed + ? "Shadow Passed / Production OFF" + : result.PhysicalCollectionCompleted + ? "Collected / Shadow Not Passed" + : "Incomplete"; + EvidenceTextBox.Text = BuildG26ShadowEvidence(result); + + if (result.ShadowPassed) + SetPassBadge(); + } + + private static string BuildG26ShadowEvidence(DynamicReportShadowVerificationCommissioningResult result) + { + var builder = new StringBuilder(); + builder.AppendLine("ARSAS G2.6 PHYSICAL SHADOW VERIFICATION EVIDENCE"); + builder.AppendLine(new string('=', 68)); + builder.AppendLine($"Result: {result.Summary}"); + builder.AppendLine($"Blocked: {result.IsBlocked}"); + builder.AppendLine($"Physical collection completed: {result.PhysicalCollectionCompleted}"); + builder.AppendLine($"Typed shadow passed: {result.ShadowPassed}"); + builder.AppendLine($"Cleanup succeeded: {result.CleanupSucceeded}"); + builder.AppendLine($"Deliberate reconnect proven: {result.ReconnectProven}"); + builder.AppendLine($"Exact RCB: {Dash(result.RcbReference)}"); + builder.AppendLine($"Exact member count: {result.MemberReferences.Count}"); + + if (result.MemberReferences.Count > 0) + { + builder.AppendLine(); + builder.AppendLine("EXACT INFORMATIONREPORT-PROVEN MEMBER ENVELOPE"); + for (var index = 0; index < result.MemberReferences.Count; index++) + builder.AppendLine($"[{index}] {result.MemberReferences[index]}"); + } + + if (result.Evidence is not null) + { + var evidence = result.Evidence; + builder.AppendLine(); + builder.AppendLine("PHYSICAL SHADOW COUNTERS"); + builder.AppendLine($"Evidence ID: {evidence.EvidenceId}"); + builder.AppendLine($"Report observations: {evidence.ReportObservations.Count}"); + builder.AppendLine($"Independent MMS polls: {evidence.PollObservations.Count}"); + builder.AppendLine($"Reconnects: {evidence.SuccessfulReconnects}/{evidence.ReconnectAttempts}"); + builder.AppendLine($"Report resubscriptions after reconnect: {evidence.ReportResubscriptionsAfterReconnect}"); + builder.AppendLine($"Poll reference recoveries after reconnect: {evidence.PollReferenceRecoveriesAfterReconnect}"); + builder.AppendLine($"Dynamic activation attempts: {evidence.DynamicActivationAttempts}"); + builder.AppendLine($"Report quality observations: {evidence.ReportObservations.Count(item => !string.IsNullOrWhiteSpace(item.Quality))}"); + builder.AppendLine($"Report device timestamp observations: {evidence.ReportObservations.Count(item => item.DeviceTimestampUtc.HasValue)}"); + } + + if (result.Acceptance?.Shadow is not null) + { + var shadow = result.Acceptance.Shadow; + builder.AppendLine(); + builder.AppendLine("TYPED ARIEC SHADOW GATES"); + builder.AppendLine($"Exact member identity: {shadow.ExactMemberIdentityPassed}"); + builder.AppendLine($"Value parity: {shadow.ValueParityPassed}"); + builder.AppendLine($"Quality parity: {shadow.QualityParityPassed}"); + builder.AppendLine($"Device timestamp parity: {shadow.TimestampParityPassed}"); + builder.AppendLine($"Report order: {shadow.ReportOrderPassed}"); + builder.AppendLine($"No missing report edges: {shadow.NoMissingReportEdgesPassed}"); + builder.AppendLine($"No duplicate report edges: {shadow.NoDuplicateReportEdgesPassed}"); + builder.AppendLine($"Polling authority: {shadow.PollingAuthorityGuardPassed}"); + builder.AppendLine($"Reconnect regression: {shadow.ReconnectRegressionPassed}"); + builder.AppendLine($"No repeated mutation loop: {shadow.NoRepeatedMutationLoopPassed}"); + foreach (var failure in shadow.Failures) + builder.AppendLine("FAIL: " + failure); + } + + if (result.Acceptance?.ProductionAcceptanceCandidate is not null) + { + var candidate = result.Acceptance.ProductionAcceptanceCandidate; + builder.AppendLine(); + builder.AppendLine("STRICT PRODUCTION-ACCEPTANCE CANDIDATE — NOT PERSISTED"); + builder.AppendLine($"Smart Control regression: {candidate.ControlRegressionPassed}"); + builder.AppendLine($"Static reporting regression: {candidate.StaticReportingRegressionPassed}"); + builder.AppendLine($"Dynamic InformationReport regression: {candidate.DynamicInformationReportRegressionPassed}"); + builder.AppendLine($"Polling authority: {candidate.PollingAuthorityGuardPassed}"); + builder.AppendLine($"Reconnect regression: {candidate.ReconnectRegressionPassed}"); + builder.AppendLine($"Observed q/t regression: {candidate.QualityRegressionPassed}"); + builder.AppendLine($"No mutation loop: {candidate.NoRepeatedMutationLoopPassed}"); + builder.AppendLine($"All passed: {candidate.AllPassed}"); + } + + builder.AppendLine(); + builder.AppendLine("DETAILED EVIDENCE"); + foreach (var line in result.EvidenceLines) + builder.AppendLine(line); + + builder.AppendLine(); + builder.AppendLine("STATE BOUNDARY"); + builder.AppendLine("Shadow PASS != ProductionEligible."); + builder.AppendLine("The persisted profile remains InformationReportProven and production automatic dynamic reporting remains OFF."); + return builder.ToString(); + } + + private static string Dash(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); +} diff --git a/Services/DynamicReportCommandBoundDataChangeCommissioningService.cs b/Services/DynamicReportCommandBoundDataChangeCommissioningService.cs new file mode 100644 index 00000000..10cea36b --- /dev/null +++ b/Services/DynamicReportCommandBoundDataChangeCommissioningService.cs @@ -0,0 +1,705 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +internal sealed class DynamicReportCommandBoundA3Transition +{ + public int Index { get; init; } + public string MemberReference { get; init; } = string.Empty; + public string PointReference { get; init; } = string.Empty; + public string BeforeValue { get; init; } = string.Empty; + public string AfterValue { get; init; } = string.Empty; + public DateTimeOffset ObservedAtUtc { get; init; } +} + +internal sealed class DynamicReportCommandBoundA3WitnessResult +{ + public bool BaselineCaptured { get; init; } + public bool CommandCaptured { get; init; } + public bool CommandBoundTransitionProven { get; init; } + public bool AssociationHealthy { get; init; } + public string CommandSignalReference { get; init; } = string.Empty; + public string ControlStatusReference { get; init; } = string.Empty; + public string RequestedValue { get; init; } = string.Empty; + public string CommandSource { get; init; } = string.Empty; + public DateTimeOffset? CommandObservedAtUtc { get; init; } + public int SampleCycles { get; init; } + public int ReadFailures { get; init; } + public IReadOnlyList Transitions { get; init; } = Array.Empty(); + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); + public string Summary { get; init; } = string.Empty; +} + +internal sealed class DynamicReportCommandBoundA3CommissioningResult +{ + public bool IsSuccess { get; init; } + public bool IsBlocked { get; init; } + public bool CommandBoundReportCorrelationProven { get; init; } + public bool NativeControlAcceptanceProven { get; init; } + public bool ReportAfterCommandProven { get; init; } + public DateTimeOffset? NativeControlAcceptedAtUtc { get; init; } + public IReadOnlyList CorrelatedIndexes { get; init; } = Array.Empty(); + public IReadOnlyList CorrelatedMemberReferences { get; init; } = Array.Empty(); + public DynamicReportSpontaneousDataChangeCommissioningResult CoreResult { get; init; } = new(); + public DynamicReportCommandBoundA3WitnessResult Witness { get; init; } = new(); + public string Summary { get; init; } = string.Empty; + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); +} + +internal sealed record DynamicReportCommandBoundA3EligibleTarget( + SignalDefinition Signal, + ArMms.MmsFcResolvedPoint ExactStatusPoint, + IReadOnlyList QualifiedFocusPoints, + IReadOnlyList QualifiedIndexes); + +/// +/// G2.6-P1 deterministic A3 wrapper. +/// +/// The reporting path remains the existing G2.5-A one-URCB dchg-only / NO-GI transaction. +/// A second isolated MMS association is read-only and is used only to prove that the exact +/// pre-existing ARSAS control command caused a transition on a member that belongs to the +/// exact G2.4-proven DataSet envelope. The command itself remains owned by the existing +/// Iec61850MonitorRuntime control path; this service observes the runtime request plus the +/// later successful native-control diagnostic and never calls ExecuteControlAsync. +/// +/// PASS therefore requires all of the following in one bounded armed window: +/// - exact InformationReportProven identity/profile and G2.4 RCB/member sequence; +/// - at least one ARSAS control object whose A2.1 focus chain intersects that exact sequence; +/// - core dchg-only activation/report/cleanup success with GI disabled; +/// - one exact runtime-observed ARSAS command after the witness baseline is ready; +/// - later successful native control-result/wire evidence for that exact request; +/// - a post-command MMS transition on a qualified command-focus member; +/// - the dchg InformationReport was received strictly after the captured command and includes the same DataSet index. +/// +/// This service never saves or advances the qualification profile and cannot set +/// ProductionEligible. Production automatic dynamic reporting remains a later gate. +/// +internal sealed class DynamicReportCommandBoundDataChangeCommissioningService +{ + internal const string ReadyMarker = "G2.6-P1 A3 READY — ISSUE ONE ARSAS COMMAND"; + internal const string CommandCapturedMarker = "G2.6-P1 A3 COMMAND CAPTURED"; + internal const string TransitionMarker = "G2.6-P1 A3 COMMAND-BOUND TRANSITION"; + internal const string NativeAcceptedMarker = "G2.6-P1 A3 NATIVE CONTROL ACCEPTED"; + internal static readonly TimeSpan AuxiliaryAssociationTimeout = TimeSpan.FromSeconds(10); + internal static readonly TimeSpan CommandWaitWindow = TimeSpan.FromSeconds(45); + internal static readonly TimeSpan CommandTransitionWindow = TimeSpan.FromSeconds(5); + internal static readonly TimeSpan PostTransitionSettleWindow = TimeSpan.FromMilliseconds(350); + internal static readonly TimeSpan InterCycleDelay = TimeSpan.FromMilliseconds(1); + + private readonly DynamicReportQualificationProfileStore _profileStore; + + public DynamicReportCommandBoundDataChangeCommissioningService( + DynamicReportQualificationProfileStore? profileStore = null) + { + _profileStore = profileStore ?? new DynamicReportQualificationProfileStore(); + } + + public async Task RunAsync( + Iec61850MonitorRuntime runtime, + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(fullModelSignals); + + var evidence = new List + { + "G2.6-P1 A3 contract: exact existing ARSAS command -> accepted native MMS control result -> read-only command-bound qualified-member transition -> post-command dchg InformationReport on the same DataSet index -> mandatory G2.5-A cleanup.", + "G2.6-P1 A3 control safety: this service never calls ExecuteControlAsync and never writes SBO/SBOw/Operate/Cancel; command authority remains the existing Iec61850MonitorRuntime path. Request diagnostics alone cannot prove PASS.", + "G2.6-P1 A3 report safety: core path is strict dchg-only with GI=false, integrity=false, qchg=false and dupd=false. The selected valid report receive timestamp must be strictly after the captured command time.", + "G2.6-P1 A3 profile safety: persisted InformationReportProven evidence is read-only; this service cannot save, advance or mark ProductionEligible." + }; + + ArMms.MmsDynamicReportIedIdentity identity; + try + { + identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return Blocked("A3 identity preflight failed: " + ex.Message, evidence); + } + + var loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + evidence.Add($"G2.6-P1 A3 profile: exists={loaded.Exists}; valid={loaded.IsValid}; state={loaded.Profile?.State.ToString() ?? "-"}; reason={loaded.Reason}"); + if (!loaded.IsValid || loaded.Profile is null || + loaded.Profile.State != ArMms.MmsDynamicReportQualificationState.InformationReportProven || + loaded.Profile.RcbActivationProof?.IsSuccess != true || + loaded.Profile.InformationReportProof?.IsSuccess != true) + { + return Blocked("A3 requires the exact identity-compatible InformationReportProven G2.4 profile.", evidence); + } + + var profile = loaded.Profile; + var qualifiedReferences = profile.RcbActivationProof.MemberReferences.ToArray(); + if (qualifiedReferences.Length == 0) + return Blocked("A3 profile contains no exact G2.4 member sequence.", evidence); + + var commandSignals = fullModelSignals + .Where(signal => signal.IsControlSignal && !string.IsNullOrWhiteSpace(signal.ControlStatusReference)) + .Distinct() + .ToArray(); + if (commandSignals.Length == 0) + return Blocked("No live control object exposes ControlStatusReference; A3 will not guess command/status correlation.", evidence); + if (commandSignals.Any(signal => signal.ControlCommandBusy)) + return Blocked("A control command is already in progress. A3 must be armed before the one test command starts.", evidence); + + await using var witnessSession = new ArMms.MmsClientSession(); + ArMms.MmsDiscoveryResult witnessDiscovery; + try + { + await witnessSession.ConnectAsync( + device.IpAddress, + device.Port, + AuxiliaryAssociationTimeout, + cancellationToken).ConfigureAwait(false); + evidence.Add($"G2.6-P1 A3 witness association ready: state={witnessSession.State}; localTcpAddress={TextOrDash(witnessSession.LocalTcpAddress)}; READ-ONLY=true"); + + witnessDiscovery = await witnessSession.DiscoverAsync( + probeReportAttributes: false, + maxReportAttributeProbes: 0, + cancellationToken: cancellationToken, + readDataSetDirectories: false, + maxDataSetDirectoryReads: 0).ConfigureAwait(false); + evidence.Add("G2.6-P1 A3 witness discovery: " + witnessDiscovery.Summary); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + evidence.Add($"G2.6-P1 A3 witness preflight exception: {ex.GetType().Name}: {ex.Message}"); + return Blocked("A3 could not establish its isolated read-only MMS witness association.", evidence); + } + + if (!DynamicReportActivationCommissioningService.TryResolveExactQualifiedMembers( + witnessDiscovery.IedDirectory, + qualifiedReferences, + out var exactQualifiedPoints, + out var memberReason)) + { + evidence.Add("G2.6-P1 A3 exact member resolution failed: " + memberReason); + return Blocked("The exact G2.4-proven member sequence no longer resolves on the live IED.", evidence); + } + + var eligibleTargets = BuildEligibleCommandTargets( + witnessDiscovery.IedDirectory, + commandSignals, + qualifiedReferences, + evidence); + if (eligibleTargets.Count == 0) + { + evidence.Add("G2.6-P1 A3 preflight: no command focus chain intersects the exact G2.4 DataSet envelope. No RCB mutation was attempted."); + return Blocked( + "No current ARSAS command has a command-bound A2.1 status candidate inside the exact G2.4-proven member envelope. Re-qualify an envelope containing CSWI/XCBR status before A3.", + evidence); + } + + evidence.Add("G2.6-P1 A3 eligible commands: " + string.Join(" | ", eligibleTargets.Select(target => + $"{target.Signal.ObjectReference} -> status={target.ExactStatusPoint.UserReference}; qualifiedIndexes=[{string.Join(",", target.QualifiedIndexes)}]"))); + + var armed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var commandCapture = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var nativeCommandAcceptance = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var witnessReady = 0; + + void RuntimeDiagnosticHandler(DiagnosticEntry entry) + { + if (Volatile.Read(ref witnessReady) == 1 && + DynamicReportCommandBoundStimulusWitnessServiceV3.TryBuildRuntimeIntent( + entry, + device, + fullModelSignals, + out var intent) && intent is not null && + eligibleTargets.Any(target => ReferenceEquals(target.Signal, intent.Signal) || + SameReference(target.Signal.ObjectReference, intent.Signal.ObjectReference))) + { + commandCapture.TrySetResult(intent); + } + + if (!commandCapture.Task.IsCompletedSuccessfully) + return; + + var captured = commandCapture.Task.Result; + if (!IsAcceptedNativeControlResultDiagnostic(entry, captured)) + return; + + nativeCommandAcceptance.TrySetResult(ToUtc(entry.Time)); + } + + runtime.Diagnostic += RuntimeDiagnosticHandler; + using var witnessCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var relay = new RelayProgress(text => + { + if (text.Contains(DynamicReportStimulusWitnessCommissioningService.ArmedMarker, StringComparison.OrdinalIgnoreCase)) + { + armed.TrySetResult(true); + progress?.Report("G2.6-P1 A3: dchg-only report path is ARMED with NO GI; capturing the final pre-command read-only baseline…"); + return; + } + progress?.Report(text); + }); + + var witnessTask = RunCommandWitnessAsync( + witnessSession, + exactQualifiedPoints, + qualifiedReferences, + eligibleTargets, + armed.Task, + commandCapture.Task, + ready => Volatile.Write(ref witnessReady, ready ? 1 : 0), + progress, + witnessCancellation.Token); + + DynamicReportSpontaneousDataChangeCommissioningResult coreResult; + try + { + var coreService = new DynamicReportSpontaneousDataChangeCommissioningService(_profileStore); + coreResult = await coreService.RunAsync( + device, + fullModelSignals, + relay, + cancellationToken).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref witnessReady, 0); + runtime.Diagnostic -= RuntimeDiagnosticHandler; + if (!armed.Task.IsCompleted) + witnessCancellation.Cancel(); + } + + DynamicReportCommandBoundA3WitnessResult witnessResult; + try + { + witnessResult = await witnessTask.ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + witnessResult = new DynamicReportCommandBoundA3WitnessResult + { + Summary = "A3 command witness was cancelled because the core report transaction never reached ARMED.", + EvidenceLines = ["G2.6-P1 A3 witness: core path did not reach ARMED; no command-bound conclusion is possible."] + }; + } + + evidence.AddRange(coreResult.EvidenceLines.Select(line => "CORE/" + line)); + evidence.AddRange(witnessResult.EvidenceLines.Select(line => "WITNESS/" + line)); + + var nativeControlAccepted = nativeCommandAcceptance.Task.IsCompletedSuccessfully; + var nativeAcceptedAtUtc = nativeControlAccepted ? nativeCommandAcceptance.Task.Result : (DateTimeOffset?)null; + if (nativeControlAccepted) + evidence.Add($"{NativeAcceptedMarker}: object={witnessResult.CommandSignalReference}; requested={witnessResult.RequestedValue}; acceptedAt={nativeAcceptedAtUtc:O}; source=Iec61850MonitorRuntime successful native-control diagnostic."); + else if (witnessResult.CommandCaptured) + evidence.Add("G2.6-P1 A3 native control acceptance: NOT PROVEN. A request diagnostic alone is insufficient; rejected/NotSent/ambiguous control cannot satisfy PASS."); + + var reportAfterCommand = witnessResult.CommandObservedAtUtc.HasValue && + coreResult.ReportReceivedAtUtc.HasValue && + coreResult.ReportReceivedAtUtc.Value > witnessResult.CommandObservedAtUtc.Value; + evidence.Add($"G2.6-P1 A3 report ordering: commandAt={witnessResult.CommandObservedAtUtc?.ToString("O") ?? "-"}; reportReceivedAt={coreResult.ReportReceivedAtUtc?.ToString("O") ?? "-"}; strictlyAfterCommand={reportAfterCommand}."); + + var changedIndexes = witnessResult.Transitions + .Select(transition => transition.Index) + .Distinct() + .OrderBy(index => index) + .ToArray(); + var correlatedIndexes = CorrelateIndexes(coreResult.IncludedIndexes, changedIndexes); + var correlatedMembers = correlatedIndexes + .Where(index => index >= 0 && index < qualifiedReferences.Length) + .Select(index => qualifiedReferences[index]) + .ToArray(); + + var correlation = coreResult.SpontaneousDataChangeProven && + witnessResult.CommandCaptured && + nativeControlAccepted && + witnessResult.CommandBoundTransitionProven && + reportAfterCommand && + correlatedIndexes.Length > 0; + var success = coreResult.IsSuccess && correlation; + + string diagnosis; + if (success) + { + diagnosis = $"G2.6-P1 A3 PASS: exact ARSAS command {witnessResult.CommandSignalReference} had successful native control evidence, produced a command-bound transition, and a later dchg InformationReport included the same exact DataSet index(es) [{string.Join(",", correlatedIndexes)}]; monitor/proof-field/fresh-association cleanup all passed."; + } + else if (!coreResult.ActivationProven) + { + diagnosis = "A3 did not reach a proven dchg-only ARMED state; command/report correlation is inconclusive."; + } + else if (!witnessResult.CommandCaptured) + { + diagnosis = "A3 report path armed, but no eligible existing ARSAS command was captured after the read-only baseline became ready."; + } + else if (!nativeControlAccepted) + { + diagnosis = "A3 captured a control request, but successful native MMS control-result evidence for that exact request was not observed. Request intent alone cannot prove command acceptance."; + } + else if (!witnessResult.CommandBoundTransitionProven) + { + diagnosis = "A3 captured and natively accepted the exact ARSAS command, but no qualified command-focus member changed in the bounded high-speed witness window."; + } + else if (!coreResult.SpontaneousDataChangeProven) + { + diagnosis = $"A3 captured an accepted command and witnessed qualified DataSet index(es) [{string.Join(",", changedIndexes)}] change, but no valid dchg InformationReport arrived. This isolates the remaining fault to dchg/report emission or receive-path evidence."; + } + else if (!reportAfterCommand) + { + diagnosis = $"A3 received a valid dchg report at {coreResult.ReportReceivedAtUtc?.ToString("O") ?? ""}, but it was not received strictly after the captured command at {witnessResult.CommandObservedAtUtc?.ToString("O") ?? ""}. Pre-command report traffic cannot satisfy command-bound A3."; + } + else if (correlatedIndexes.Length == 0) + { + diagnosis = $"A3 received a valid post-command dchg report, but its included indexes [{string.Join(",", coreResult.IncludedIndexes)}] did not match command-bound changed indexes [{string.Join(",", changedIndexes)}]."; + } + else + { + diagnosis = "A3 command/report correlation did not close every required gate."; + } + + evidence.Add($"G2.6-P1 A3 combined: coreSuccess={coreResult.IsSuccess}; activation={coreResult.ActivationProven}; dchg={coreResult.SpontaneousDataChangeProven}; cleanup={coreResult.MonitorCleanupSucceeded}/{coreResult.ProofFieldRestoreSucceeded}/{coreResult.FreshCleanupClosureSucceeded}; command={witnessResult.CommandCaptured}; nativeAccepted={nativeControlAccepted}; commandTransition={witnessResult.CommandBoundTransitionProven}; reportAfterCommand={reportAfterCommand}; changed=[{string.Join(",", changedIndexes)}]; reportIncluded=[{string.Join(",", coreResult.IncludedIndexes)}]; correlated=[{string.Join(",", correlatedIndexes)}]; success={success}"); + evidence.Add("G2.6-P1 A3 diagnosis: " + diagnosis); + evidence.Add("G2.6-P1 A3 state: profile remains InformationReportProven. Production automatic dynamic reporting remains OFF; shadow/regression acceptance is still required before ProductionEligible."); + + return new DynamicReportCommandBoundA3CommissioningResult + { + IsSuccess = success, + CommandBoundReportCorrelationProven = correlation, + NativeControlAcceptanceProven = nativeControlAccepted, + ReportAfterCommandProven = reportAfterCommand, + NativeControlAcceptedAtUtc = nativeAcceptedAtUtc, + CorrelatedIndexes = correlatedIndexes, + CorrelatedMemberReferences = correlatedMembers, + CoreResult = coreResult, + Witness = witnessResult, + Summary = diagnosis + " Profile remains InformationReportProven; production dynamic reporting remains OFF.", + EvidenceLines = evidence.ToArray() + }; + } + + internal static IReadOnlyList BuildEligibleCommandTargets( + ArMms.MmsIedModelDirectory directory, + IReadOnlyList commandSignals, + IReadOnlyList qualifiedReferences, + ICollection? evidence = null) + { + ArgumentNullException.ThrowIfNull(directory); + ArgumentNullException.ThrowIfNull(commandSignals); + ArgumentNullException.ThrowIfNull(qualifiedReferences); + + var qualifiedIndex = qualifiedReferences + .Select((reference, index) => new { Key = NormalizeMms(reference), Index = index }) + .Where(item => item.Key.Length > 0) + .GroupBy(item => item.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().Index, StringComparer.OrdinalIgnoreCase); + + var statusPoints = DynamicReportCommandBoundStimulusWitnessService.ResolveCommandStatusPoints( + directory, + commandSignals, + evidence); + var result = new List(); + + foreach (var pair in statusPoints) + { + var qualifiedFocus = DynamicReportCommandBoundStimulusWitnessService + .BuildFocusChain(directory, pair.Value) + .Where(point => qualifiedIndex.ContainsKey(NormalizeMms(point.MmsReference))) + .GroupBy(point => NormalizeMms(point.MmsReference), StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToArray(); + if (qualifiedFocus.Length == 0) + continue; + + var indexes = qualifiedFocus + .Select(point => qualifiedIndex[NormalizeMms(point.MmsReference)]) + .Distinct() + .OrderBy(index => index) + .ToArray(); + result.Add(new DynamicReportCommandBoundA3EligibleTarget(pair.Key, pair.Value, qualifiedFocus, indexes)); + } + + return result + .OrderBy(target => target.Signal.ObjectReference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + internal static int[] CorrelateIndexes( + IEnumerable reportIncludedIndexes, + IEnumerable commandBoundChangedIndexes) + { + ArgumentNullException.ThrowIfNull(reportIncludedIndexes); + ArgumentNullException.ThrowIfNull(commandBoundChangedIndexes); + return reportIncludedIndexes + .Intersect(commandBoundChangedIndexes) + .Distinct() + .OrderBy(index => index) + .ToArray(); + } + + private static bool IsAcceptedNativeControlResultDiagnostic( + DiagnosticEntry entry, + DynamicReportObservedCommandIntent command) + { + if (!entry.Level.Equals("INFO", StringComparison.OrdinalIgnoreCase)) + return false; + + var message = entry.Message ?? string.Empty; + if (!message.StartsWith("Control ", StringComparison.OrdinalIgnoreCase)) + return false; + if (!message.Contains($": {command.Signal.ObjectReference};", StringComparison.OrdinalIgnoreCase)) + return false; + if (!message.Contains($"requested={command.RequestedValue};", StringComparison.OrdinalIgnoreCase)) + return false; + if (!message.Contains("wire=", StringComparison.OrdinalIgnoreCase)) + return false; + if (message.Contains("NOT SENT TO IED", StringComparison.OrdinalIgnoreCase) || + message.Contains("no response captured", StringComparison.OrdinalIgnoreCase) || + message.Contains("no wire evidence returned", StringComparison.OrdinalIgnoreCase)) + return false; + + return true; + } + + private static DateTimeOffset ToUtc(DateTime value) + { + if (value.Kind == DateTimeKind.Utc) + return new DateTimeOffset(value); + if (value.Kind == DateTimeKind.Local) + return new DateTimeOffset(value).ToUniversalTime(); + return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Local)).ToUniversalTime(); + } + + private static async Task RunCommandWitnessAsync( + ArMms.MmsClientSession session, + IReadOnlyList exactQualifiedPoints, + IReadOnlyList qualifiedReferences, + IReadOnlyList eligibleTargets, + Task armedSignal, + Task commandSignal, + Action setReady, + IProgress? progress, + CancellationToken cancellationToken) + { + var evidence = new List(); + try + { + await armedSignal.WaitAsync(cancellationToken).ConfigureAwait(false); + var baseline = await ReadValuesAsync(session, exactQualifiedPoints, cancellationToken).ConfigureAwait(false); + if (!baseline.IsSuccess || !session.IsMmsInitiated) + { + evidence.Add("A3 final pre-command baseline failed: " + baseline.Message); + return WitnessFailure("A3 could not capture a complete final pre-command qualified-member baseline.", evidence, session.IsMmsInitiated, baseline.ReadFailures); + } + + evidence.Add("A3 final pre-command baseline: " + string.Join(" | ", qualifiedReferences.Select((reference, index) => $"[{index}] {reference}={baseline.Values[index]}"))); + evidence.Add("A3 eligible command objects: " + string.Join(" | ", eligibleTargets.Select(target => target.Signal.ObjectReference))); + setReady(true); + progress?.Report($"{ReadyMarker} — issue exactly ONE already-proven safe OPEN/CLOSE using normal ARSAS control. Eligible object(s): {string.Join(", ", eligibleTargets.Select(target => target.Signal.ObjectReference))}. Do not issue an external/manual stimulus."); + + DynamicReportObservedCommandIntent command; + try + { + command = await commandSignal.WaitAsync(CommandWaitWindow, cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + evidence.Add("A3 command wait timed out after the witness baseline was ready."); + return WitnessFailure("No eligible existing ARSAS command was captured in the bounded A3 command window.", evidence, session.IsMmsInitiated, baseline: baseline.Values); + } + finally + { + setReady(false); + } + + var target = eligibleTargets.First(item => ReferenceEquals(item.Signal, command.Signal) || + SameReference(item.Signal.ObjectReference, command.Signal.ObjectReference)); + evidence.Add($"{CommandCapturedMarker}: object={command.Signal.ObjectReference}; requested={command.RequestedValue}; status={command.Signal.ControlStatusReference}; source={command.Source}; at={command.ObservedAtUtc:O}; qualifiedFocus=[{string.Join(",", target.QualifiedIndexes)}]"); + progress?.Report($"{CommandCapturedMarker} — {command.Signal.ObjectReference} requested={command.RequestedValue}. High-speed read-only sampling is active; do NOT issue another command."); + + var focus = target.QualifiedFocusPoints + .Select(point => new + { + Point = point, + Index = Array.FindIndex(qualifiedReferences.ToArray(), reference => SameMms(reference, point.MmsReference)) + }) + .Where(item => item.Index >= 0) + .ToArray(); + if (focus.Length == 0) + return WitnessFailure("Captured command lost its qualified A2.1 focus intersection before sampling.", evidence, session.IsMmsInitiated, baseline: baseline.Values, command: command); + + var deadline = DateTimeOffset.UtcNow + CommandTransitionWindow; + DateTimeOffset? settleDeadline = null; + var cycles = 0; + var failures = baseline.ReadFailures; + var transitions = new List(); + var currentValues = baseline.Values.ToArray(); + + while (DateTimeOffset.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + cycles++; + foreach (var item in focus) + { + var read = await session.ReadSingleVariableAsync(item.Point.ToObjectReference(), cancellationToken).ConfigureAwait(false); + if (!read.IsSuccess || read.Value is null) + { + failures++; + continue; + } + + var current = NormalizeValue(ArMms.MmsDataValueRenderer.ToCompactString(read.Value)); + if (string.Equals(currentValues[item.Index], current, StringComparison.OrdinalIgnoreCase)) + continue; + + var transition = new DynamicReportCommandBoundA3Transition + { + Index = item.Index, + MemberReference = qualifiedReferences[item.Index], + PointReference = item.Point.UserReference, + BeforeValue = currentValues[item.Index], + AfterValue = current, + ObservedAtUtc = DateTimeOffset.UtcNow + }; + currentValues[item.Index] = current; + transitions.Add(transition); + evidence.Add($"{TransitionMarker}: index={transition.Index}; member={transition.MemberReference}; point={transition.PointReference}; before={transition.BeforeValue}; after={transition.AfterValue}; commandAt={command.ObservedAtUtc:O}; observedAt={transition.ObservedAtUtc:O}; deltaMs={(transition.ObservedAtUtc - command.ObservedAtUtc).TotalMilliseconds:0.###}"); + settleDeadline ??= transition.ObservedAtUtc + PostTransitionSettleWindow; + } + + if (!session.IsMmsInitiated) + break; + if (settleDeadline.HasValue && DateTimeOffset.UtcNow >= settleDeadline.Value) + break; + if (InterCycleDelay > TimeSpan.Zero) + await Task.Delay(InterCycleDelay, cancellationToken).ConfigureAwait(false); + } + + var postCommand = transitions + .Where(transition => transition.ObservedAtUtc >= command.ObservedAtUtc) + .ToArray(); + var proven = postCommand.Length > 0 && session.IsMmsInitiated; + evidence.Add($"A3 witness result: commandCaptured=true; transitions={transitions.Count}; postCommand={postCommand.Length}; cycles={cycles}; readFailures={failures}; associationHealthy={session.IsMmsInitiated}; proven={proven}"); + + return new DynamicReportCommandBoundA3WitnessResult + { + BaselineCaptured = true, + CommandCaptured = true, + CommandBoundTransitionProven = proven, + AssociationHealthy = session.IsMmsInitiated, + CommandSignalReference = command.Signal.ObjectReference, + ControlStatusReference = command.Signal.ControlStatusReference, + RequestedValue = command.RequestedValue, + CommandSource = command.Source, + CommandObservedAtUtc = command.ObservedAtUtc, + SampleCycles = cycles, + ReadFailures = failures, + Transitions = postCommand, + EvidenceLines = evidence.ToArray(), + Summary = proven + ? $"A3 witnessed {postCommand.Length} qualified command-bound transition(s) after the exact existing ARSAS command." + : "A3 captured the exact ARSAS command but did not witness a qualified post-command transition." + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + evidence.Add($"A3 witness exception: {ex.GetType().Name}: {ex.Message}"); + return WitnessFailure("A3 read-only command witness failed before a conclusive transition proof.", evidence, session.IsMmsInitiated); + } + finally + { + setReady(false); + } + } + + private static async Task ReadValuesAsync( + ArMms.MmsClientSession session, + IReadOnlyList points, + CancellationToken cancellationToken) + { + var values = new string[points.Count]; + var failures = 0; + for (var index = 0; index < points.Count; index++) + { + var read = await session.ReadSingleVariableAsync(points[index].ToObjectReference(), cancellationToken).ConfigureAwait(false); + if (!read.IsSuccess || read.Value is null) + { + failures++; + values[index] = ""; + continue; + } + values[index] = NormalizeValue(ArMms.MmsDataValueRenderer.ToCompactString(read.Value)); + } + + return new ReadBatch + { + IsSuccess = failures == 0, + ReadFailures = failures, + Values = values, + Message = failures == 0 ? "all reads succeeded" : $"{failures} of {points.Count} reads failed" + }; + } + + private static DynamicReportCommandBoundA3WitnessResult WitnessFailure( + string summary, + IReadOnlyList evidence, + bool associationHealthy, + int readFailures = 0, + IReadOnlyList? baseline = null, + DynamicReportObservedCommandIntent? command = null) + => new() + { + BaselineCaptured = baseline is { Count: > 0 }, + CommandCaptured = command is not null, + AssociationHealthy = associationHealthy, + CommandSignalReference = command?.Signal.ObjectReference ?? string.Empty, + ControlStatusReference = command?.Signal.ControlStatusReference ?? string.Empty, + RequestedValue = command?.RequestedValue ?? string.Empty, + CommandSource = command?.Source ?? string.Empty, + CommandObservedAtUtc = command?.ObservedAtUtc, + ReadFailures = readFailures, + Summary = summary, + EvidenceLines = evidence.ToArray() + }; + + private static DynamicReportCommandBoundA3CommissioningResult Blocked( + string summary, + IReadOnlyList evidence) + => new() + { + IsBlocked = true, + Summary = summary + " Production automatic dynamic reporting remains OFF.", + EvidenceLines = evidence.ToArray() + }; + + private static string NormalizeMms(string? reference) + => ArMms.MmsFcReferenceNormalizer.NormalizeMmsReference(reference ?? string.Empty); + + private static bool SameMms(string? left, string? right) + => NormalizeMms(left).Equals(NormalizeMms(right), StringComparison.OrdinalIgnoreCase); + + private static bool SameReference(string? left, string? right) + => string.Equals((left ?? string.Empty).Trim().Replace('.', '$'), (right ?? string.Empty).Trim().Replace('.', '$'), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeValue(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + + private static string TextOrDash(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + + private sealed class RelayProgress(Action report) : IProgress + { + public void Report(string value) => report(value); + } + + private sealed class ReadBatch + { + public bool IsSuccess { get; init; } + public int ReadFailures { get; init; } + public IReadOnlyList Values { get; init; } = Array.Empty(); + public string Message { get; init; } = string.Empty; + } +} \ No newline at end of file diff --git a/Services/DynamicReportCommandFocusRequalificationCommissioningService.cs b/Services/DynamicReportCommandFocusRequalificationCommissioningService.cs new file mode 100644 index 00000000..b418256c --- /dev/null +++ b/Services/DynamicReportCommandFocusRequalificationCommissioningService.cs @@ -0,0 +1,653 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +internal sealed class DynamicReportCommandFocusRequalificationAssessment +{ + public bool IsSuccess { get; init; } + public bool RequiresRequalification { get; init; } + public string Summary { get; init; } = string.Empty; + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); +} + +internal sealed class DynamicReportCommandFocusRequalificationResult +{ + public bool IsSuccess { get; init; } + public bool IsBlocked { get; init; } + public bool LiveProfileReplaced { get; init; } + public bool FreshCleanupClosureSucceeded { get; init; } + public string Summary { get; init; } = string.Empty; + public ArMms.MmsDynamicReportQualificationProfile? OriginalProfile { get; init; } + public ArMms.MmsDynamicReportQualificationProfile? SavedProfile { get; init; } + public DynamicReportActivationCommissioningResult? ActivationResult { get; init; } + public DynamicReportCleanupClosureCommissioningResult? CleanupClosureResult { get; init; } + public IReadOnlyList QualifiedMemberReferences { get; init; } = Array.Empty(); + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); +} + +/// +/// Field-discovered G2.6-P1 recovery path for an InformationReportProven profile whose +/// exact member envelope cannot witness any existing ARSAS command. +/// +/// The live profile is treated as immutable until a completely separate staging profile +/// has passed all of the following: +/// 1. exact command-status discovery + direct read validation; +/// 2. explicit dynamic NamedVariableList qualification with cleanup continuity; +/// 3. G2.4 V2 one-URCB activation + actual InformationReport proof; +/// 4. G2.4-C fresh-association read-only cleanup closure. +/// +/// Staging uses a private temporary profile-store root. Only after every stage succeeds, +/// and after the live profile is re-read to prove it did not change concurrently, is the +/// new InformationReportProven profile atomically moved into the normal store. This +/// service never executes a control command and can never mark ProductionEligible. +/// +internal sealed class DynamicReportCommandFocusRequalificationCommissioningService +{ + private const int MaximumCommandFocusMembers = DynamicReportActivationCommissioningService.MaximumG24Members; + private static readonly TimeSpan AuxiliaryAssociationTimeout = TimeSpan.FromSeconds(10); + + private readonly DynamicReportQualificationProfileStore _liveProfileStore; + + public DynamicReportCommandFocusRequalificationCommissioningService( + DynamicReportQualificationProfileStore? liveProfileStore = null) + { + _liveProfileStore = liveProfileStore ?? new DynamicReportQualificationProfileStore(); + } + + public async Task AssessAsync( + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(fullModelSignals); + + var evidence = new List + { + "G2.6-P1 recovery assessment: READ ONLY; no DataSet/RCB/profile/control mutation is permitted." + }; + + ArMms.MmsDynamicReportIedIdentity identity; + try + { + identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return AssessmentFailure("Command-focus recovery identity preflight failed: " + ex.Message, evidence); + } + + var loaded = await _liveProfileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + evidence.Add($"Recovery assessment profile: exists={loaded.Exists}; valid={loaded.IsValid}; state={loaded.Profile?.State.ToString() ?? "-"}; reason={loaded.Reason}"); + if (!IsInformationReportProven(loaded.Profile) || !loaded.IsValid) + { + return AssessmentFailure( + "Command-focus recovery requires the exact identity-compatible InformationReportProven profile.", + evidence); + } + + var commandSignals = GetCommandSignals(fullModelSignals); + if (commandSignals.Length == 0) + return AssessmentFailure("No live ARSAS control object exposes ControlStatusReference.", evidence); + if (commandSignals.Any(signal => signal.ControlCommandBusy)) + return AssessmentFailure("A control command is already in progress; recovery assessment must be performed while controls are idle.", evidence); + + await using var session = new ArMms.MmsClientSession(); + try + { + await session.ConnectAsync( + device.IpAddress, + device.Port, + AuxiliaryAssociationTimeout, + cancellationToken).ConfigureAwait(false); + var discovery = await session.DiscoverAsync( + probeReportAttributes: false, + maxReportAttributeProbes: 0, + cancellationToken: cancellationToken, + readDataSetDirectories: false, + maxDataSetDirectoryReads: 0).ConfigureAwait(false); + + var qualifiedReferences = loaded.Profile!.RcbActivationProof!.MemberReferences.ToArray(); + if (!DynamicReportActivationCommissioningService.TryResolveExactQualifiedMembers( + discovery.IedDirectory, + qualifiedReferences, + out _, + out var memberReason)) + { + evidence.Add("Recovery assessment exact member resolution failed: " + memberReason); + return AssessmentFailure("The existing InformationReportProven envelope no longer resolves exactly on the live IED.", evidence); + } + + var eligible = DynamicReportCommandBoundDataChangeCommissioningService.BuildEligibleCommandTargets( + discovery.IedDirectory, + commandSignals, + qualifiedReferences, + evidence); + if (eligible.Count > 0) + { + evidence.Add("Recovery assessment: existing envelope already has command-focus intersection: " + + string.Join(" | ", eligible.Select(item => item.Signal.ObjectReference))); + return new DynamicReportCommandFocusRequalificationAssessment + { + IsSuccess = true, + RequiresRequalification = false, + Summary = "The existing InformationReportProven envelope already contains an eligible ARSAS command-focus status member; no requalification is required.", + EvidenceLines = evidence.ToArray() + }; + } + + evidence.Add("Recovery assessment: zero existing command-focus intersections. Live profile remains untouched."); + return new DynamicReportCommandFocusRequalificationAssessment + { + IsSuccess = true, + RequiresRequalification = true, + Summary = "The existing InformationReportProven envelope cannot witness an ARSAS command. Transactional command-focus requalification is required before deterministic A3 can arm.", + EvidenceLines = evidence.ToArray() + }; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + evidence.Add($"Recovery assessment exception: {ex.GetType().Name}: {ex.Message}"); + return AssessmentFailure("The read-only recovery assessment could not complete.", evidence); + } + } + + public async Task RunAsync( + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(fullModelSignals); + + var evidence = new List + { + "G2.6-P1 command-focus requalification contract: stage everything away from the live profile, prove activation/report/cleanup completely, then atomically replace only with InformationReportProven.", + "G2.6-P1 recovery control safety: ZERO control execution. Existing ARSAS SBO/SBOw/Operate path is not called, wrapped, delayed or re-issued.", + "G2.6-P1 recovery production safety: ProductionEligible is forbidden; production automatic dynamic reporting remains OFF." + }; + + ArMms.MmsDynamicReportIedIdentity identity; + try + { + identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return Blocked("Command-focus requalification identity preflight failed: " + ex.Message, evidence); + } + + var originalLoad = await _liveProfileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + if (!originalLoad.IsValid || !IsInformationReportProven(originalLoad.Profile)) + { + evidence.Add($"Live profile rejected: exists={originalLoad.Exists}; valid={originalLoad.IsValid}; state={originalLoad.Profile?.State.ToString() ?? "-"}; reason={originalLoad.Reason}"); + return Blocked("Transactional command-focus recovery requires the exact existing InformationReportProven profile.", evidence, originalLoad.Profile); + } + + var originalProfile = originalLoad.Profile!; + var commandSignals = GetCommandSignals(fullModelSignals); + if (commandSignals.Length == 0) + return Blocked("No ARSAS control object exposes ControlStatusReference; recovery will not guess a status point.", evidence, originalProfile); + if (commandSignals.Any(signal => signal.ControlCommandBusy)) + return Blocked("A control command is already in progress. Recovery must complete before the one A3 test command.", evidence, originalProfile); + + var stagingRoot = Path.Combine(Path.GetTempPath(), "ARSAS", "g26-p1-command-focus-" + Guid.NewGuid().ToString("N")); + try + { + progress?.Report("G2.6-P1 recovery: discovering exact command-status points and qualifying a staging-only dynamic DataSet…"); + var envelope = await BuildStagedEnvelopeAsync( + device, + fullModelSignals, + commandSignals, + identity, + evidence, + cancellationToken).ConfigureAwait(false); + if (!envelope.IsSuccess || envelope.Profile is null) + { + return Failed( + envelope.Summary, + evidence, + originalProfile, + envelope.MemberReferences); + } + + var stagingStore = new DynamicReportQualificationProfileStore(stagingRoot); + await stagingStore.SaveAsync(envelope.Profile, cancellationToken).ConfigureAwait(false); + evidence.Add($"Staging profile persisted outside live store: state={envelope.Profile.State}; members={envelope.Profile.ProvenSafeMemberCount}; liveProfileTouched=false"); + + progress?.Report("G2.6-P1 recovery: staging envelope qualified; proving one-URCB activation + actual InformationReport without touching the live profile…"); + var activationService = new DynamicReportActivationCommissioningServiceV2(stagingStore); + var activation = await activationService.RunAsync( + device, + fullModelSignals, + cancellationToken).ConfigureAwait(false); + evidence.Add("Staged G2.4 V2: " + activation.Summary); + evidence.AddRange(activation.EvidenceLines.Select(line => "staged/G2.4: " + line)); + + if (!activation.IsSuccess || !activation.CleanupSucceeded || !IsInformationReportProven(activation.SavedProfile)) + { + return Failed( + "Staged command-focus G2.4 did not close activation + actual InformationReport + cleanup. The original live InformationReportProven profile was not changed.", + evidence, + originalProfile, + envelope.MemberReferences, + activation); + } + + progress?.Report("G2.6-P1 recovery: staged report proof passed; opening a fresh READ-ONLY association to close RCB/DataSet cleanup…"); + var closureService = new DynamicReportCleanupClosureCommissioningService(stagingStore); + var closure = await closureService.RunAsync( + device, + fullModelSignals, + cancellationToken).ConfigureAwait(false); + evidence.Add("Staged G2.4-C: " + closure.Summary); + evidence.AddRange(closure.EvidenceLines.Select(line => "staged/G2.4-C: " + line)); + + if (!closure.IsSuccess) + { + return Failed( + "Staged command-focus report proof passed, but fresh-association cleanup closure did not. The original live profile remains untouched.", + evidence, + originalProfile, + envelope.MemberReferences, + activation, + closure); + } + + var finalProfile = activation.SavedProfile!; + if (!FinalProfileMatchesCommandFocus(finalProfile, envelope.CommandStatusReferences, out var finalReason)) + { + evidence.Add("Final staged profile rejected: " + finalReason); + return Failed( + "Staged proof completed but the resulting InformationReportProven profile lost the exact command-focus member invariant. Live profile was not changed.", + evidence, + originalProfile, + envelope.MemberReferences, + activation, + closure); + } + + // Optimistic concurrency gate: a long physical staging transaction must never + // overwrite a live qualification profile that another commissioning action + // changed while this recovery was running. + var currentLoad = await _liveProfileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + if (!currentLoad.IsValid || currentLoad.Profile is null || !SameProfileEvidence(originalProfile, currentLoad.Profile)) + { + evidence.Add("Live profile concurrency gate failed: the persisted evidence changed during staging. No replacement was attempted."); + return Failed( + "The live qualification profile changed while command-focus staging was running. Recovery aborted rather than overwrite newer evidence.", + evidence, + originalProfile, + envelope.MemberReferences, + activation, + closure); + } + + await _liveProfileStore.SaveAsync(finalProfile, cancellationToken).ConfigureAwait(false); + evidence.Add($"LIVE PROFILE ATOMIC REPLACEMENT PASS: oldState={originalProfile.State}; newState={finalProfile.State}; rcb={finalProfile.RcbActivationProof?.RcbReference}; members={finalProfile.RcbActivationProof?.MemberReferences.Count}; ProductionEligible=false"); + evidence.Add("G2.6-P1 recovery complete: the new exact InformationReportProven envelope contains command-status evidence; deterministic A3 may now be armed. Production automatic dynamic reporting remains OFF."); + + progress?.Report("G2.6-P1 recovery PASS — command-focus profile is InformationReportProven and cleanup-closed. Re-arming deterministic A3 automatically; DO NOT command until the exact A3 READY marker appears."); + return new DynamicReportCommandFocusRequalificationResult + { + IsSuccess = true, + LiveProfileReplaced = true, + FreshCleanupClosureSucceeded = true, + Summary = "G2.6-P1 command-focus requalification PASS: a staging-only envelope passed dynamic DataSet qualification, one-URCB actual InformationReport proof and fresh cleanup closure; only then was the live profile atomically replaced at InformationReportProven. A3 can be re-armed; ProductionEligible remains OFF.", + OriginalProfile = originalProfile, + SavedProfile = finalProfile, + ActivationResult = activation, + CleanupClosureResult = closure, + QualifiedMemberReferences = envelope.MemberReferences, + EvidenceLines = evidence.ToArray() + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException or UnauthorizedAccessException or ArgumentException) + { + evidence.Add($"G2.6-P1 recovery exception: {ex.GetType().Name}: {ex.Message}"); + return Failed( + "Transactional command-focus requalification stopped before atomic live-profile replacement. The previous InformationReportProven profile remains authoritative.", + evidence, + originalProfile); + } + finally + { + TryDeleteStagingRoot(stagingRoot, evidence); + } + } + + private static async Task BuildStagedEnvelopeAsync( + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + IReadOnlyList commandSignals, + ArMms.MmsDynamicReportIedIdentity identity, + ICollection evidence, + CancellationToken cancellationToken) + { + await using var session = new ArMms.MmsClientSession(); + try + { + await session.ConnectAsync( + device.IpAddress, + device.Port, + AuxiliaryAssociationTimeout, + cancellationToken).ConfigureAwait(false); + evidence.Add($"Recovery staging association ready: state={session.State}; localTcpAddress={TextOrDash(session.LocalTcpAddress)}"); + + var discovery = await session.DiscoverAsync( + probeReportAttributes: false, + maxReportAttributeProbes: 0, + cancellationToken: cancellationToken, + readDataSetDirectories: false, + maxDataSetDirectoryReads: 0).ConfigureAwait(false); + evidence.Add("Recovery staging discovery: " + discovery.Summary); + + var statusPoints = DynamicReportCommandBoundStimulusWitnessService.ResolveCommandStatusPoints( + discovery.IedDirectory, + commandSignals, + evidence); + if (statusPoints.Count == 0) + return StagedEnvelopeResult.Fail("No ControlStatusReference resolved to a live ST/stVal MMS point."); + + var candidates = SelectCommandFocusCandidates(discovery.IedDirectory, statusPoints) + .Take(MaximumCommandFocusMembers) + .ToArray(); + if (candidates.Length < 2) + { + evidence.Add("Recovery staging candidates: " + string.Join(" | ", candidates.Select(point => point.UserReference))); + return StagedEnvelopeResult.Fail("Command-focus recovery requires at least two bounded ST/stVal candidates so the G2.3 multi-member envelope gate is not weakened."); + } + + var validated = new List(); + var validatedMms = new List(); + foreach (var point in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = await session.ReadSingleVariableAsync(point.ToObjectReference(), cancellationToken).ConfigureAwait(false); + evidence.Add($"Recovery direct-read candidate: ref={point.UserReference}; mms={point.MmsReference}; success={read.IsSuccess}; result={read.Message}"); + if (!read.IsSuccess) + { + if (!session.IsMmsInitiated) + return StagedEnvelopeResult.Fail("The staging association was lost during direct-read validation."); + continue; + } + + validated.Add(point.ToObjectReference()); + validatedMms.Add(point.MmsReference); + } + + if (validated.Count < 2) + return StagedEnvelopeResult.Fail("Fewer than two command-focus candidates passed exact direct MMS-read validation."); + + var commandStatusReferences = statusPoints.Values + .Select(point => point.MmsReference) + .Where(status => validatedMms.Any(candidate => SameMms(candidate, status))) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (commandStatusReferences.Length == 0) + return StagedEnvelopeResult.Fail("Direct-read validation removed every exact ControlStatusReference; recovery will not qualify a status envelope by inference."); + + var dataSetReference = BuildTemporaryDataSetReference(validated[0].Domain); + evidence.Add($"Recovery qualification dataset={dataSetReference}; candidates={validated.Count}; commandStatusCandidates={commandStatusReferences.Length}; liveProfileTouched=false"); + + var coordinator = await session.RunDynamicDataSetQualificationCommissioningAsync( + dataSetReference, + validated, + new ArMms.MmsDynamicDataSetQualificationCoordinatorOptions + { + ExecutionMode = ArMms.MmsDynamicDataSetQualificationExecutionMode.ExplicitCommissioning, + MaxAttempts = 16, + LocalizeFailedBatch = true, + Ladder = new ArMms.MmsDynamicDataSetQualificationLadderOptions + { + Milestones = [1, 4, 8], + ApplicationSafetyMemberLimit = MaximumCommandFocusMembers, + IncludeTerminalCandidateCount = true + }, + Probe = new ArMms.MmsDynamicDataSetQualificationProbeOptions + { + ApplicationSafetyMemberLimit = MaximumCommandFocusMembers, + RejectKnownNegotiatedPduOverflow = true + } + }, + discovery.IedDirectory, + cancellationToken).ConfigureAwait(false); + + evidence.Add("Recovery qualification coordinator: " + coordinator.Summary); + foreach (var attempt in coordinator.Attempts) + { + evidence.Add($"Recovery qualification attempt {attempt.AttemptId}: members={attempt.MemberCount}; success={attempt.IsQualificationSuccess}; associationSurvived={attempt.AssociationSurvived}; cleanup={attempt.CleanupSucceeded}; stage={attempt.FailureStage}"); + } + evidence.AddRange(coordinator.Warnings.Select(warning => "Recovery qualification warning: " + warning)); + + if (coordinator.RequiresFreshAssociation || + !coordinator.Assessment.HasMultiMemberEnvelopeCandidate || + string.IsNullOrWhiteSpace(coordinator.EnvelopeCandidateAttemptId)) + { + return StagedEnvelopeResult.Fail( + coordinator.RequiresFreshAssociation + ? "Dynamic DataSet qualification did not prove association/cleanup continuity." + : "Dynamic DataSet qualification did not produce a cleanup-safe multi-member envelope."); + } + + var acceptedEnvelope = ArMms.MmsDynamicDataSetQualificationLadder.AcceptExactEnvelope( + coordinator.Assessment, + coordinator.EnvelopeCandidateAttemptId); + var profile = ArMms.MmsDynamicReportQualificationProfilePolicy.CreateEnvelopeQualifiedProfile( + identity, + acceptedEnvelope, + coordinator.Assessment, + capacityEvidence: null, + sourceEvidenceId: $"arsas-g2.6-p1-command-focus-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}", + nowUtc: DateTimeOffset.UtcNow); + + var accepted = profile.AcceptedEnvelope?.ExactProvenMemberReferences?.ToArray() ?? Array.Empty(); + var acceptedStatuses = commandStatusReferences.Where(status => accepted.Any(member => SameMms(member, status))).ToArray(); + if (acceptedStatuses.Length == 0) + { + return StagedEnvelopeResult.Fail("The accepted exact envelope did not retain any exact ControlStatusReference member."); + } + + evidence.Add($"Recovery staged EnvelopeQualified PASS: members={accepted.Length}; exactCommandStatuses={acceptedStatuses.Length}; state={profile.State}; liveProfileTouched=false"); + evidence.Add("Recovery staged exact members: " + string.Join(" | ", accepted)); + return new StagedEnvelopeResult + { + IsSuccess = true, + Summary = "Command-focus dynamic DataSet envelope qualified in staging.", + Profile = profile, + MemberReferences = accepted, + CommandStatusReferences = acceptedStatuses + }; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException or ArgumentException) + { + evidence.Add($"Recovery staging qualification exception: {ex.GetType().Name}: {ex.Message}"); + return StagedEnvelopeResult.Fail("Command-focus staging qualification ended on a transport/protocol/policy exception."); + } + } + + private static IReadOnlyList SelectCommandFocusCandidates( + ArMms.MmsIedModelDirectory directory, + IReadOnlyDictionary statusPoints) + { + var result = new List(); + + // Exact ControlStatusReference values come first. With multiple live controls this + // makes the bounded G2.4 envelope useful for more than one normal ARSAS command. + foreach (var pair in statusPoints.OrderBy(item => item.Key.ObjectReference, StringComparer.OrdinalIgnoreCase)) + AddDistinct(result, pair.Value); + + // Then add the same A2.1 focus chain used by the physical command witness. This + // naturally adds XCBR/CSWI/XSWI Pos.stVal corroboration when the IED exposes it. + foreach (var pair in statusPoints.OrderBy(item => item.Key.ObjectReference, StringComparer.OrdinalIgnoreCase)) + { + foreach (var point in DynamicReportCommandBoundStimulusWitnessService.BuildFocusChain(directory, pair.Value)) + { + if (!point.FunctionalConstraint.Equals("ST", StringComparison.OrdinalIgnoreCase) || + point.IsControlAttribute || point.IsReportAttribute || + !(point.DataObjectPath.Equals("stVal", StringComparison.OrdinalIgnoreCase) || + point.DataObjectPath.EndsWith(".stVal", StringComparison.OrdinalIgnoreCase))) + continue; + AddDistinct(result, point); + } + } + + return result.Take(MaximumCommandFocusMembers).ToArray(); + } + + private static void AddDistinct(List target, ArMms.MmsFcResolvedPoint point) + { + if (target.Any(existing => SameMms(existing.MmsReference, point.MmsReference))) + return; + target.Add(point); + } + + private static bool FinalProfileMatchesCommandFocus( + ArMms.MmsDynamicReportQualificationProfile profile, + IReadOnlyList commandStatusReferences, + out string reason) + { + if (!IsInformationReportProven(profile)) + { + reason = $"final state/proofs are incomplete: state={profile.State}"; + return false; + } + + var members = profile.RcbActivationProof!.MemberReferences; + if (!commandStatusReferences.Any(status => members.Any(member => SameMms(member, status)))) + { + reason = "final G2.4 exact member sequence has no retained command-status member"; + return false; + } + + if (profile.State == ArMms.MmsDynamicReportQualificationState.ProductionEligible) + { + reason = "staging unexpectedly produced ProductionEligible, which is forbidden in P1 recovery"; + return false; + } + + reason = "exact InformationReportProven command-focus member invariant passed"; + return true; + } + + private static bool SameProfileEvidence( + ArMms.MmsDynamicReportQualificationProfile expected, + ArMms.MmsDynamicReportQualificationProfile current) + { + if (expected.State != current.State || + !string.Equals(expected.Identity.StableIdentityKey, current.Identity.StableIdentityKey, StringComparison.OrdinalIgnoreCase) || + !string.Equals(expected.Identity.ModelFingerprint, current.Identity.ModelFingerprint, StringComparison.OrdinalIgnoreCase)) + return false; + + if (!string.Equals(expected.RcbActivationProof?.EvidenceId, current.RcbActivationProof?.EvidenceId, StringComparison.Ordinal) || + !string.Equals(expected.InformationReportProof?.EvidenceId, current.InformationReportProof?.EvidenceId, StringComparison.Ordinal)) + return false; + + var expectedMembers = expected.RcbActivationProof?.MemberReferences ?? Array.Empty(); + var currentMembers = current.RcbActivationProof?.MemberReferences ?? Array.Empty(); + return expectedMembers.Count == currentMembers.Count && + expectedMembers.Zip(currentMembers).All(pair => SameMms(pair.First, pair.Second)); + } + + private static SignalDefinition[] GetCommandSignals(IReadOnlyList signals) + => signals + .Where(signal => signal.IsControlSignal && !string.IsNullOrWhiteSpace(signal.ControlStatusReference)) + .Distinct() + .OrderBy(signal => signal.ObjectReference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static bool IsInformationReportProven(ArMms.MmsDynamicReportQualificationProfile? profile) + => profile is not null && + profile.State == ArMms.MmsDynamicReportQualificationState.InformationReportProven && + profile.RcbActivationProof?.IsSuccess == true && + profile.InformationReportProof?.IsSuccess == true; + + private static string BuildTemporaryDataSetReference(string domain) + { + if (string.IsNullOrWhiteSpace(domain)) + throw new InvalidOperationException("The first command-focus member has no logical-device domain."); + return $"{domain.Trim()}/LLN0.ARQ{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}"; + } + + private static bool SameMms(string? left, string? right) + => ArMms.MmsFcReferenceNormalizer.NormalizeMmsReference(left ?? string.Empty) + .Equals( + ArMms.MmsFcReferenceNormalizer.NormalizeMmsReference(right ?? string.Empty), + StringComparison.OrdinalIgnoreCase); + + private static string TextOrDash(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + + private static void TryDeleteStagingRoot(string path, ICollection evidence) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + evidence.Add($"Recovery staging-directory cleanup warning: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static DynamicReportCommandFocusRequalificationAssessment AssessmentFailure( + string summary, + IReadOnlyList evidence) + => new() + { + IsSuccess = false, + Summary = summary + " Production automatic dynamic reporting remains OFF.", + EvidenceLines = evidence.ToArray() + }; + + private static DynamicReportCommandFocusRequalificationResult Blocked( + string summary, + IReadOnlyList evidence, + ArMms.MmsDynamicReportQualificationProfile? originalProfile = null) + => new() + { + IsBlocked = true, + Summary = summary + " The existing live profile was not changed; ProductionEligible remains OFF.", + OriginalProfile = originalProfile, + EvidenceLines = evidence.ToArray() + }; + + private static DynamicReportCommandFocusRequalificationResult Failed( + string summary, + IReadOnlyList evidence, + ArMms.MmsDynamicReportQualificationProfile? originalProfile, + IReadOnlyList? members = null, + DynamicReportActivationCommissioningResult? activation = null, + DynamicReportCleanupClosureCommissioningResult? closure = null) + => new() + { + IsSuccess = false, + IsBlocked = false, + LiveProfileReplaced = false, + FreshCleanupClosureSucceeded = closure?.IsSuccess == true, + Summary = summary + " Production automatic dynamic reporting remains OFF.", + OriginalProfile = originalProfile, + ActivationResult = activation, + CleanupClosureResult = closure, + QualifiedMemberReferences = members ?? Array.Empty(), + EvidenceLines = evidence.ToArray() + }; + + private sealed class StagedEnvelopeResult + { + public bool IsSuccess { get; init; } + public string Summary { get; init; } = string.Empty; + public ArMms.MmsDynamicReportQualificationProfile? Profile { get; init; } + public IReadOnlyList MemberReferences { get; init; } = Array.Empty(); + public IReadOnlyList CommandStatusReferences { get; init; } = Array.Empty(); + + public static StagedEnvelopeResult Fail(string summary) => new() { Summary = summary }; + } +} \ No newline at end of file diff --git a/Services/DynamicReportEvidenceCollectionExtensions.cs b/Services/DynamicReportEvidenceCollectionExtensions.cs new file mode 100644 index 00000000..6a21ab01 --- /dev/null +++ b/Services/DynamicReportEvidenceCollectionExtensions.cs @@ -0,0 +1,16 @@ +namespace ArIED61850Tester.Services; + +/// +/// Keeps commissioning evidence helpers usable with the ICollection contract used by +/// staged recovery routines without forcing callers to expose a concrete List type. +/// +internal static class DynamicReportEvidenceCollectionExtensions +{ + internal static void AddRange(this ICollection target, IEnumerable values) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(values); + foreach (var value in values) + target.Add(value); + } +} diff --git a/Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs b/Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs new file mode 100644 index 00000000..480114b0 --- /dev/null +++ b/Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs @@ -0,0 +1,339 @@ +using System.Reflection; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Field-bounded G2.6-P1 coordinator for the already-proven AA1C1F08R4 Q0 CSWI1.Pos +/// control object. Ctrl+Shift+A is the explicit commissioning action; after every +/// identity/profile/control/report gate closes, this coordinator dispatches exactly one +/// OPEN through the existing Iec61850MonitorRuntime control path. It never retries, +/// toggles, sends CLOSE, or restores the breaker automatically. +/// +/// The existing deterministic A3 service remains authoritative for the dchg-only report +/// transaction and exact DataSet-index correlation. This coordinator only removes the +/// operator timing race and target-selection ambiguity discovered during physical P1. +/// +internal sealed class DynamicReportQ0TargetLockedAutoA3CommissioningService +{ + internal const string ExpectedStableIdentity = "ied:AA1C1F08R4"; + internal const string ExpectedModelFingerprint = "sha256:50c691318c6d6a16b68b121ac48627c26e6e32b937836d559dca1b9eb559f0d9"; + internal const string TargetControlReference = "AA1C1F08R4Q0/CSWI1.Pos"; + internal const string TargetStatusReference = "AA1C1F08R4Q0/CSWI1.Pos.stVal"; + internal const string AutoStimulusValue = "Open"; + + private const string AutoOriginator = "ARSAS-G2.6-P1-A3"; + private const string AutoOriginCategory = "StationControl"; + + public async Task RunAsync( + Iec61850MonitorRuntime runtime, + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(fullModelSignals); + + var identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals); + if (!identity.StableIdentityKey.Equals(ExpectedStableIdentity, StringComparison.OrdinalIgnoreCase) || + !identity.ModelFingerprint.Equals(ExpectedModelFingerprint, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Q0 target-locked A3 is field-bounded to {ExpectedStableIdentity} / {ExpectedModelFingerprint}. " + + $"Connected identity is {identity.StableIdentityKey} / {identity.ModelFingerprint}. No control command was sent."); + } + + var target = fullModelSignals.SingleOrDefault(signal => + SameUserReference(signal.ObjectReference, TargetControlReference)); + if (target is null) + throw new InvalidOperationException($"Exact A3 target {TargetControlReference} is absent from the live model. No control command was sent."); + if (!target.IsControlSignal) + throw new InvalidOperationException($"Exact A3 target {TargetControlReference} is not a live ARSAS control signal. No control command was sent."); + if (!SameUserReference(target.ControlStatusReference, TargetStatusReference)) + { + throw new InvalidOperationException( + $"Exact A3 target status mismatch. Expected {TargetStatusReference}; live ControlStatusReference={TextOrDash(target.ControlStatusReference)}. No control command was sent."); + } + if (target.ControlCommandBusy) + throw new InvalidOperationException($"Exact A3 target {TargetControlReference} is already busy. No additional control command was sent."); + + progress?.Report($"G2.6-P1 Q0 AUTO: target locked to {TargetControlReference}; validating existing ARSAS control semantics before any A3 report mutation…"); + await RequireClosedOperationalTargetAsync(runtime, device, target, "initial preflight", cancellationToken).ConfigureAwait(false); + + // Existing field recovery is intentionally reused, but with a cloned model whose + // command-focus surface exposes only Q0. Identity-significant signal properties are + // unchanged, so the exact persisted profile remains identity-compatible. Originals + // are never mutated and the normal ARSAS command panel/runtime keep their full model. + var recoverySignals = CreateTargetScopedRecoveryModel(fullModelSignals); + var scopedIdentity = DynamicReportQualificationIdentity.Build(device, recoverySignals); + if (!scopedIdentity.StableIdentityKey.Equals(identity.StableIdentityKey, StringComparison.OrdinalIgnoreCase) || + !scopedIdentity.ModelFingerprint.Equals(identity.ModelFingerprint, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Target-scoped recovery changed identity-significant model evidence. Recovery and control were blocked."); + } + + var recovery = new DynamicReportCommandFocusRequalificationCommissioningService(); + progress?.Report($"G2.6-P1 Q0 AUTO: READ-ONLY exact-profile assessment for {TargetStatusReference}…"); + var assessment = await recovery.AssessAsync(device, recoverySignals, cancellationToken).ConfigureAwait(false); + if (!assessment.IsSuccess) + throw new InvalidOperationException(assessment.Summary + " No control command was sent."); + + if (assessment.RequiresRequalification) + { + progress?.Report("G2.6-P1 Q0 AUTO: Q0 is absent from the exact G2.4 envelope; running transactional staging recovery automatically. ZERO control commands are permitted during recovery…"); + var recoveryResult = await recovery.RunAsync( + device, + recoverySignals, + progress, + cancellationToken).ConfigureAwait(false); + if (!recoveryResult.IsSuccess || !recoveryResult.LiveProfileReplaced || !recoveryResult.FreshCleanupClosureSucceeded) + { + throw new InvalidOperationException( + recoveryResult.Summary + " The previous live profile remains authoritative and no control command was sent."); + } + + progress?.Report("G2.6-P1 Q0 AUTO: staged recovery PASS; independently re-checking the exact Q0 command-focus invariant…"); + var postRecovery = await recovery.AssessAsync(device, recoverySignals, cancellationToken).ConfigureAwait(false); + if (!postRecovery.IsSuccess || postRecovery.RequiresRequalification) + { + throw new InvalidOperationException( + "Q0 recovery completed, but the independent post-recovery exact-target assessment did not close. No control command was sent. " + + postRecovery.Summary); + } + } + + // Re-read immediately before the mutating report transaction. OPEN is permitted + // only from an exact Closed state; Open/intermediate/unknown never causes a toggle. + await RequireClosedOperationalTargetAsync(runtime, device, target, "post-recovery pre-arm", cancellationToken).ConfigureAwait(false); + + using var a3Cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task? autoCommandTask = null; + Exception? readyGateFailure = null; + var autoDispatchStarted = 0; + + var immediateProgress = new ImmediateProgress(text => + { + if (!text.StartsWith(DynamicReportCommandBoundDataChangeCommissioningService.ReadyMarker, StringComparison.Ordinal)) + { + progress?.Report(text); + return; + } + + if (Interlocked.CompareExchange(ref autoDispatchStarted, 1, 0) != 0) + return; + + progress?.Report($"G2.6-P1 A3 AUTO READY — exact target {TargetControlReference}; re-validating Closed then dispatching ONE OPEN through the existing ARSAS control path. Do not press OPEN/CLOSE manually."); + autoCommandTask = DispatchOneShotOpenAsync( + runtime, + device, + target, + progress, + ex => + { + readyGateFailure = ex; + try + { + a3Cancellation.Cancel(); + } + catch (ObjectDisposedException) + { + // The A3 transaction already closed; no retry is ever attempted. + } + }, + a3Cancellation.Token); + }); + + progress?.Report("G2.6-P1 Q0 AUTO: Q0 command-focus gate closed; arming the existing dchg-only A3 report transaction. The one-shot OPEN will be dispatched only after the final read-only baseline is ready…"); + var a3 = new DynamicReportCommandBoundDataChangeCommissioningService(); + DynamicReportCommandBoundA3CommissioningResult result; + try + { + result = await a3.RunAsync( + runtime, + device, + fullModelSignals, + immediateProgress, + a3Cancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (readyGateFailure is not null && !cancellationToken.IsCancellationRequested) + { + throw new InvalidOperationException( + "Q0 auto-stimulus READY gate failed before control dispatch. A3 was cancelled so it would not wait for a command that was deliberately blocked. No retry was attempted.", + readyGateFailure); + } + + if (autoCommandTask is not null) + { + try + { + var command = await autoCommandTask.ConfigureAwait(false); + progress?.Report( + $"G2.6-P1 Q0 AUTO command completed: success={command.IsSuccess}; accepted={command.ServiceAccepted}; feedback={command.FeedbackConfirmed}; termination={command.CommandTerminationReceived}/{command.PositiveTermination}; stage={command.Stage}. No retry, CLOSE, toggle, or auto-restore will be issued."); + } + catch (OperationCanceledException) when (a3Cancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + progress?.Report("G2.6-P1 Q0 AUTO command task was cancelled by the fail-closed A3 coordinator. No retry was attempted."); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + // Runtime wire evidence plus the A3 physical transition/report correlation + // remain authoritative. Never retry an ambiguous physical command. + progress?.Report($"G2.6-P1 Q0 AUTO command returned {ex.GetType().Name}: {ex.Message}. No retry was attempted; A3 evidence remains fail-closed."); + } + } + else if (!result.IsBlocked) + { + progress?.Report("G2.6-P1 Q0 AUTO: A3 never reached its final READY handoff, therefore zero control commands were sent."); + } + + return result; + } + + private static async Task DispatchOneShotOpenAsync( + Iec61850MonitorRuntime runtime, + Iec61850MonitorDevice device, + SignalDefinition target, + IProgress? progress, + Action failBeforeDispatch, + CancellationToken cancellationToken) + { + // Re-inspect after the A3 final witness baseline. This closes the time-of-check / + // time-of-use gap: the service never turns "current state" into an automatic toggle. + Iec61850ControlCapabilities capabilities; + try + { + capabilities = await runtime.InspectControlAsync(device.DeviceId, target, cancellationToken).ConfigureAwait(false); + ValidateClosedOperationalTarget(capabilities, "A3 READY recheck"); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + failBeforeDispatch(ex); + throw new InvalidOperationException("Q0 READY-time control inspection failed; no control command was sent.", ex); + } + + if (target.ControlCommandBusy) + { + var ex = new InvalidOperationException($"Exact A3 target {TargetControlReference} became busy at READY. No control command was sent."); + failBeforeDispatch(ex); + throw ex; + } + + var request = new Iec61850ControlCommandRequest + { + Signal = target, + ValueText = AutoStimulusValue, + InterlockCheck = true, + SynchroCheck = false, + TestMode = false, + Originator = AutoOriginator, + OriginCategory = AutoOriginCategory, + FeedbackTimeoutMs = 12000, + CommandTerminationTimeoutMs = 10000 + }; + + progress?.Report($"G2.6-P1 Q0 AUTO DISPATCH: {TargetControlReference} -> {AutoStimulusValue}; interlock=true; synchro=false; test=false; one-shot=true; retry=false."); + + // IMPORTANT: call the existing runtime method directly. Its already-existing + // "Control execution requested:" diagnostic is emitted synchronously before the + // native ARIEC control await, so the armed A3 witness captures the exact request. + // No separate SBO/SBOw/Operate implementation exists here. + return await runtime.ExecuteControlAsync(device.DeviceId, request, cancellationToken).ConfigureAwait(false); + } + + private static async Task RequireClosedOperationalTargetAsync( + Iec61850MonitorRuntime runtime, + Iec61850MonitorDevice device, + SignalDefinition target, + string phase, + CancellationToken cancellationToken) + { + var capabilities = await runtime.InspectControlAsync(device.DeviceId, target, cancellationToken).ConfigureAwait(false); + ValidateClosedOperationalTarget(capabilities, phase); + } + + private static void ValidateClosedOperationalTarget(Iec61850ControlCapabilities capabilities, string phase) + { + if (!SameUserReference(capabilities.ObjectReference, TargetControlReference)) + { + throw new InvalidOperationException( + $"{phase}: control inspection returned {TextOrDash(capabilities.ObjectReference)} instead of exact target {TargetControlReference}. No control command was sent."); + } + + if (!capabilities.SupportsOperate || !capabilities.IsOperationallyReady) + { + throw new InvalidOperationException( + $"{phase}: exact target is not operationally ready for the existing ARSAS control service; model={capabilities.ControlModelText}. No control command was sent."); + } + + if (!capabilities.CurrentState.Equals("Closed", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"{phase}: exact target must be Closed before the one-shot OPEN stimulus. CurrentState={TextOrDash(capabilities.CurrentState)}, CurrentValue={TextOrDash(capabilities.CurrentValue)}. No CLOSE/toggle/restore command is allowed."); + } + } + + private static SignalDefinition[] CreateTargetScopedRecoveryModel(IReadOnlyList fullModelSignals) + { + var cloneMethod = typeof(object).GetMethod("MemberwiseClone", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("MemberwiseClone is unavailable; target-scoped recovery cannot be isolated safely."); + var statusProperty = typeof(SignalDefinition).GetProperty( + nameof(SignalDefinition.ControlStatusReference), + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("SignalDefinition.ControlStatusReference is unavailable; target-scoped recovery cannot be isolated safely."); + var statusSetter = statusProperty.GetSetMethod(nonPublic: true); + var backingField = typeof(SignalDefinition).GetField( + $"<{nameof(SignalDefinition.ControlStatusReference)}>k__BackingField", + BindingFlags.Instance | BindingFlags.NonPublic); + if (statusSetter is null && backingField is null) + throw new InvalidOperationException("ControlStatusReference cannot be changed on a private clone; target-scoped recovery was blocked."); + + var clones = new SignalDefinition[fullModelSignals.Count]; + for (var index = 0; index < fullModelSignals.Count; index++) + { + var clone = (SignalDefinition)(cloneMethod.Invoke(fullModelSignals[index], null) + ?? throw new InvalidOperationException("Signal clone failed; target-scoped recovery was blocked.")); + + if (clone.IsControlSignal && + !SameUserReference(clone.ObjectReference, TargetControlReference) && + !string.IsNullOrWhiteSpace(clone.ControlStatusReference)) + { + // Prefer direct private backing-field mutation on the private clone. A + // MemberwiseClone can carry event delegates; invoking a notifying setter + // could otherwise wake observers that belong to the live signal instance. + if (backingField is not null) + backingField.SetValue(clone, string.Empty); + else + statusSetter!.Invoke(clone, [string.Empty]); + } + + clones[index] = clone; + } + + var scopedCommands = clones + .Where(signal => signal.IsControlSignal && !string.IsNullOrWhiteSpace(signal.ControlStatusReference)) + .ToArray(); + if (scopedCommands.Length != 1 || !SameUserReference(scopedCommands[0].ObjectReference, TargetControlReference)) + { + throw new InvalidOperationException( + $"Target-scoped recovery model must expose exactly one control focus ({TargetControlReference}); resolved={string.Join(", ", scopedCommands.Select(signal => signal.ObjectReference))}. No recovery/control mutation was attempted."); + } + + return clones; + } + + private static bool SameUserReference(string? left, string? right) + => NormalizeUserReference(left).Equals(NormalizeUserReference(right), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeUserReference(string? value) + => (value ?? string.Empty).Trim().Replace('$', '.'); + + private static string TextOrDash(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + + private sealed class ImmediateProgress(Action callback) : IProgress + { + public void Report(string value) => callback(value ?? string.Empty); + } +} \ No newline at end of file diff --git a/Services/DynamicReportShadowEvidenceRecorder.cs b/Services/DynamicReportShadowEvidenceRecorder.cs new file mode 100644 index 00000000..1fd75a1f --- /dev/null +++ b/Services/DynamicReportShadowEvidenceRecorder.cs @@ -0,0 +1,174 @@ +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +/// +/// Thread-safe bounded recorder used by the upcoming physical G2.6 collector. +/// It accepts only the exact qualified DataSet index/member sequence supplied at +/// construction time. It never synthesizes quality/timestamps and performs no I/O. +/// +internal sealed class DynamicReportShadowEvidenceRecorder +{ + internal const int MaximumReportObservations = 4096; + internal const int MaximumPollObservations = 16384; + + private readonly object _sync = new(); + private readonly string _evidenceId; + private readonly string[] _memberReferences; + private readonly List _reports = new(); + private readonly List _polls = new(); + private int _reconnectAttempts; + private int _successfulReconnects; + private int _reportResubscriptionsAfterReconnect; + private int _pollReferenceRecoveriesAfterReconnect; + private int _dynamicActivationAttempts; + + public DynamicReportShadowEvidenceRecorder( + string evidenceId, + IReadOnlyList exactMemberReferences) + { + ArgumentException.ThrowIfNullOrWhiteSpace(evidenceId); + ArgumentNullException.ThrowIfNull(exactMemberReferences); + if (exactMemberReferences.Count == 0) + throw new ArgumentException("At least one exact qualified member is required.", nameof(exactMemberReferences)); + + _evidenceId = evidenceId.Trim(); + _memberReferences = exactMemberReferences.Select(reference => + { + var normalized = NormalizeReference(reference); + if (normalized.Length == 0) + throw new ArgumentException("Qualified member references cannot be empty.", nameof(exactMemberReferences)); + return normalized; + }).ToArray(); + + if (_memberReferences.Distinct(StringComparer.OrdinalIgnoreCase).Count() != _memberReferences.Length) + throw new ArgumentException("Qualified member references must be duplicate-free.", nameof(exactMemberReferences)); + } + + public void RecordReport( + int dataSetIndex, + string memberReference, + string value, + string? quality, + DateTimeOffset? deviceTimestampUtc, + DateTimeOffset receivedAtUtc, + ulong? sequenceNumber) + { + ValidateExactMember(dataSetIndex, memberReference); + lock (_sync) + { + if (_reports.Count >= MaximumReportObservations) + throw new InvalidOperationException($"Shadow report evidence exceeded the bounded limit of {MaximumReportObservations} observations."); + + _reports.Add(new ArMms.MmsDynamicReportShadowReportObservation + { + DataSetIndex = dataSetIndex, + MemberReference = _memberReferences[dataSetIndex], + Value = NormalizeValue(value), + Quality = NormalizeOptional(quality), + DeviceTimestampUtc = deviceTimestampUtc, + ReceivedAtUtc = receivedAtUtc, + SequenceNumber = sequenceNumber + }); + } + } + + public void RecordPoll( + int dataSetIndex, + string memberReference, + string value, + string? quality, + DateTimeOffset? deviceTimestampUtc, + DateTimeOffset readAtUtc) + { + ValidateExactMember(dataSetIndex, memberReference); + lock (_sync) + { + if (_polls.Count >= MaximumPollObservations) + throw new InvalidOperationException($"Shadow polling evidence exceeded the bounded limit of {MaximumPollObservations} observations."); + + _polls.Add(new ArMms.MmsDynamicReportShadowPollObservation + { + DataSetIndex = dataSetIndex, + MemberReference = _memberReferences[dataSetIndex], + Value = NormalizeValue(value), + Quality = NormalizeOptional(quality), + DeviceTimestampUtc = deviceTimestampUtc, + ReadAtUtc = readAtUtc + }); + } + } + + public void RecordDynamicActivationAttempt() + { + lock (_sync) + _dynamicActivationAttempts++; + } + + public void RecordReconnectAttempt() + { + lock (_sync) + _reconnectAttempts++; + } + + public void RecordReconnectSuccess( + bool reportResubscribed, + bool pollReferenceRecovered) + { + lock (_sync) + { + _successfulReconnects++; + if (reportResubscribed) + _reportResubscriptionsAfterReconnect++; + if (pollReferenceRecovered) + _pollReferenceRecoveriesAfterReconnect++; + } + } + + public ArMms.MmsDynamicReportShadowVerificationEvidence BuildEvidence(DateTimeOffset observedAtUtc) + { + lock (_sync) + { + return new ArMms.MmsDynamicReportShadowVerificationEvidence + { + EvidenceId = _evidenceId, + ObservedAtUtc = observedAtUtc, + MemberReferences = _memberReferences.ToArray(), + ReportObservations = _reports.ToArray(), + PollObservations = _polls.ToArray(), + ReconnectAttempts = _reconnectAttempts, + SuccessfulReconnects = _successfulReconnects, + ReportResubscriptionsAfterReconnect = _reportResubscriptionsAfterReconnect, + PollReferenceRecoveriesAfterReconnect = _pollReferenceRecoveriesAfterReconnect, + DynamicActivationAttempts = _dynamicActivationAttempts + }; + } + } + + private void ValidateExactMember(int dataSetIndex, string memberReference) + { + if (dataSetIndex < 0 || dataSetIndex >= _memberReferences.Length) + throw new ArgumentOutOfRangeException(nameof(dataSetIndex), dataSetIndex, $"Shadow DataSet index must be inside 0..{_memberReferences.Length - 1}."); + + var normalized = NormalizeReference(memberReference); + if (!_memberReferences[dataSetIndex].Equals(normalized, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Shadow observation identity mismatch at DataSet index {dataSetIndex}: expected={_memberReferences[dataSetIndex]}, actual={normalized}."); + } + } + + private static string NormalizeReference(string? reference) + => NormalizeOptional(reference).Replace('$', '.'); + + private static string NormalizeValue(string? value) + { + var normalized = NormalizeOptional(value); + if (normalized.Length == 0) + throw new ArgumentException("Shadow process value cannot be empty.", nameof(value)); + return normalized; + } + + private static string NormalizeOptional(string? value) + => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim(); +} diff --git a/Services/DynamicReportShadowPollingCompanionReader.cs b/Services/DynamicReportShadowPollingCompanionReader.cs new file mode 100644 index 00000000..1ed61b77 --- /dev/null +++ b/Services/DynamicReportShadowPollingCompanionReader.cs @@ -0,0 +1,186 @@ +using ArBinding = AR.Iec61850.Binding; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +internal sealed record DynamicReportShadowPollCompanionEvidence( + string? Quality, + DateTimeOffset? DeviceTimestampUtc, + string QualityReference, + string TimestampReference, + bool QualityReadAttempted, + bool TimestampReadAttempted); + +/// +/// Bounded read-only companion collector for the G2.6 independent MMS polling authority. +/// It derives only known IEC 61850 data-object sibling q/t paths from an exact persisted +/// process member, resolves them against the already-discovered live MMS directory, and +/// performs at most one q read plus one t read. Missing, unreadable, or undecodable +/// companions remain missing; no receive time, report metadata, or other fallback is used. +/// +internal static class DynamicReportShadowPollingCompanionReader +{ + private static readonly string[] KnownValueSuffixes = + { + ".instCVal.mag.f", + ".cVal.mag.f", + ".instMag.f", + ".mag.f", + ".stVal", + ".general", + ".dirGeneral", + ".phsA", + ".dirPhsA", + ".phsB", + ".dirPhsB", + ".phsC", + ".dirPhsC" + }; + + internal static async Task ReadAsync( + ArMms.MmsClientSession session, + ArMms.MmsIedModelDirectory directory, + ArMms.MmsFcResolvedPoint point, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(directory); + ArgumentNullException.ThrowIfNull(point); + + if (!TryBuildCompanionReferences(point.MmsReference, out var qualityReference, out var timestampReference)) + return Missing(string.Empty, string.Empty, false, false); + + string? quality = null; + DateTimeOffset? deviceTimestampUtc = null; + var qualityAttempted = false; + var timestampAttempted = false; + + if (TryResolveExactCompanion(directory, qualityReference, point.FunctionalConstraint, out var qualityPoint)) + { + qualityAttempted = true; + try + { + var read = await session + .ReadSingleVariableAsync(qualityPoint.ToObjectReference(), cancellationToken) + .ConfigureAwait(false); + if (read.IsSuccess && read.Value is not null) + { + var decoded = ArBinding.Iec61850QualityDecoder.Decode(read.Value); + if (decoded.IsDecoded) + quality = decoded.Validity; + } + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + // Companion metadata is optional evidence. A failed q read remains missing; + // it must never be substituted from report-side or host-side metadata. + } + } + + if (TryResolveExactCompanion(directory, timestampReference, point.FunctionalConstraint, out var timestampPoint)) + { + timestampAttempted = true; + try + { + var read = await session + .ReadSingleVariableAsync(timestampPoint.ToObjectReference(), cancellationToken) + .ConfigureAwait(false); + if (read.IsSuccess && read.Value is not null) + { + var decoded = ArBinding.Iec61850TimestampDecoder.Decode(read.Value); + if (decoded.IsDecoded && TryFindUtcTime(read.Value, out var utcTime)) + deviceTimestampUtc = utcTime.Value.ToUniversalTime(); + } + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + // Same fail-closed rule as q: no observedAt/receive-time fallback. + } + } + + return new DynamicReportShadowPollCompanionEvidence( + quality, + deviceTimestampUtc, + qualityReference, + timestampReference, + qualityAttempted, + timestampAttempted); + } + + internal static bool TryBuildCompanionReferences( + string valueReference, + out string qualityReference, + out string timestampReference) + { + var normalized = (valueReference ?? string.Empty).Trim().Replace('$', '.'); + foreach (var suffix in KnownValueSuffixes) + { + if (!normalized.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + continue; + + var dataObjectReference = normalized[..^suffix.Length]; + if (string.IsNullOrWhiteSpace(dataObjectReference) || !dataObjectReference.Contains('/')) + break; + + qualityReference = dataObjectReference + ".q"; + timestampReference = dataObjectReference + ".t"; + return true; + } + + qualityReference = string.Empty; + timestampReference = string.Empty; + return false; + } + + private static bool TryResolveExactCompanion( + ArMms.MmsIedModelDirectory directory, + string reference, + string expectedFunctionalConstraint, + out ArMms.MmsFcResolvedPoint point) + { + point = null!; + if (string.IsNullOrWhiteSpace(reference) || !directory.TryFindByMmsReference(reference, out var resolved)) + return false; + + if (resolved.IsControlAttribute || resolved.IsReportAttribute || + !resolved.FunctionalConstraint.Equals(expectedFunctionalConstraint, StringComparison.OrdinalIgnoreCase) || + !NormalizeReference(resolved.MmsReference).Equals(NormalizeReference(reference), StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + point = resolved; + return true; + } + + private static bool TryFindUtcTime(ArMms.MmsDataValue value, out ArMms.Iec61850UtcTime utcTime) + { + if (value.Kind == ArMms.MmsDataKind.UtcTime && value.Value is ArMms.Iec61850UtcTime direct) + { + utcTime = direct; + return true; + } + + if (value.Kind is ArMms.MmsDataKind.Structure or ArMms.MmsDataKind.Array) + { + foreach (var child in value.Children) + { + if (TryFindUtcTime(child, out utcTime)) + return true; + } + } + + utcTime = default; + return false; + } + + private static DynamicReportShadowPollCompanionEvidence Missing( + string qualityReference, + string timestampReference, + bool qualityAttempted, + bool timestampAttempted) + => new(null, null, qualityReference, timestampReference, qualityAttempted, timestampAttempted); + + private static string NormalizeReference(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); +} diff --git a/Services/DynamicReportShadowVerificationAcceptanceService.cs b/Services/DynamicReportShadowVerificationAcceptanceService.cs new file mode 100644 index 00000000..53c41077 --- /dev/null +++ b/Services/DynamicReportShadowVerificationAcceptanceService.cs @@ -0,0 +1,192 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +internal sealed class DynamicReportShadowVerificationAcceptanceResult +{ + public bool IsSuccess { get; init; } + public bool IsBlocked { get; init; } + public string Summary { get; init; } = string.Empty; + public ArMms.MmsDynamicReportShadowVerificationResult? Shadow { get; init; } + public ArMms.MmsDynamicReportProductionAcceptance? ProductionAcceptanceCandidate { get; init; } + public ArMms.MmsDynamicReportQualificationProfile? InputProfile { get; init; } + public string ProfilePath { get; init; } = string.Empty; + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); +} + +/// +/// ARSAS-side G2.6 gate from physical report-vs-poll observations to a typed +/// production-acceptance candidate. This service deliberately does not persist or +/// advance the qualification profile. A successful shadow therefore remains weaker +/// than ProductionEligible and production automatic dynamic reporting stays OFF. +/// +internal sealed class DynamicReportShadowVerificationAcceptanceService +{ + internal static readonly ArMms.MmsDynamicReportShadowVerificationOptions ProductionShadowOptions = new() + { + MinimumReportEdges = 2, + MaximumReportToPollLag = TimeSpan.FromSeconds(3), + MaximumPollTransitionToReportLag = TimeSpan.FromSeconds(3), + MaximumDeviceTimestampDelta = TimeSpan.FromMilliseconds(250), + RequireQualityEvidence = true, + RequireDeviceTimestampEvidence = true, + RequireReconnectCycle = true, + MaximumDynamicActivationAttemptsPerAssociation = 1 + }; + + private readonly DynamicReportQualificationProfileStore _profileStore; + + public DynamicReportShadowVerificationAcceptanceService( + DynamicReportQualificationProfileStore? profileStore = null) + { + _profileStore = profileStore ?? new DynamicReportQualificationProfileStore(); + } + + public async Task EvaluateAsync( + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + ArMms.MmsDynamicReportShadowVerificationEvidence evidence, + bool controlRegressionPassed, + bool staticReportingRegressionPassed, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(fullModelSignals); + ArgumentNullException.ThrowIfNull(evidence); + + var lines = new List + { + "G2.6 shadow acceptance contract: exact InformationReportProven identity/member envelope -> typed report-vs-independent-MMS shadow -> strict candidate production acceptance only.", + "G2.6 shadow safety: this service performs no MMS network I/O, no RCB/DataSet write, no profile save, and never calls MarkProductionEligible.", + "G2.6 production safety: Shadow PASS != ProductionEligible; production automatic dynamic reporting remains OFF until a separate explicit promotion gate closes.", + "G2.6 strict q/t safety: production quality acceptance requires actually observed paired report/poll quality AND device-timestamp evidence; missing evidence is never synthesized or treated as PASS." + }; + + ArMms.MmsDynamicReportIedIdentity identity; + try + { + identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return Blocked("Shadow identity preflight failed: " + ex.Message, lines); + } + + var loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + lines.Add($"G2.6 shadow profile: exists={loaded.Exists}; valid={loaded.IsValid}; state={loaded.Profile?.State.ToString() ?? "-"}; reason={loaded.Reason}"); + if (!loaded.IsValid || loaded.Profile is null) + return Blocked("Shadow verification requires the exact identity-compatible persisted profile.", lines, loaded.FilePath); + + var profile = loaded.Profile; + if (profile.State != ArMms.MmsDynamicReportQualificationState.InformationReportProven || + profile.RcbActivationProof?.IsSuccess != true || + profile.InformationReportProof?.IsSuccess != true) + { + return Blocked( + $"Shadow verification requires a complete InformationReportProven profile; current state is {profile.State}.", + lines, + loaded.FilePath, + profile); + } + + var qualifiedMembers = profile.RcbActivationProof.MemberReferences.ToArray(); + if (qualifiedMembers.Length == 0) + return Blocked("Persisted report proof contains no exact member sequence.", lines, loaded.FilePath, profile); + + if (!ExactSequenceEquals(qualifiedMembers, evidence.MemberReferences)) + { + lines.Add("G2.6 shadow expected members: " + string.Join(" | ", qualifiedMembers)); + lines.Add("G2.6 shadow observed members: " + string.Join(" | ", evidence.MemberReferences)); + return Blocked( + "Shadow evidence member sequence does not exactly match the InformationReport-proven DataSet envelope.", + lines, + loaded.FilePath, + profile); + } + + lines.Add($"G2.6 shadow exact envelope: rcb={profile.RcbActivationProof.RcbReference}; members={qualifiedMembers.Length}; evidenceId={evidence.EvidenceId}; reports={evidence.ReportObservations.Count}; polls={evidence.PollObservations.Count}; reconnect={evidence.SuccessfulReconnects}/{evidence.ReconnectAttempts}; dynamicAttempts={evidence.DynamicActivationAttempts}"); + + ArMms.MmsDynamicReportShadowVerificationResult shadow; + try + { + shadow = ArMms.MmsDynamicReportShadowVerificationPolicy.Evaluate( + evidence, + ProductionShadowOptions); + } + catch (Exception ex) when (ex is ArgumentException or ArgumentOutOfRangeException or InvalidOperationException or OverflowException) + { + lines.Add($"G2.6 shadow evaluator rejected evidence: {ex.GetType().Name}: {ex.Message}"); + return Blocked("Typed shadow evidence is invalid and cannot be accepted.", lines, loaded.FilePath, profile); + } + + lines.Add("G2.6 typed shadow: " + shadow.Summary); + lines.Add($"G2.6 typed gates: identity={shadow.ExactMemberIdentityPassed}; value={shadow.ValueParityPassed}; quality={shadow.QualityParityPassed}; timestamp={shadow.TimestampParityPassed}; order={shadow.ReportOrderPassed}; noMissing={shadow.NoMissingReportEdgesPassed}; noDuplicate={shadow.NoDuplicateReportEdgesPassed}; pollingAuthority={shadow.PollingAuthorityGuardPassed}; reconnect={shadow.ReconnectRegressionPassed}; noMutationLoop={shadow.NoRepeatedMutationLoopPassed}"); + foreach (var failure in shadow.Failures) + lines.Add("G2.6 shadow failure: " + failure); + + if (!shadow.IsSuccess) + { + return new DynamicReportShadowVerificationAcceptanceResult + { + Summary = "G2.6 shadow did not close every report-vs-poll gate. Profile remains InformationReportProven; ProductionEligible is OFF.", + Shadow = shadow, + InputProfile = profile, + ProfilePath = loaded.FilePath, + EvidenceLines = lines.ToArray() + }; + } + + var pairedQualityEvidence = ArMms.MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedQualityEvidence(evidence); + var pairedTimestampEvidence = ArMms.MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedTimestampEvidence(evidence); + lines.Add($"G2.6 strict observed evidence: pairedQuality={pairedQualityEvidence}; pairedDeviceTimestamp={pairedTimestampEvidence}; absenceCannotPass=true"); + + var acceptance = ArMms.MmsDynamicReportShadowProductionAcceptancePolicy.BuildStrict( + evidence, + shadow, + controlRegressionPassed, + staticReportingRegressionPassed); + + lines.Add($"G2.6 strict acceptance candidate: control={acceptance.ControlRegressionPassed}; staticReporting={acceptance.StaticReportingRegressionPassed}; dynamicInformationReport={acceptance.DynamicInformationReportRegressionPassed}; pollingAuthority={acceptance.PollingAuthorityGuardPassed}; reconnect={acceptance.ReconnectRegressionPassed}; quality={acceptance.QualityRegressionPassed}; noMutationLoop={acceptance.NoRepeatedMutationLoopPassed}; allPassed={acceptance.AllPassed}"); + lines.Add("G2.6 state boundary: candidate was NOT persisted and MarkProductionEligible was NOT called. Shadow PASS != ProductionEligible."); + + return new DynamicReportShadowVerificationAcceptanceResult + { + IsSuccess = shadow.IsSuccess && acceptance.AllPassed, + Summary = acceptance.AllPassed + ? "G2.6 strict shadow plus independent control/static regression inputs form a complete production-acceptance candidate. Profile is intentionally unchanged at InformationReportProven; explicit promotion remains a separate step." + : "G2.6 shadow passed, but strict observed q/t and/or independent control/static regression acceptance is incomplete. Profile remains InformationReportProven; ProductionEligible is OFF.", + Shadow = shadow, + ProductionAcceptanceCandidate = acceptance, + InputProfile = profile, + ProfilePath = loaded.FilePath, + EvidenceLines = lines.ToArray() + }; + } + + internal static bool ExactSequenceEquals(IEnumerable expected, IEnumerable actual) + { + ArgumentNullException.ThrowIfNull(expected); + ArgumentNullException.ThrowIfNull(actual); + var left = expected.Select(NormalizeReference).ToArray(); + var right = actual.Select(NormalizeReference).ToArray(); + return left.Length == right.Length && left.SequenceEqual(right, StringComparer.OrdinalIgnoreCase); + } + + private static string NormalizeReference(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); + + private static DynamicReportShadowVerificationAcceptanceResult Blocked( + string summary, + IReadOnlyList evidence, + string profilePath = "", + ArMms.MmsDynamicReportQualificationProfile? profile = null) + => new() + { + IsBlocked = true, + Summary = summary + " Production automatic dynamic reporting remains OFF.", + InputProfile = profile, + ProfilePath = profilePath, + EvidenceLines = evidence.ToArray() + }; +} diff --git a/Services/DynamicReportShadowVerificationCommissioningService.cs b/Services/DynamicReportShadowVerificationCommissioningService.cs new file mode 100644 index 00000000..42376e97 --- /dev/null +++ b/Services/DynamicReportShadowVerificationCommissioningService.cs @@ -0,0 +1,802 @@ +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +internal sealed class DynamicReportShadowVerificationCommissioningResult +{ + public bool IsSuccess { get; init; } + public bool IsBlocked { get; init; } + public bool PhysicalCollectionCompleted { get; init; } + public bool ShadowPassed { get; init; } + public bool CleanupSucceeded { get; init; } + public bool ReconnectProven { get; init; } + public string Summary { get; init; } = string.Empty; + public string RcbReference { get; init; } = string.Empty; + public IReadOnlyList MemberReferences { get; init; } = Array.Empty(); + public ArMms.MmsDynamicReportShadowVerificationEvidence? Evidence { get; init; } + public DynamicReportShadowVerificationAcceptanceResult? Acceptance { get; init; } + public IReadOnlyList EvidenceLines { get; init; } = Array.Empty(); +} + +/// +/// G2.6 physical shadow collector. +/// +/// The collector intentionally keeps two independent MMS authorities alive while each +/// report phase is armed: one transactional one-URCB dchg-only report association and one +/// read-only direct-MMS polling association. It performs two bounded report phases with a +/// deliberate teardown/reconnect between them. It never issues a control command, never +/// writes the qualification profile and never calls MarkProductionEligible. +/// +/// Report quality/timestamp evidence is accepted only when it is physically carried by the +/// received InformationReport and projected by ARIEC. Poll quality/timestamp evidence is +/// independently read from exact live q/t companion objects on the isolated read-only MMS +/// polling association. Neither side borrows metadata from the other, and host receive/read +/// time is never substituted for an IEC 61850 device timestamp. +/// +internal sealed class DynamicReportShadowVerificationCommissioningService +{ + internal const string Phase1ReadyMarker = "G2.6 SHADOW PHASE 1 READY — CAUSE ONE SAFE CHANGE"; + internal const string Phase2ReadyMarker = "G2.6 SHADOW PHASE 2 READY — CAUSE ONE SAFE CHANGE"; + internal static readonly TimeSpan AssociationTimeout = TimeSpan.FromSeconds(10); + internal static readonly TimeSpan ReportWindow = TimeSpan.FromSeconds(60); + internal static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(250); + internal const string TemporaryTriggerOptions = "dchg"; + internal const string TemporaryOptionalFields = "reason-for-inclusion data-set-name"; + + private readonly DynamicReportQualificationProfileStore _profileStore; + private readonly DynamicReportShadowVerificationAcceptanceService _acceptanceService; + + public DynamicReportShadowVerificationCommissioningService( + DynamicReportQualificationProfileStore? profileStore = null) + { + _profileStore = profileStore ?? new DynamicReportQualificationProfileStore(); + _acceptanceService = new DynamicReportShadowVerificationAcceptanceService(_profileStore); + } + + public async Task RunAsync( + Iec61850MonitorDevice device, + IReadOnlyList fullModelSignals, + IProgress? progress = null, + bool controlRegressionPassed = false, + bool staticReportingRegressionPassed = false, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(fullModelSignals); + + var lines = new List + { + "G2.6 physical shadow contract: exact persisted InformationReportProven envelope + transactional one-URCB dchg reporting + independent read-only MMS polling + deliberate reconnect.", + "G2.6 physical shadow command safety: this collector issues ZERO control commands. The operator must cause exactly one already-approved safe process/status change only after each READY marker.", + "G2.6 physical shadow profile safety: no profile save, no downgrade, no promotion, no MarkProductionEligible. Production automatic dynamic reporting remains OFF.", + "G2.6 physical shadow q/t safety: report q/t is accepted only from the InformationReport; poll q/t is read independently from exact live q/t companions. Missing metadata stays missing; TimeOfEntry/read time is never a device-timestamp fallback." + }; + + ArMms.MmsDynamicReportIedIdentity identity; + try + { + identity = DynamicReportQualificationIdentity.Build(device, fullModelSignals); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return Blocked("G2.6 shadow identity preflight failed: " + ex.Message, lines); + } + + var loaded = await _profileStore.LoadAsync(identity, cancellationToken).ConfigureAwait(false); + lines.Add($"G2.6 shadow profile: exists={loaded.Exists}; valid={loaded.IsValid}; state={loaded.Profile?.State.ToString() ?? "-"}; reason={loaded.Reason}"); + if (!loaded.IsValid || loaded.Profile is null) + return Blocked("G2.6 physical shadow requires the exact identity-compatible persisted qualification profile.", lines); + + var profile = loaded.Profile; + if (profile.State != ArMms.MmsDynamicReportQualificationState.InformationReportProven || + profile.RcbActivationProof?.IsSuccess != true || + profile.InformationReportProof?.IsSuccess != true) + { + return Blocked( + $"G2.6 physical shadow requires a complete InformationReportProven profile; current state is {profile.State}.", + lines, + profile.RcbActivationProof?.RcbReference, + profile.RcbActivationProof?.MemberReferences); + } + + var rcbReference = profile.RcbActivationProof.RcbReference; + var members = profile.RcbActivationProof.MemberReferences.ToArray(); + if (string.IsNullOrWhiteSpace(rcbReference) || members.Length == 0 || + members.Length > DynamicReportActivationCommissioningService.MaximumG24Members) + { + return Blocked( + "G2.6 physical shadow profile does not retain a usable exact one-URCB G2.4 member envelope.", + lines, + rcbReference, + members); + } + + lines.Add($"G2.6 exact target: rcb={rcbReference}; members={members.Length}; fieldProfile={profile.State}; pollInterval={PollInterval.TotalMilliseconds:0}ms; phaseWindow={ReportWindow.TotalSeconds:0}s"); + lines.Add("G2.6 exact members: " + string.Join(" | ", members)); + + var recorder = new DynamicReportShadowEvidenceRecorder( + $"arsas-g2.6-shadow-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}", + members); + + var phase1 = await RunPhaseAsync( + 1, + device, + members, + rcbReference, + recorder, + lines, + progress, + cancellationToken).ConfigureAwait(false); + if (!phase1.IsSuccess) + { + return Failed( + "G2.6 shadow phase 1 did not close. No reconnect/production conclusion is allowed.", + lines, + rcbReference, + members, + cleanupSucceeded: phase1.CleanupSucceeded); + } + + recorder.RecordReconnectAttempt(); + lines.Add("G2.6 deliberate reconnect boundary: phase 1 report + poll associations are closed; phase 2 must independently re-establish both paths and re-arm the exact RCB once."); + progress?.Report("G2.6 SHADOW RECONNECT — both phase-1 MMS associations closed. Re-establishing independent report + polling paths; do not cause a process change yet."); + + var phase2 = await RunPhaseAsync( + 2, + device, + members, + rcbReference, + recorder, + lines, + progress, + cancellationToken).ConfigureAwait(false); + if (!phase2.IsSuccess) + { + return Failed( + "G2.6 shadow reconnect phase did not close both report and polling paths. Production automatic dynamic reporting remains OFF.", + lines, + rcbReference, + members, + cleanupSucceeded: phase1.CleanupSucceeded && phase2.CleanupSucceeded); + } + + recorder.RecordReconnectSuccess( + reportResubscribed: phase2.ActivationProven, + pollReferenceRecovered: phase2.PollReferenceRecovered); + + var collected = recorder.BuildEvidence(DateTimeOffset.UtcNow); + lines.Add($"G2.6 physical evidence collected: reports={collected.ReportObservations.Count}; polls={collected.PollObservations.Count}; reconnect={collected.SuccessfulReconnects}/{collected.ReconnectAttempts}; reportResubscriptions={collected.ReportResubscriptionsAfterReconnect}; pollRecoveries={collected.PollReferenceRecoveriesAfterReconnect}; dynamicAttempts={collected.DynamicActivationAttempts}"); + lines.Add($"G2.6 observed report metadata: qualityObservations={collected.ReportObservations.Count(item => !string.IsNullOrWhiteSpace(item.Quality))}; timestampObservations={collected.ReportObservations.Count(item => item.DeviceTimestampUtc.HasValue)}. Missing q/t remains missing by design."); + lines.Add($"G2.6 observed independent poll metadata: qualityObservations={collected.PollObservations.Count(item => !string.IsNullOrWhiteSpace(item.Quality))}; timestampObservations={collected.PollObservations.Count(item => item.DeviceTimestampUtc.HasValue)}. Missing q/t remains missing by design."); + + var acceptance = await _acceptanceService.EvaluateAsync( + device, + fullModelSignals, + collected, + controlRegressionPassed, + staticReportingRegressionPassed, + cancellationToken).ConfigureAwait(false); + lines.AddRange(acceptance.EvidenceLines.Select(line => "ACCEPTANCE: " + line)); + + var shadowPassed = acceptance.Shadow?.IsSuccess == true; + var cleanup = phase1.CleanupSucceeded && phase2.CleanupSucceeded; + var reconnect = collected.ReconnectAttempts == 1 && + collected.SuccessfulReconnects == 1 && + collected.ReportResubscriptionsAfterReconnect == 1 && + collected.PollReferenceRecoveriesAfterReconnect == 1; + var physicalComplete = phase1.IsSuccess && phase2.IsSuccess && cleanup && reconnect; + var success = physicalComplete && shadowPassed; + + lines.Add($"G2.6 final collector result: physicalComplete={physicalComplete}; shadowPassed={shadowPassed}; cleanup={cleanup}; reconnect={reconnect}; strictProductionCandidate={acceptance.ProductionAcceptanceCandidate?.AllPassed == true}; collectorSuccess={success}"); + lines.Add("G2.6 final state boundary: physical shadow evidence cannot modify the persisted profile. Shadow PASS != ProductionEligible; production automatic dynamic reporting remains OFF."); + + return new DynamicReportShadowVerificationCommissioningResult + { + IsSuccess = success, + PhysicalCollectionCompleted = physicalComplete, + ShadowPassed = shadowPassed, + CleanupSucceeded = cleanup, + ReconnectProven = reconnect, + RcbReference = rcbReference, + MemberReferences = members, + Evidence = collected, + Acceptance = acceptance, + Summary = success + ? "G2.6 physical shadow PASS: two exact dchg/report-vs-poll phases plus deliberate reconnect closed the typed shadow gates. Profile remains InformationReportProven; ProductionEligible is still OFF pending separate explicit promotion." + : physicalComplete + ? "G2.6 physical collection completed, but the strict typed shadow remains fail-closed. Inspect q/t, parity, missing-edge and independent regression gates; profile remains InformationReportProven." + : "G2.6 physical shadow did not complete every collection/cleanup/reconnect gate. Production automatic dynamic reporting remains OFF.", + EvidenceLines = lines.ToArray() + }; + } + + private static async Task RunPhaseAsync( + int phaseNumber, + Iec61850MonitorDevice device, + IReadOnlyList qualifiedReferences, + string rcbReference, + DynamicReportShadowEvidenceRecorder recorder, + ICollection evidence, + IProgress? progress, + CancellationToken cancellationToken) + { + var label = $"G2.6 shadow phase {phaseNumber}"; + var pollReferenceRecovered = false; + var activationProven = false; + var reportProven = false; + var monitorCleanup = true; + var fieldRestore = true; + var freshClosure = true; + var temporaryDataSetReference = string.Empty; + + await using var pollSession = new ArMms.MmsClientSession(); + try + { + await pollSession.ConnectAsync(device.IpAddress, device.Port, AssociationTimeout, cancellationToken).ConfigureAwait(false); + var pollDiscovery = await pollSession.DiscoverAsync( + probeReportAttributes: false, + maxReportAttributeProbes: 0, + cancellationToken: cancellationToken, + readDataSetDirectories: false, + maxDataSetDirectoryReads: 0).ConfigureAwait(false); + evidence.Add($"{label} polling association: state={pollSession.State}; localTcpAddress={TextOrDash(pollSession.LocalTcpAddress)}; readOnly=true; discovery={pollDiscovery.Summary}"); + + if (!DynamicReportActivationCommissioningService.TryResolveExactQualifiedMembers( + pollDiscovery.IedDirectory, + qualifiedReferences, + out var pollPoints, + out var pollReason)) + { + evidence.Add($"{label} polling exact-member resolution failed: {pollReason}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: true); + } + + pollReferenceRecovered = await CapturePollCycleAsync( + pollSession, + pollDiscovery.IedDirectory, + pollPoints, + qualifiedReferences, + recorder, + evidence, + label + " initial poll", + cancellationToken).ConfigureAwait(false); + if (!pollReferenceRecovered || !pollSession.IsMmsInitiated) + { + evidence.Add($"{label} polling baseline did not prove every exact reference; no report mutation will be attempted."); + return ShadowPhaseResult.Fail(cleanupSucceeded: true); + } + + var reportSession = new ArMms.MmsClientSession(); + ArMms.MmsDynamicRcbCommissioningFieldLease? fieldLease = null; + ArMms.MmsPersistentReportMonitorSession? monitor = null; + try + { + await reportSession.ConnectAsync(device.IpAddress, device.Port, AssociationTimeout, cancellationToken).ConfigureAwait(false); + var discovery = await reportSession.DiscoverAsync( + probeReportAttributes: true, + maxReportAttributeProbes: 64, + cancellationToken: cancellationToken, + readDataSetDirectories: false, + maxDataSetDirectoryReads: 0).ConfigureAwait(false); + evidence.Add($"{label} report association: state={reportSession.State}; localTcpAddress={TextOrDash(reportSession.LocalTcpAddress)}; discovery={discovery.Summary}"); + + if (!DynamicReportActivationCommissioningService.TryResolveExactQualifiedMembers( + discovery.IedDirectory, + qualifiedReferences, + out var exactPoints, + out var exactReason)) + { + evidence.Add($"{label} report exact-member resolution failed: {exactReason}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: true); + } + + foreach (var point in exactPoints) + { + var read = await reportSession.ReadSingleVariableAsync(point.ToObjectReference(), cancellationToken).ConfigureAwait(false); + if (!read.IsSuccess || read.Value is null || !reportSession.IsMmsInitiated) + { + evidence.Add($"{label} report preflight direct read failed: ref={point.MmsReference}; result={read.Message}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: true); + } + } + + var selectedRcb = discovery.ReportInventory.ReportControls.FirstOrDefault(candidate => SameReference(candidate.Reference, rcbReference)); + if (selectedRcb is null || selectedRcb.Buffered) + { + evidence.Add($"{label} exact persisted URCB is absent or no longer unbuffered: {rcbReference}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: true); + } + + var oneRcb = new ArMms.MmsReportInventory(); + oneRcb.ReportControls.Add(selectedRcb); + var preLeaseAvailability = await reportSession.CheckReportControlAvailabilityAsync( + oneRcb, + discovery.IedDirectory, + new ArMms.MmsRcbAvailabilityOptions { MaxReportControls = 1, ReadDataSetDirectories = false }, + cancellationToken).ConfigureAwait(false); + var preLease = preLeaseAvailability.ReportControls.SingleOrDefault(); + var freeReason = "snapshot missing"; + var free = preLease is not null && DynamicReportActivationCommissioningServiceV2.IsLeaseableFreeUrcbForG24(preLease, out freeReason); + evidence.Add($"{label} pre-lease exact URCB: free={free}; reason={freeReason}"); + if (!free || preLease is null) + return ShadowPhaseResult.Fail(cleanupSucceeded: true); + + ApplyFreshSnapshot(selectedRcb, preLease); + var prepare = await reportSession.PrepareDynamicRcbCommissioningFieldsAsync( + selectedRcb, + TemporaryTriggerOptions, + TemporaryOptionalFields, + cancellationToken).ConfigureAwait(false); + AppendWriteSteps(evidence, label + " proof-field prepare", prepare.WriteSteps); + if (!prepare.IsSuccess || prepare.Lease is null) + { + evidence.Add($"{label} dchg-only proof-field lease failed: rollback={prepare.CleanupSucceeded}; result={prepare.Message}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: prepare.CleanupSucceeded); + } + fieldLease = prepare.Lease; + + var plan = ArMms.MmsReportSubscriptionPlanner.BuildDynamicPlan( + discovery.ReportInventory, + discovery.IedDirectory, + exactPoints.Select(point => point.UserReference), + preferredLogicalDevice: selectedRcb.Domain, + preferredRcbReference: selectedRcb.Reference, + dataSetName: $"AR_G26S{phaseNumber}_" + Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(), + strictRcb: true, + allowUrCbFallback: true, + allowPollingFallback: false); + temporaryDataSetReference = plan.DataSetReference; + if (!DynamicReportActivationCommissioningService.ValidatePlanAgainstEnvelope(plan, selectedRcb.Reference, qualifiedReferences, out var planReason)) + { + evidence.Add($"{label} strict plan rejected: {planReason}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: false); + } + + var postLeaseAvailability = await reportSession.CheckReportControlAvailabilityAsync( + oneRcb, + discovery.IedDirectory, + DynamicReportActivationCommissioningServiceV2.BuildPostLeaseAvailabilityOptions(selectedRcb.Reference), + cancellationToken).ConfigureAwait(false); + var postLease = postLeaseAvailability.ReportControls.SingleOrDefault(); + if (!DynamicReportSpontaneousDataChangeCommissioningService.IsPostLeaseUrcbSafeForDchg( + postLease, + reportSession.LocalTcpAddress, + out var postLeaseReason)) + { + evidence.Add($"{label} post-lease exact URCB rejected: {postLeaseReason}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: false); + } + + ApplyFreshSnapshot(plan.ReportControl!, postLease!); + EnsureAttribute(plan.ReportControl!, "TrgOps"); + EnsureAttribute(plan.ReportControl!, "OptFlds"); + plan.ReportControl!.TriggerOptions = TemporaryTriggerOptions; + plan.ReportControl.OptionalFields = TemporaryOptionalFields; + selectedRcb.TriggerOptions = TemporaryTriggerOptions; + selectedRcb.OptionalFields = TemporaryOptionalFields; + + recorder.RecordDynamicActivationAttempt(); + monitorCleanup = false; + var attempt = await reportSession.StartPersistentReportMonitorWithAttemptEvidenceAsync( + plan, + triggerGeneralInterrogation: false, + deleteDynamicDataSetOnStop: true, + directory: discovery.IedDirectory, + cancellationToken: cancellationToken).ConfigureAwait(false); + AppendWriteSteps(evidence, label + " activation", attempt.StartResult.WriteSteps); + if (!attempt.IsSuccess || attempt.StartResult.Session is null) + { + monitorCleanup = attempt.CleanupSucceeded; + evidence.Add($"{label} activation failed: reason={attempt.FailureReason}; cleanup={attempt.CleanupSucceeded}; result={attempt.StartResult.Message}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: monitorCleanup); + } + + monitor = attempt.StartResult.Session; + var readback = await reportSession.GetDataSetDirectoryAsync(plan.DataSetReference, discovery.IedDirectory, cancellationToken).ConfigureAwait(false); + var exactReadback = readback.IsSuccess && ExactSequenceEquals(qualifiedReferences, 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; + activationProven = exactReadback && bindingAccepted && rptEnaAccepted && reportSession.IsMmsInitiated; + evidence.Add($"{label} activation proof: success={activationProven}; exactReadback={exactReadback}; binding={bindingAccepted}; RptEna={rptEnaAccepted}; GI=false; associationHealthy={reportSession.IsMmsInitiated}"); + if (!activationProven) + return ShadowPhaseResult.Fail(cleanupSucceeded: false); + + using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var pollTask = PollLoopAsync( + pollSession, + pollDiscovery.IedDirectory, + pollPoints, + qualifiedReferences, + recorder, + evidence, + label, + pollCts.Token); + + var readyMarker = phaseNumber == 1 ? Phase1ReadyMarker : Phase2ReadyMarker; + progress?.Report($"{readyMarker} — report is strict dchg-only and independent MMS polling is already active. Cause exactly ONE approved safe change affecting the proven member envelope. No automatic command is issued."); + evidence.Add($"{readyMarker}: waiting up to {ReportWindow.TotalSeconds:0}s; GI=false; independentPoll=true; autoControl=false"); + + ArMms.MmsPersistentReportMonitorReceiveResult receive; + try + { + receive = await reportSession.ReceivePersistentReportMonitorSliceAsync( + monitor, + ReportWindow, + pollDirectory: null, + pollReferences: null, + pollInterval: null, + triggerGeneralInterrogation: false, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + finally + { + pollCts.Cancel(); + try { await pollTask.ConfigureAwait(false); } + catch (OperationCanceledException) { } + } + + evidence.Add($"{label} receive: reports={receive.Reports.Count}; unrouted={reportSession.UnroutedPersistentReportCount}; route={TextOrDash(reportSession.LastReceiveRoutingSummary)}; GI=false; result={receive.Message}"); + foreach (var frame in receive.Reports) + { + var validation = DynamicReportSpontaneousDataChangeCommissioningService.ValidateSpontaneousDataChangeFrame( + frame, + postLease!.ReportId, + plan.DataSetReference, + qualifiedReferences); + evidence.Add($"{label} report candidate: receivedAt={frame.ReceivedAt:O}; sqNum={frame.Header.SequenceNumber?.ToString() ?? "-"}; values={frame.Values.Count}; included=[{string.Join(",", frame.IncludedDataSetIndexes)}]; valid={validation.IsSuccess}; reason={validation.Reason}"); + if (!validation.IsSuccess) + continue; + + RecordFrame(frame, qualifiedReferences, recorder, evidence, label); + reportProven = true; + break; + } + + if (!reportProven) + evidence.Add($"{label} did not receive one exact dchg-only InformationReport inside the bounded window."); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + evidence.Add($"{label} fail-closed exception: {ex.GetType().Name}: {ex.Message}"); + } + finally + { + if (monitor is not null) + { + try + { + var stop = await reportSession.StopPersistentReportMonitorAsync(monitor, CancellationToken.None).ConfigureAwait(false); + monitorCleanup = stop.IsSuccess; + AppendWriteSteps(evidence, label + " monitor cleanup", stop.WriteSteps); + evidence.Add($"{label} monitor cleanup: success={stop.IsSuccess}; result={stop.Message}"); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException) + { + monitorCleanup = false; + evidence.Add($"{label} monitor cleanup exception: {ex.GetType().Name}: {ex.Message}"); + } + } + + if (fieldLease is not null) + { + fieldRestore = false; + try + { + var restore = await reportSession.RestoreDynamicRcbCommissioningFieldsAsync(fieldLease, CancellationToken.None).ConfigureAwait(false); + fieldRestore = restore.IsSuccess; + AppendWriteSteps(evidence, label + " proof-field restore", restore.WriteSteps); + evidence.Add($"{label} proof-field restore: success={restore.IsSuccess}; result={restore.Message}"); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException) + { + evidence.Add($"{label} proof-field restore exception: {ex.GetType().Name}: {ex.Message}"); + } + } + + await reportSession.DisposeAsync().ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(temporaryDataSetReference)) + { + freshClosure = await ProveFreshCleanupClosureAsync( + device, + rcbReference, + temporaryDataSetReference, + evidence, + label, + CancellationToken.None).ConfigureAwait(false); + } + + var cleanup = monitorCleanup && fieldRestore && freshClosure; + var success = activationProven && reportProven && pollReferenceRecovered && cleanup; + evidence.Add($"{label} combined: activation={activationProven}; report={reportProven}; pollReference={pollReferenceRecovered}; monitorCleanup={monitorCleanup}; fieldRestore={fieldRestore}; freshClosure={freshClosure}; success={success}"); + return new ShadowPhaseResult(success, cleanup, activationProven, pollReferenceRecovered); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + evidence.Add($"{label} polling association exception: {ex.GetType().Name}: {ex.Message}"); + return ShadowPhaseResult.Fail(cleanupSucceeded: true); + } + } + + private static async Task CapturePollCycleAsync( + ArMms.MmsClientSession session, + ArMms.MmsIedModelDirectory directory, + IReadOnlyList points, + IReadOnlyList qualifiedReferences, + DynamicReportShadowEvidenceRecorder recorder, + ICollection evidence, + string label, + CancellationToken cancellationToken) + { + if (points.Count != qualifiedReferences.Count) + return false; + + for (var index = 0; index < points.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var readAtUtc = DateTimeOffset.UtcNow; + var read = await session.ReadSingleVariableAsync(points[index].ToObjectReference(), cancellationToken).ConfigureAwait(false); + if (!read.IsSuccess || read.Value is null || !session.IsMmsInitiated) + { + evidence.Add($"{label}: read failed index={index}; ref={qualifiedReferences[index]}; result={read.Message}"); + return false; + } + + var companion = await DynamicReportShadowPollingCompanionReader.ReadAsync( + session, + directory, + points[index], + cancellationToken).ConfigureAwait(false); + + recorder.RecordPoll( + index, + qualifiedReferences[index], + ArMms.MmsDataValueRenderer.ToCompactString(read.Value), + companion.Quality, + companion.DeviceTimestampUtc, + readAtUtc); + evidence.Add($"{label}: read success index={index}; ref={qualifiedReferences[index]}; q={(string.IsNullOrWhiteSpace(companion.Quality) ? "missing" : "observed")}; t={(companion.DeviceTimestampUtc.HasValue ? "observed" : "missing")}; qAttempt={companion.QualityReadAttempted}; tAttempt={companion.TimestampReadAttempted}; qRef={TextOrDash(companion.QualityReference)}; tRef={TextOrDash(companion.TimestampReference)}"); + } + + return true; + } + + private static async Task PollLoopAsync( + ArMms.MmsClientSession session, + ArMms.MmsIedModelDirectory directory, + IReadOnlyList points, + IReadOnlyList qualifiedReferences, + DynamicReportShadowEvidenceRecorder recorder, + ICollection evidence, + string label, + CancellationToken cancellationToken) + { + var cycles = 0; + var failures = 0; + while (!cancellationToken.IsCancellationRequested && session.IsMmsInitiated) + { + cycles++; + for (var index = 0; index < points.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var readAtUtc = DateTimeOffset.UtcNow; + var read = await session.ReadSingleVariableAsync(points[index].ToObjectReference(), cancellationToken).ConfigureAwait(false); + if (!read.IsSuccess || read.Value is null) + { + failures++; + continue; + } + + var companion = await DynamicReportShadowPollingCompanionReader.ReadAsync( + session, + directory, + points[index], + cancellationToken).ConfigureAwait(false); + + recorder.RecordPoll( + index, + qualifiedReferences[index], + ArMms.MmsDataValueRenderer.ToCompactString(read.Value), + companion.Quality, + companion.DeviceTimestampUtc, + readAtUtc); + } + + await Task.Delay(PollInterval, cancellationToken).ConfigureAwait(false); + } + + evidence.Add($"{label} independent polling stopped: cycles={cycles}; failures={failures}; associationHealthy={session.IsMmsInitiated}"); + } + + private static void RecordFrame( + ArMms.MmsReportFrame frame, + IReadOnlyList qualifiedReferences, + DynamicReportShadowEvidenceRecorder recorder, + ICollection evidence, + string label) + { + var projection = ArMms.MmsReportValueProjector.Project(frame); + foreach (var value in frame.Values) + { + if (value.Index < 0 || value.Index >= qualifiedReferences.Count || value.Value is null || value.FailureCode.HasValue) + continue; + + var expected = qualifiedReferences[value.Index]; + var projected = projection.Updates.FirstOrDefault(update => SameReference(update.Reference, expected) || + (value.Member is not null && SameReference(update.Reference, value.Member.UserReference))); + + var quality = projected?.HasQuality == true ? projected.Quality : null; + DateTimeOffset? deviceTimestamp = null; + if (projected?.HasTimestamp == true && + DateTimeOffset.TryParse(projected.Timestamp, out var parsedTimestamp)) + { + deviceTimestamp = parsedTimestamp; + } + + recorder.RecordReport( + value.Index, + expected, + ArMms.MmsDataValueRenderer.ToCompactString(value.Value), + quality, + deviceTimestamp, + frame.ReceivedAt, + frame.Header.SequenceNumber); + evidence.Add($"{label} recorded report observation: index={value.Index}; member={expected}; q={(string.IsNullOrWhiteSpace(quality) ? "missing" : "observed")}; t={(deviceTimestamp.HasValue ? "observed" : "missing")}; receivedAt={frame.ReceivedAt:O}; sqNum={frame.Header.SequenceNumber?.ToString() ?? "-"}"); + } + + foreach (var warning in projection.Warnings) + evidence.Add($"{label} report projection warning: {warning}"); + } + + private static async Task ProveFreshCleanupClosureAsync( + Iec61850MonitorDevice device, + string rcbReference, + string temporaryDataSetReference, + ICollection evidence, + string label, + CancellationToken cancellationToken) + { + await using var fresh = new ArMms.MmsClientSession(); + try + { + await fresh.ConnectAsync(device.IpAddress, device.Port, AssociationTimeout, cancellationToken).ConfigureAwait(false); + var discovery = await fresh.DiscoverAsync( + probeReportAttributes: true, + maxReportAttributeProbes: 64, + cancellationToken: cancellationToken, + readDataSetDirectories: false, + maxDataSetDirectoryReads: 0).ConfigureAwait(false); + var rcb = discovery.ReportInventory.ReportControls.FirstOrDefault(candidate => SameReference(candidate.Reference, rcbReference)); + if (rcb is null) + { + evidence.Add($"{label} fresh cleanup: exact RCB absent."); + return false; + } + + var one = new ArMms.MmsReportInventory(); + one.ReportControls.Add(rcb); + var availability = await fresh.CheckReportControlAvailabilityAsync( + one, + discovery.IedDirectory, + new ArMms.MmsRcbAvailabilityOptions { MaxReportControls = 1, ReadDataSetDirectories = false }, + cancellationToken).ConfigureAwait(false); + var snapshot = availability.ReportControls.SingleOrDefault(); + var nameAbsent = DynamicReportCleanupClosureCommissioningService.IsTemporaryDataSetAbsentFromNameList( + discovery.Snapshot, + temporaryDataSetReference, + out var nameReason); + var directory = await fresh.GetDataSetDirectoryAsync(temporaryDataSetReference, discovery.IedDirectory, cancellationToken).ConfigureAwait(false); + var directoryAbsent = !directory.IsSuccess; + var closed = DynamicReportCleanupClosureCommissioningService.IsFreshCleanupClosed( + snapshot, + nameAbsent, + directoryAbsent, + fresh.IsMmsInitiated, + out var closureReason); + evidence.Add($"{label} fresh cleanup: nameAbsent={nameAbsent}; directoryAbsent={directoryAbsent}; association={fresh.IsMmsInitiated}; namespace={nameReason}; result={closureReason}"); + return closed; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or ObjectDisposedException or TimeoutException) + { + evidence.Add($"{label} fresh cleanup exception: {ex.GetType().Name}: {ex.Message}"); + return false; + } + } + + 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 bool SuccessfulStep(IEnumerable steps, string attribute) + => steps.Any(step => step.Attempted && step.IsSuccess && step.Attribute.Equals(attribute, StringComparison.OrdinalIgnoreCase)); + + 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 bool ExactSequenceEquals(IEnumerable expected, IEnumerable actual) + { + var left = expected.Select(NormalizeReference).ToArray(); + var right = actual.Select(NormalizeReference).ToArray(); + return left.Length == right.Length && left.SequenceEqual(right, 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? text) + { + if (bool.TryParse(text, out var parsed)) return parsed; + return (text ?? string.Empty).Trim() switch { "1" => true, "0" => false, _ => null }; + } + + private static string TextOrDash(string? text) + => string.IsNullOrWhiteSpace(text) ? "-" : text.Trim(); + + private static DynamicReportShadowVerificationCommissioningResult Blocked( + string summary, + IReadOnlyList evidence, + string? rcbReference = null, + IReadOnlyList? members = null) + => new() + { + IsBlocked = true, + Summary = summary + " Production automatic dynamic reporting remains OFF.", + RcbReference = rcbReference ?? string.Empty, + MemberReferences = members?.ToArray() ?? Array.Empty(), + EvidenceLines = evidence.ToArray() + }; + + private static DynamicReportShadowVerificationCommissioningResult Failed( + string summary, + IReadOnlyList evidence, + string rcbReference, + IReadOnlyList members, + bool cleanupSucceeded) + => new() + { + Summary = summary, + RcbReference = rcbReference, + MemberReferences = members.ToArray(), + CleanupSucceeded = cleanupSucceeded, + EvidenceLines = evidence.ToArray() + }; + + private sealed record ShadowPhaseResult( + bool IsSuccess, + bool CleanupSucceeded, + bool ActivationProven, + bool PollReferenceRecovered) + { + public static ShadowPhaseResult Fail(bool cleanupSucceeded) + => new(false, cleanupSucceeded, false, false); + } +} diff --git a/Services/DynamicReportSpontaneousDataChangeCommissioningService.cs b/Services/DynamicReportSpontaneousDataChangeCommissioningService.cs index e97c1f18..8e519195 100644 --- a/Services/DynamicReportSpontaneousDataChangeCommissioningService.cs +++ b/Services/DynamicReportSpontaneousDataChangeCommissioningService.cs @@ -22,6 +22,7 @@ internal sealed class DynamicReportSpontaneousDataChangeCommissioningResult public bool ProofFieldRestoreSucceeded { get; init; } public bool FreshCleanupClosureSucceeded { get; init; } public bool AssociationHealthyAfterReport { get; init; } + public DateTimeOffset? ReportReceivedAtUtc { get; init; } public string Summary { get; init; } = string.Empty; public ArMms.MmsDynamicReportIedIdentity? Identity { get; init; } public ArMms.MmsDynamicReportQualificationProfile? InputProfile { get; init; } @@ -121,6 +122,7 @@ profile.AcceptedEnvelope is null || var includedIndexes = Array.Empty(); var includedMembers = Array.Empty(); var includedReasons = Array.Empty(); + DateTimeOffset? reportReceivedAtUtc = null; var reportId = string.Empty; var failureSummary = string.Empty; @@ -302,16 +304,17 @@ afterEnable is not null && afterEnable.IsSuccess && foreach (var frame in receive.Reports) { var validation = ValidateSpontaneousDataChangeFrame(frame, reportId, plan.DataSetReference, qualifiedReferences); - evidence.Add($"G2.5-A 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}; reasons=[{string.Join(",", validation.Reasons)}]; reason={validation.Reason}"); + evidence.Add($"G2.5-A report candidate: receivedAt={frame.ReceivedAt:O}; 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}; reasons=[{string.Join(",", validation.Reasons)}]; reason={validation.Reason}"); if (!validation.IsSuccess) continue; spontaneousProven = true; associationHealthyAfterReport = auxiliary.IsMmsInitiated; + reportReceivedAtUtc = frame.ReceivedAt; includedIndexes = validation.IncludedIndexes.ToArray(); includedMembers = validation.IncludedMemberReferences.ToArray(); includedReasons = validation.Reasons.ToArray(); - evidence.Add($"G2.5-A spontaneous dchg proof: success={spontaneousProven && associationHealthyAfterReport}; kind=DataChange; actual=true; identity=true; mappedIncludedMembers={includedIndexes.Length}; associationHealthy={associationHealthyAfterReport}; GIrequested=false"); + evidence.Add($"G2.5-A spontaneous dchg proof: success={spontaneousProven && associationHealthyAfterReport}; receivedAt={reportReceivedAtUtc:O}; kind=DataChange; actual=true; identity=true; mappedIncludedMembers={includedIndexes.Length}; associationHealthy={associationHealthyAfterReport}; GIrequested=false"); break; } @@ -394,6 +397,7 @@ afterEnable is not null && afterEnable.IsSuccess && ProofFieldRestoreSucceeded = fieldRestore, FreshCleanupClosureSucceeded = freshClosure, AssociationHealthyAfterReport = associationHealthyAfterReport, + ReportReceivedAtUtc = reportReceivedAtUtc, Summary = success ? $"G2.5-A PASS: exact G2.4-proven URCB delivered a spontaneous data-change InformationReport without GI for {includedIndexes.Length} included member(s), and monitor/proof-field/fresh-association cleanup all passed. Profile remains InformationReportProven; production dynamic reporting remains OFF." : "G2.5-A did not prove the complete spontaneous dchg gate. Cleanup evidence is retained; the InformationReportProven profile is unchanged and production dynamic reporting remains OFF.", @@ -537,4 +541,4 @@ private static DynamicReportSpontaneousDataChangeCommissioningResult FailedBefor => new() { Summary = summary + " No RCB/DataSet mutation was attempted.", Identity = identity, InputProfile = profile, RcbReference = rcbReference, MemberReferences = memberReferences.ToArray(), MonitorCleanupSucceeded = true, ProofFieldRestoreSucceeded = true, FreshCleanupClosureSucceeded = true, ProfilePath = profilePath, EvidenceLines = evidence.ToArray() }; private static string TextOrDash(string? value) => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); -} +} \ No newline at end of file diff --git a/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs new file mode 100644 index 00000000..a1bae5e8 --- /dev/null +++ b/Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs @@ -0,0 +1,106 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArMms = AR.Iec61850.Mms; + +namespace ArIED61850Tester.Services; + +public sealed partial class NativeIec61850Client +{ + private readonly Dictionary _guardedRuntimeContexts = + new(StringComparer.OrdinalIgnoreCase); + + private sealed record GuardedRuntimeContextLoadResult( + ArMms.MmsDynamicReportGuardedRuntimePlanningContext? Context, + string Reason) + { + public bool IsAuthorizedCandidate => Context is not null; + } + + private static async Task TryLoadGuardedRuntimeContextAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(device); + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var identity = DynamicReportQualificationIdentity.Build(device, device.Signals.ToArray()); + var load = await new DynamicReportQualificationProfileStore() + .LoadAsync(identity, cancellationToken) + .ConfigureAwait(false); + + if (!load.IsValid || load.Profile is null) + { + return new GuardedRuntimeContextLoadResult( + null, + string.IsNullOrWhiteSpace(load.Reason) + ? "No valid identity-compatible dynamic qualification profile is available." + : load.Reason); + } + + if (load.Profile.State < ArMms.MmsDynamicReportQualificationState.InformationReportProven) + { + return new GuardedRuntimeContextLoadResult( + null, + $"Dynamic qualification profile is {load.Profile.State}; guarded Smart Dynamic runtime requires InformationReportProven or stronger evidence."); + } + + if (load.Profile.RcbActivationProof?.IsSuccess != true || + load.Profile.InformationReportProof?.IsSuccess != true || + load.Profile.InformationReportProof.Kind != ArMms.MmsDynamicInformationReportKind.DataChange) + { + return new GuardedRuntimeContextLoadResult( + null, + "Stored dynamic qualification evidence does not contain a successful data-change InformationReport chain; guarded Smart Dynamic runtime remains withheld."); + } + + return new GuardedRuntimeContextLoadResult( + new ArMms.MmsDynamicReportGuardedRuntimePlanningContext + { + Profile = load.Profile, + CurrentIdentity = identity + }, + "Smart Dynamic RCB guarded runtime candidate loaded from identity-compatible InformationReportProven data-change evidence. ProductionEligible certification remains separate."); + } + catch (Exception ex) when (ex is InvalidOperationException or ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException) + { + return new GuardedRuntimeContextLoadResult( + null, + $"Guarded Smart Dynamic runtime profile could not be trusted: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static ArMms.MmsCapabilityAwareHybridReportAcquisitionPlan BuildCapabilityPlanWithGuardedRuntime( + Iec61850SignalCatalogDocument catalog, + IEnumerable requestedSignals, + ArMms.MmsReportInventory inventory, + ArMms.MmsRcbAvailabilityResult availability, + ArMms.MmsIedModelDirectory liveDirectory, + AR.Iec61850.Acse.AcseMmsNegotiatedCapabilities? negotiatedCapabilities, + ArMms.MmsHybridReportAcquisitionOptions options, + ArMms.MmsDynamicReportGuardedRuntimePlanningContext? guardedContext) + => guardedContext is null + ? ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build( + catalog, + requestedSignals, + inventory, + availability, + liveDirectory, + negotiatedCapabilities, + options) + : ArMms.MmsGuardedDynamicReportRuntimePlanner.Build( + catalog, + requestedSignals, + inventory, + availability, + liveDirectory, + negotiatedCapabilities, + options, + guardedContext); + + private bool TryGetGuardedRuntimeContext( + string planId, + out ArMms.MmsDynamicReportGuardedRuntimePlanningContext context) + => _guardedRuntimeContexts.TryGetValue(planId, out context!); +} diff --git a/Services/NativeIec61850Client.HybridReporting.P4.cs b/Services/NativeIec61850Client.HybridReporting.P4.cs index 09a3da8a..b982b360 100644 --- a/Services/NativeIec61850Client.HybridReporting.P4.cs +++ b/Services/NativeIec61850Client.HybridReporting.P4.cs @@ -7,41 +7,169 @@ namespace ArIED61850Tester.Services; public sealed partial class NativeIec61850Client { /// - /// P6.1 baseline-safety compatibility hook. + /// G2.6 Smart Auto recovery for a static report segment that cannot be activated. /// - /// P4 originally converted a failed static activation into a brand-new dynamic - /// DataSet/RCB write attempt. That changed the proven pre-P0 failure semantics and made - /// one static problem capable of mutating another RCB or destabilizing the association. - /// Static failure is now isolated again: no dynamic DataSet is created, no alternate RCB - /// is written, and bounded MMS polling remains the fallback for the affected signal set. + /// Recovery is deliberately narrower than the original P4 experiment: + /// - the failed static RCB is excluded from the recovery availability evidence; + /// - static RCBs are disabled in the recovery planner, so only an alternate dynamic + /// BRCB/URCB can be selected; + /// - InformationReportProven guarded-runtime authority is preserved by the original + /// PlanId, so recovery may select only the exact already-proven dynamic RCB/member set; + /// - a post-mutation static failure may recover only after rollback/cleanup is proven; + /// - ARIEC capability + exact availability evidence remains authoritative; + /// - StartHybridReportMonitorAsync performs another fresh discovery/revalidation before + /// any dynamic DataSet/RCB write and retains the process-lifetime dynamic-write circuit; + /// - the original PlanId is preserved so runtime routing/coverage ownership does not fork. /// - /// The method name is retained temporarily so existing call-sites stay source-compatible; - /// its behavior is deliberately fail-closed and side-effect free. + /// If any gate is not satisfied, bounded MMS polling remains the final fallback. /// - private Task TryStartDynamicRecoveryAfterStaticFailureP4Async( + private async Task TryStartDynamicRecoveryAfterStaticFailureP4Async( ReportControlPlan appPlan, AuthoritativeHybridSubscription authoritative, ArMms.MmsDiscoveryResult discovery, ArMms.MmsRcbAvailabilityResult freshAvailability, string staticFailure, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool staticCleanupProven = false) { - _ = authoritative; - _ = discovery; - _ = freshAvailability; - _ = cancellationToken; + ArgumentNullException.ThrowIfNull(appPlan); + ArgumentNullException.ThrowIfNull(authoritative); + ArgumentNullException.ThrowIfNull(discovery); + ArgumentNullException.ThrowIfNull(freshAvailability); + cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(new NativeReportMonitorStartResult + NativeReportMonitorStartResult Fallback(string reason, string detail) => new() { IsSuccess = false, PlanId = appPlan.PlanId, - Message = $"{staticFailure} P6.1 preserved baseline static-failure isolation: no dynamic DataSet/RCB write was attempted; bounded MMS polling remains active for this affected signal set.", + Message = $"{staticFailure} Smart Auto dynamic recovery withheld: {detail} Bounded MMS polling remains active for this affected signal set.", UsedDynamicDataSet = false, DynamicAttempted = false, DynamicAttemptState = "Skipped", - FailureReason = "StaticActivationFailed", - PollingFallbackReason = "StaticActivationFailed" - }); + FailureReason = reason, + PollingFallbackReason = reason, + Warnings = freshAvailability.Warnings + }; + + if (!IsStaticHybridKind(authoritative.Kind)) + return Fallback("StaticRecoveryNotApplicable", "the failed authoritative segment is not static."); + + // The current StartHybridReportMonitorAsync call sites distinguish pre-write + // revalidation failures from the one post-write activation failure through this + // stable diagnostic prefix. Pre-write failures have nothing to roll back. A real + // activation failure, however, MUST carry explicit CleanupSucceeded evidence before + // this method is allowed to mutate an alternate RCB. Until the caller supplies that + // evidence, fail closed rather than assuming cleanup from a return code/message. + var staticMutationWasAttempted = staticFailure.Contains( + "hybrid report activation failed", + StringComparison.OrdinalIgnoreCase); + if (staticMutationWasAttempted && !staticCleanupProven) + { + return Fallback( + "StaticCleanupUnproven", + "the failed static activation reached the mutation path and rollback/cleanup was not explicitly proven; a second RCB mutation is forbidden on this association."); + } + + if (!_session.IsMmsInitiated) + return Fallback("TransportUnavailable", $"the MMS association is no longer initiated ({_session.State})."); + + if (!authoritative.Options.AllowDynamicBrcb && !authoritative.Options.AllowDynamicUrcb) + return Fallback("DynamicRecoveryDisabled", "dynamic BRCB/URCB acquisition is disabled by the current Smart Auto policy."); + + if (!string.IsNullOrWhiteSpace(appPlan.RelayId) && + DynamicWriteCircuitByDevice.TryGetValue(appPlan.RelayId, out var circuitReason)) + { + return Fallback( + "DynamicWriteCircuitOpen", + $"the device dynamic-write circuit is already open after real field failure evidence ({circuitReason})."); + } + + // Never turn the RCB that just failed static activation into a dynamic target. + // Recovery must use a distinct, freshly classified RCB so a bad/busy/static object + // cannot be immediately mutated under a different acquisition label. + var alternateSnapshots = freshAvailability.ReportControls + .Where(snapshot => !SameLiteralReference(snapshot.Reference, authoritative.ReportControlReference)) + .ToArray(); + if (alternateSnapshots.Length == 0) + { + return Fallback( + "NoAlternateRcbEvidence", + $"no alternate RCB has fresh availability evidence after excluding {authoritative.ReportControlReference}."); + } + + var alternateAvailability = new ArMms.MmsRcbAvailabilityResult + { + CheckedAtUtc = freshAvailability.CheckedAtUtc, + ReportControls = alternateSnapshots, + Warnings = freshAvailability.Warnings + }; + + var recoveryOptions = new ArMms.MmsHybridReportAcquisitionOptions + { + AllowStaticBrcb = false, + AllowStaticUrcb = false, + AllowDynamicBrcb = authoritative.Options.AllowDynamicBrcb, + AllowDynamicUrcb = authoritative.Options.AllowDynamicUrcb, + AllowCallerOwnedReports = false, + AllowPollingFallback = true, + RequireExactAvailabilityEvidence = true + }; + + // Preserve the same InformationReportProven context carried by this PlanId. Without + // it the normal ProductionEligible-only planner would re-quarantine dynamic recovery; + // with it ARIEC still restricts recovery to the exact proven RCB/member envelope. + TryGetGuardedRuntimeContext(appPlan.PlanId, out var guardedRuntimeContext); + var recoveryCapability = BuildCapabilityPlanWithGuardedRuntime( + authoritative.Catalog, + authoritative.Signals, + discovery.ReportInventory, + alternateAvailability, + discovery.IedDirectory, + _session.LastNegotiatedCapabilities, + recoveryOptions, + guardedRuntimeContext); + + var dynamicSegment = recoveryCapability.AcquisitionPlan.Segments.FirstOrDefault(segment => + segment.IsReportBacked && + segment.ReportPlan is not null && + segment.Kind is ArMms.MmsHybridAcquisitionKind.DynamicBrcb or ArMms.MmsHybridAcquisitionKind.DynamicUrcb); + + if (dynamicSegment?.ReportPlan is null) + { + var blocker = recoveryCapability.Blockers.FirstOrDefault(); + var warning = recoveryCapability.Warnings.FirstOrDefault(); + var detail = !string.IsNullOrWhiteSpace(blocker) + ? blocker + : !string.IsNullOrWhiteSpace(warning) + ? warning + : "ARIEC found no exact alternate dynamic report segment for the affected signals."; + return Fallback("NoDynamicRecoverySegment", detail); + } + + // Preserve the runtime plan identity while replacing only its acquisition target. + // Runtime dictionaries, guarded qualification authority, report slice routing and + // PointPlanIds therefore continue to refer to one plan even though Smart Auto + // escalated static -> dynamic. + appPlan.ReportControlReference = dynamicSegment.ReportControlReference; + appPlan.DataSetReference = dynamicSegment.DataSetReference; + appPlan.Mode = $"ARIEC Smart Dynamic • {dynamicSegment.Kind} • static recovery"; + appPlan.AllowDynamicDataSetWrites = true; + appPlan.Buffered = dynamicSegment.Kind == ArMms.MmsHybridAcquisitionKind.DynamicBrcb; + appPlan.Status = $"{dynamicSegment.Kind} recovery planned"; + appPlan.IsEngineAuthoritative = true; + appPlan.EngineAcquisitionKind = dynamicSegment.Kind.ToString(); + + _authoritativeHybridSubscriptions[appPlan.PlanId] = new AuthoritativeHybridSubscription( + dynamicSegment.Kind, + dynamicSegment.ReportControlReference, + authoritative.Catalog, + dynamicSegment.Signals.ToArray(), + recoveryOptions); + + // This recursive entry is safe: the authoritative subscription is now dynamic, so + // any subsequent failure cannot re-enter static recovery. It also gives the dynamic + // target a fresh discovery + exact availability revalidation immediately before write. + return await StartHybridReportMonitorAsync(appPlan, cancellationToken).ConfigureAwait(false); } private static bool IsStaticHybridKind(ArMms.MmsHybridAcquisitionKind kind) diff --git a/Services/NativeIec61850Client.HybridReporting.cs b/Services/NativeIec61850Client.HybridReporting.cs index 99b3b0ba..ccad0983 100644 --- a/Services/NativeIec61850Client.HybridReporting.cs +++ b/Services/NativeIec61850Client.HybridReporting.cs @@ -46,6 +46,7 @@ public async Task BuildHybridReportPlansAsync( cancellationToken.ThrowIfCancellationRequested(); _authoritativeHybridSubscriptions.Clear(); + _guardedRuntimeContexts.Clear(); var planningModel = ResolveHybridPlanningModel(device); if (planningModel is null) @@ -196,17 +197,29 @@ public async Task BuildHybridReportPlansAsync( RequireExactAvailabilityEvidence = true }; - // P3: the protocol engine owns capability interpretation. ARSAS supplies the - // current association evidence and consumes the resulting acquisition plan; it - // does not recreate MMS service-bit, RCB ownership, or writability policy locally. - var capabilityAwarePlan = ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build( + // G2.6 runtime boundary: certification and operation are separate. A valid, + // identity-compatible InformationReportProven data-change profile may authorize + // guarded dynamic monitoring only on its exact proven RCB/member envelope. The + // profile is read-only here; no ProductionEligible state is synthesized or saved. + var guardedRuntime = allowDynamicWrites + ? await TryLoadGuardedRuntimeContextAsync(device, cancellationToken).ConfigureAwait(false) + : new GuardedRuntimeContextLoadResult( + null, + dynamicWriteCircuitOpen + ? $"Dynamic writes are circuit-broken after field failure evidence ({dynamicCircuitReason})." + : "Dynamic DataSet writes are disabled for this device."); + + // P3/G2.6: ARIEC remains the protocol/planning authority. ARSAS supplies the + // current association plus optional exact persisted proof and consumes the result. + var capabilityAwarePlan = BuildCapabilityPlanWithGuardedRuntime( catalog, descriptorPoints.Keys, discovery.ReportInventory, availability, discovery.IedDirectory, _session.LastNegotiatedCapabilities, - plannerOptions); + plannerOptions, + guardedRuntime.Context); var enginePlan = capabilityAwarePlan.AcquisitionPlan; var associationCapability = capabilityAwarePlan.AssociationCapability; var p4AttemptEvidence = ArMms.MmsHybridDynamicAttemptEvidenceBuilder.Build(capabilityAwarePlan, plannerOptions); @@ -250,6 +263,8 @@ public async Task BuildHybridReportPlansAsync( catalog, segment.Signals.ToArray(), plannerOptions); + if (guardedRuntime.Context is not null) + _guardedRuntimeContexts[appPlan.PlanId] = guardedRuntime.Context; reportPlans.Add(appPlan); } @@ -282,6 +297,15 @@ public async Task BuildHybridReportPlansAsync( p6Warnings.Add( $"P6 static inventory bridge mapped {staticInventoryMappedCount} selected point(s) through ARIEC mandatory DataSet member evidence before broad catalog matching."); } + if (guardedRuntime.IsAuthorizedCandidate) + { + p6Warnings.Add( + "G2.6 Smart Dynamic RCB guarded runtime is authorized from identity-compatible InformationReportProven data-change evidence. Only the exact proven RCB/member envelope may be mutated; ProductionEligible certification remains separate."); + } + else if (device.AllowDynamicDataSetWrites && !dynamicWriteCircuitOpen) + { + p6Warnings.Add($"G2.6 Smart Dynamic RCB guarded runtime is not available: {guardedRuntime.Reason}"); + } if (activationPlans.Count > 1 && activationPlans[0].AllowDynamicDataSetWrites && activationPlans.Any(plan => !plan.AllowDynamicDataSetWrites)) { p6Warnings.Add( @@ -309,6 +333,7 @@ public async Task BuildHybridReportPlansAsync( Authority = $"ARIEC61850 capability-aware hybrid acquisition ({catalogAuthority})", Status = enginePlan.Status.ToString(), Summary = $"{enginePlan.Summary} {associationCapability.Summary}" + + (guardedRuntime.IsAuthorizedCandidate ? " Guarded Smart Dynamic runtime=InformationReportProven exact envelope." : string.Empty) + (staticInventoryMappedCount > 0 ? $" Static inventory bridge={staticInventoryMappedCount}." : string.Empty) + (dynamicWriteCircuitOpen ? " Dynamic writes circuit-broken after field failure evidence." : string.Empty), ReportPlans = activationPlans, @@ -440,9 +465,9 @@ ArMms.MmsHybridAcquisitionKind.DynamicBrcb or // Planning is intentionally an intent, not permission to write forever. // Re-read the exact selected RCB immediately before execution, then ask the same - // ARIEC capability-aware planner to classify that fresh association evidence again. - // This is especially important for SCL fast-connect, where the typed catalog may be - // design-sourced but execution authority must always be live-sourced. + // ARIEC planner family to classify that fresh association evidence again. Guarded + // InformationReportProven authority, when present, is carried by PlanId so the + // execution gate cannot silently broaden or lose the exact proven envelope. var callerOwned = _reportMonitorSessions.Values .Select(session => session.ReportControl.Reference) .Where(reference => !string.IsNullOrWhiteSpace(reference)) @@ -488,14 +513,16 @@ ArMms.MmsHybridAcquisitionKind.DynamicBrcb or ReportControls = selectedSnapshots, Warnings = freshAvailability.Warnings }; - var revalidatedCapabilityAwarePlan = ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build( + TryGetGuardedRuntimeContext(plan.PlanId, out var guardedRuntimeContext); + var revalidatedCapabilityAwarePlan = BuildCapabilityPlanWithGuardedRuntime( authoritative.Catalog, authoritative.Signals, discovery.ReportInventory, selectedAvailability, discovery.IedDirectory, _session.LastNegotiatedCapabilities, - authoritative.Options); + authoritative.Options, + guardedRuntimeContext); var revalidatedPlan = revalidatedCapabilityAwarePlan.AcquisitionPlan; var revalidatedSegment = revalidatedPlan.Segments.FirstOrDefault(segment => segment.IsReportBacked && @@ -568,7 +595,14 @@ segment.ReportPlan is not null && { var message = $"ARIEC hybrid report activation failed for {plan.DisplayReference}: {start.Message}"; if (!isDynamic) - return await TryStartDynamicRecoveryAfterStaticFailureP4Async(plan, authoritative, discovery, freshAvailability, message, cancellationToken).ConfigureAwait(false); + return await TryStartDynamicRecoveryAfterStaticFailureP4Async( + plan, + authoritative, + discovery, + freshAvailability, + message, + cancellationToken, + staticCleanupProven: attempt.CleanupSucceeded).ConfigureAwait(false); if (attempt.DynamicAttempted && !string.IsNullOrWhiteSpace(plan.RelayId)) { @@ -620,7 +654,9 @@ segment.ReportPlan is not null && FailureReason = string.Empty, ReportControlReference = plan.ReportControlReference, DataSetReference = plan.DataSetReference, - AcquisitionLabel = $"ARIEC Hybrid: {authoritative.Kind}", + AcquisitionLabel = isDynamic && guardedRuntimeContext is not null + ? $"ARIEC Smart Dynamic: {authoritative.Kind} • InformationReportProven" + : $"ARIEC Hybrid: {authoritative.Kind}", CoveredReferences = coveredReferences, Warnings = attemptWarnings }; diff --git a/docs/G2_6_P1_DETERMINISTIC_A3.md b/docs/G2_6_P1_DETERMINISTIC_A3.md new file mode 100644 index 00000000..c7eb3d0f --- /dev/null +++ b/docs/G2_6_P1_DETERMINISTIC_A3.md @@ -0,0 +1,135 @@ +# G2.6-P1 — Deterministic Q0 Target-Locked Auto A3 dchg Proof + +## Goal + +Close the field A3 proof with one exact, already-proven ARSAS control path and remove the sources of physical-test ambiguity discovered during P1. + +The field-bounded P1 chain is: + +`AA1C1F08R4Q0/CSWI1.Pos one-shot OPEN -> accepted native MMS control -> qualified Q0 MMS status transition -> post-command Dynamic URCB InformationReport(reason=data-change) on the same exact DataSet index -> cleanup` + +This is a commissioning proof only. It does **not** mark the IED `ProductionEligible` and it does **not** enable production automatic dynamic reporting. + +## Entry point + +Select the qualified field IED in ARSAS, then press `Ctrl + Shift + A`. + +For this P1 field build, the hotkey itself is the explicit commissioning action. The older A2.1 read-only/manual command witness remains separately available on `Ctrl + Shift + F`. + +## Exact field lock + +Auto A3 is deliberately bounded to all of the following exact values: + +- stable identity: `ied:AA1C1F08R4`; +- model fingerprint: `sha256:50c691318c6d6a16b68b121ac48627c26e6e32b937836d559dca1b9eb559f0d9`; +- control object: `AA1C1F08R4Q0/CSWI1.Pos`; +- control status: `AA1C1F08R4Q0/CSWI1.Pos.stVal`; +- stimulus: `Open` only; +- interlock check: enabled; +- synchrocheck: disabled; +- test mode: disabled. + +The coordinator never converts current state into a toggle. `Open`, intermediate, unknown, wrong identity, wrong status mapping, busy control, or a non-operational control model all block dispatch. There is no automatic CLOSE, retry, opposite command, or restore command. + +## Preflight and target-scoped recovery + +Before the report path is allowed to mutate an RCB, P1 requires exact identity/fingerprint, exact Q0 control/status mapping, operational readiness, exact `Closed` state, identity-compatible `InformationReportProven`, successful persisted G2.4 activation/report proof, live resolution of the persisted member sequence, Q0 command-focus intersection, and no control command already busy. + +If Q0 is missing from the persisted G2.4 member envelope, P1 reuses transactional recovery on a **private target-scoped clone** of the discovered signal model. Live `SignalDefinition` instances are not modified. Non-Q0 command focus is suppressed only on private clones, identity-significant fields remain unchanged and are revalidated, qualification runs in a staging profile store, G2.4 V2 proves activation + actual InformationReport, G2.4-C proves fresh cleanup, optimistic concurrency prevents overwriting newer evidence, and the live profile is replaced only after all staging gates pass. Recovery itself issues zero control commands and cannot mark `ProductionEligible`. + +## Armed transaction and one-shot control + +The existing `DynamicReportSpontaneousDataChangeCommissioningService` and `DynamicReportCommandBoundDataChangeCommissioningService` remain authoritative for the dchg-only report/witness proof: + +- one exact InformationReport-proven URCB; +- one bounded temporary dynamic DataSet; +- `TrgOps`: dchg only; +- GI/integrity/qchg/dupd disabled; +- `OptFlds`: reason-for-inclusion + DataSet-name; +- exact RptID/DataSet/member/reason validation; +- separate read-only MMS witness association; +- final pre-command exact member baseline; +- exact command-bound high-speed transition sampling; +- exact DataSet-index correlation; +- report monitor cleanup; +- TrgOps/OptFlds restoration; +- fresh-association cleanup closure. + +The core A3 service still does not execute a control command. The Q0 coordinator removes only the operator timing race. At READY it performs one final control inspection and, only if Q0 is still exactly operationally ready and `Closed`, calls the already-existing `Iec61850MonitorRuntime.ExecuteControlAsync(...)` once with `Open`. No new MMS control implementation is introduced. There is no retry, CLOSE, toggle/opposite command, or automatic restore. + +## Physical field acceptance — PASS, 2026-08-24 + +The physical acceptance run was executed on implementation head `4eedc1449b15ddc24f048040805cef4e508a6dd9` and produced the following operator-captured evidence: + +- exact command: `AA1C1F08R4Q0/CSWI1.Pos -> Open`; +- command intent observed at `2026-08-24T10:21:37.0572634+00:00`; +- `AA1C1F08R4Q0/XCBR1.Pos.stVal` transitioned `bits(80) -> bits(40)` about `470.638 ms` after command; +- `AA1C1F08R4Q0/CSWI1.Pos.stVal` transitioned `bits(80) -> bits(40)` about `491.29 ms` after command; +- spontaneous InformationReport was proven with `reason=data-change`; +- report included exact DataSet indexes `[0,1]`; +- command-bound changed indexes were `[0,1]`; +- correlated indexes were `[0,1]`; +- report monitor cleanup passed; +- TrgOps/OptFlds restoration passed; +- fresh-association cleanup closure passed; +- report association remained healthy. + +That run closed the original P1 physical command -> qualified transition -> dchg InformationReport -> same exact DataSet index -> cleanup contract. + +## Final fail-closed correlation hardening before merge + +Before merge, two additional false-positive paths were closed in the final code candidate beginning at `d9a8f83b06b025b954c486ad467581df2347a387`. + +### Native control acceptance is mandatory + +`Control execution requested:` is command intent only. It cannot independently satisfy PASS because it is emitted before native control execution completes. + +P1 now also requires a later successful native-control diagnostic from the **same existing runtime control path**, for the same exact object and requested value, with MMS response/wire evidence. `NotSent`, rejected, no-response, and otherwise unproven native control fail closed. No second SBO/SBOw/Operate implementation was introduced. + +### Report reception must follow the command + +The selected valid dchg frame preserves `MmsReportFrame.ReceivedAt` as `ReportReceivedAtUtc`. P1 now requires: + +`ReportReceivedAtUtc > CommandObservedAtUtc` + +before command/report correlation may pass. A valid unrelated dchg frame received before the command can no longer be combined with a later same-index MMS transition to create a false PASS. + +These changes only make acceptance stricter; they do not broaden command authority or production eligibility. The original physical run predates these extra software gates, so it is recorded as physical acceptance of the original P1 contract, not falsely described as a physical rerun of the final hardening head. + +## Final PASS contract + +A final-head A3 PASS requires all of the following in the same bounded armed window: + +1. exact identity-compatible `InformationReportProven` profile; +2. exact Q0 command-focus intersection with the persisted G2.4 member sequence; +3. exact dchg-only URCB activation with no GI; +4. exact ARSAS Q0 command intent after the final read-only baseline; +5. successful native MMS control result/wire evidence for that exact request; +6. post-command transition on a qualified command-focus member; +7. valid spontaneous `reason=data-change` InformationReport; +8. selected report receive time strictly after the captured command time; +9. at least one same exact DataSet index between command-bound transition and report; +10. report monitor cleanup PASS; +11. TrgOps/OptFlds restore PASS; +12. fresh-association cleanup closure PASS. + +## Final CI validation + +The code hardening candidate `d9a8f83b06b025b954c486ad467581df2347a387` completed: + +- Build ARSAS #1422: PASS; +- full solution build: PASS, 0 errors; +- ARSAS regression suite: **583/583 PASS**, 0 failed, 0 skipped; +- portable single EXE publish + smoke test: PASS; +- Windows installer #372: PASS; +- IO List validation #365: PASS; +- SV evidence validation #534: PASS; +- immutable ARIEC61850 engine: `main` @ `aa2ddfb47af5f3b806858553568792fbc21a64f1`. + +Commits after that candidate only freeze this documentation acceptance record; they do not change the P1 runtime safety contract. + +## Production boundary and next phase + +P1 success remains intentionally weaker than production eligibility. The persisted state remains `InformationReportProven`; `ProductionEligible` and production automatic dynamic reporting remain OFF. + +P1 is stacked onto `g2.6-smart-dynamic-rcb`, so its merge target is that Smart Dynamic branch rather than `main`. After P1 merge, the next engineering gate is Smart Dynamic shadow verification: dynamic reporting operates under controlled observation while MMS polling remains the reconciliation/reference path. Only later shadow/regression acceptance may authorize a production eligibility transition. \ No newline at end of file diff --git a/docs/G2_6_PHYSICAL_SHADOW_VERIFICATION.md b/docs/G2_6_PHYSICAL_SHADOW_VERIFICATION.md new file mode 100644 index 00000000..f191f1e2 --- /dev/null +++ b/docs/G2_6_PHYSICAL_SHADOW_VERIFICATION.md @@ -0,0 +1,127 @@ +# G2.6 Physical Shadow Verification + +## Purpose + +This phase sits after the deterministic command-bound A3 proof and before any later `ProductionEligible` decision. + +It answers one question only: + +> Can the exact `InformationReportProven` dynamic report path remain trustworthy when compared continuously against an independent read-only MMS reference, including across a deliberate reconnect? + +A shadow PASS is **not** a production promotion. + +## Operator entry point + +Select the qualified IED and press: + +`Ctrl + Shift + S` + +The action is explicit commissioning only. It is not executed automatically by normal monitoring. + +## Physical topology + +Each phase uses two independent MMS associations: + +1. **Report association** + - exact persisted URCB only; + - exact persisted G2.4 member sequence only; + - transactional `TrgOps=dchg` lease; + - `OptFlds=reason-for-inclusion data-set-name`; + - GI/integrity/qchg/dupd remain disabled; + - dynamic DataSet is temporary and cleaned on stop. +2. **Reference association** + - read-only direct MMS reads; + - exact same proven member sequence; + - exact live `q` and `t` companion objects are read independently when they exist and resolve under the same functional constraint; + - at most one `q` read and one `t` read are attempted per successful primary-value observation; + - companion values are decoded with the ARIEC IEC 61850 quality/timestamp decoders; + - no RCB/DataSet access or mutation; + - bounded 250 ms polling while the report phase is armed. + +## Two-phase reconnect contract + +The collector performs: + +1. phase 1 report + polling proof; +2. complete monitor/proof-field/fresh-association cleanup; +3. deliberate teardown/reconnect; +4. phase 2 report re-subscription + independent polling-reference recovery; +5. complete cleanup again; +6. typed ARIEC shadow evaluation. + +The READY markers are: + +- `G2.6 SHADOW PHASE 1 READY — CAUSE ONE SAFE CHANGE` +- `G2.6 SHADOW PHASE 2 READY — CAUSE ONE SAFE CHANGE` + +After each marker, cause exactly one already-approved safe process/status change affecting the proven envelope. The shadow collector itself issues **zero** control commands. + +## Evidence rules + +Every report observation is bound to the exact DataSet index/member pair already persisted in `RcbActivationProof.MemberReferences`. + +Every polling observation is independently read through the second MMS association and is recorded against that same exact index/member pair. + +The collector records: + +- report values and receive ordering; +- independent polling values; +- exact report sequence number when supplied; +- report-carried quality/timestamp only when ARIEC physically projects those fields from the received InformationReport; +- polling quality/timestamp only when exact live companion objects can be resolved, read and decoded on the isolated polling association; +- reconnect attempts and successes; +- report re-subscription after reconnect; +- polling-reference recovery after reconnect; +- bounded dynamic activation-attempt count; +- monitor cleanup, proof-field restore and fresh-association closure. + +## Quality / timestamp boundary + +The currently proven field envelope may contain scalar primary members such as `CSWI1.Pos.stVal`. + +For the independent polling authority, ARSAS derives only bounded known IEC data-object sibling paths. For example: + +`AA1C1F08R4Q0/CSWI1.Pos.stVal` + +maps to the independently discovered/read companions: + +- `AA1C1F08R4Q0/CSWI1.Pos.q` +- `AA1C1F08R4Q0/CSWI1.Pos.t` + +These companions are accepted only when the live MMS directory resolves the exact reference under the same functional constraint. A read failure, missing object or decoder failure remains missing evidence. + +A scalar report member still does not automatically prove that its data-object quality and timestamp were transported in the same InformationReport. Therefore this phase deliberately does **not**: + +- copy polling quality into a report observation; +- copy polling timestamps into a report observation; +- copy report quality/timestamp into the polling observation; +- treat polling host read time as the IEC data-object timestamp; +- treat report receive time as the IEC data-object timestamp; +- treat report header `TimeOfEntry` as the member's device timestamp; +- invent missing q/t when either independent side does not physically supply them. + +ARSAS pins ARIEC61850 PR #99 (`1efad9a2cdb6b4452b13687bbcd8c7ec41a9e53f`). The strict production-facing acceptance policy requires actually observed paired report/poll quality evidence and actually observed paired report/poll device timestamp evidence. Independent polling companion reads close the polling-side evidence gap, but they do not weaken the report-side requirement: if the physical InformationReport does not transport q/t, the strict gate remains fail-closed. That result is useful field evidence, not a software failure to be bypassed. + +## Acceptance layers + +The collector separates three outcomes: + +1. **Physical collection completed** — both phases, cleanup and reconnect finished. +2. **Typed shadow passed** — ARIEC exact identity/value/q/t/order/missing/duplicate/reconnect/mutation-loop checks all passed. +3. **Production-acceptance candidate** — additionally includes independent Smart Control and static-reporting regression inputs. + +`Ctrl + Shift + S` passes those independent control/static inputs as `false`; it never assumes unrelated regressions passed. A later explicit gate must supply reviewed evidence if those regressions are to become true. + +## State invariant + +This phase never calls `DynamicReportQualificationProfileStore.SaveAsync` and never calls `MarkProductionEligible`. + +The persisted profile remains: + +`InformationReportProven` + +and production automatic dynamic reporting remains: + +`OFF` + +until a later, separately reviewed promotion gate is implemented and physically justified. diff --git a/docs/G2_6_SHADOW_ACCEPTANCE.md b/docs/G2_6_SHADOW_ACCEPTANCE.md new file mode 100644 index 00000000..85fbd8b3 --- /dev/null +++ b/docs/G2_6_SHADOW_ACCEPTANCE.md @@ -0,0 +1,78 @@ +# G2.6 — ARSAS Shadow Acceptance Boundary + +## Current state + +The deterministic Q0 A3 commissioning proof is complete, but the field profile remains `InformationReportProven`. Production automatic dynamic reporting is intentionally OFF. + +ARSAS now pins ARIEC61850 PR #98 on `main`, which adds a pure typed report-vs-independent-MMS shadow evaluator. The application-side acceptance service is: + +`DynamicReportShadowVerificationAcceptanceService` + +This service is deliberately downstream of physical evidence collection. It performs no MMS I/O, no RCB/DataSet write, no profile save, and no `MarkProductionEligible` call. + +## Required evidence + +The physical collector must supply one `MmsDynamicReportShadowVerificationEvidence` bound to the exact persisted InformationReport-proven member sequence. + +ARSAS production-shadow options currently require: + +- at least 2 accepted report observations; +- exact DataSet index/member identity; +- report-to-poll value parity within 3 seconds; +- polling-observed transition to report-edge correlation within 3 seconds; +- report and polling quality evidence on both sides; +- report and polling device timestamp evidence on both sides; +- device timestamp delta <= 250 ms; +- one deliberate reconnect cycle; +- report subscription recovery after reconnect; +- independent polling-reference recovery after reconnect; +- maximum one dynamic activation attempt per association; +- no missing report edge; +- no duplicate report edge; +- no repeated RCB/DataSet mutation loop. + +These values are commissioning acceptance thresholds, not a production runtime retry policy. + +## Exact envelope gate + +Before evaluating any shadow evidence ARSAS reloads the current identity-bound profile and requires: + +1. state exactly `InformationReportProven`; +2. successful stored RCB activation proof; +3. successful stored InformationReport proof; +4. non-empty exact qualified member sequence; +5. shadow evidence member sequence exactly equal to the persisted sequence after only `$`/`.` reference normalization. + +No alternate RCB/member sequence is accepted by this gate. + +## Acceptance candidate + +A successful typed shadow is converted through ARIEC's existing `MmsDynamicReportProductionAcceptance` contract. Smart Control and static-reporting regression decisions remain independent explicit inputs and are not inferred from shadow traffic. + +Therefore there are three distinct outcomes: + +- **Shadow FAIL** — keep `InformationReportProven`; ProductionEligible OFF. +- **Shadow PASS, control/static regression incomplete** — keep `InformationReportProven`; ProductionEligible OFF. +- **Shadow PASS + complete acceptance candidate** — still keep `InformationReportProven`; a separate reviewed promotion action is required later. + +`Shadow PASS != ProductionEligible` is an invariant. + +## Next implementation step: physical collector + +The collector must be built on already-proven ARIEC/ARSAS paths rather than introducing an ad-hoc RCB/control implementation. + +Preferred structure: + +1. load exact `InformationReportProven` target; +2. reuse the established one-URCB transactional dchg commissioning setup/cleanup for the exact persisted RCB/member envelope; +3. receive exact mapped report observations; +4. use a second isolated read-only MMS association to poll the same exact members; +5. capture real quality and device timestamp evidence only when both sides supply trustworthy values — never synthesize missing q/t; +6. perform one deliberate report/reference reconnect cycle; +7. prove report re-arm + reference re-open; +8. bound dynamic activation to one attempt per association; +9. always execute monitor/RCB/DataSet/proof-field cleanup; +10. pass the resulting typed evidence to `DynamicReportShadowVerificationAcceptanceService`; +11. do not persist or promote profile state automatically. + +No automatic OPEN/CLOSE/toggle stimulus should be added to the shadow collector merely to create traffic. Normal process changes or separately reviewed commissioning stimulus remain outside this acceptance service. diff --git a/docs/G2_6_SMART_DYNAMIC_RUNTIME.md b/docs/G2_6_SMART_DYNAMIC_RUNTIME.md new file mode 100644 index 00000000..53349334 --- /dev/null +++ b/docs/G2_6_SMART_DYNAMIC_RUNTIME.md @@ -0,0 +1,79 @@ +# G2.6 Smart Dynamic RCB Runtime + +## Goal + +Smart Dynamic RCB is a normal monitoring acquisition path. It is not a commissioning ceremony. + +After an IED has an identity-compatible `InformationReportProven` profile with a successful data-change InformationReport, ARSAS may use the exact already-proven dynamic RCB/member envelope during ordinary monitoring without requiring `ProductionEligible` certification. + +`ProductionEligible` remains a separate certification boundary and is never synthesized or persisted by this runtime path. + +## Operator workflow + +There is no G2.6 commissioning hotkey in the normal runtime workflow. + +1. Connect the qualified IED normally. +2. Select the required proven signals. +3. Start Monitor. + +ARSAS then performs the acquisition decision automatically. + +## Runtime order + +The ARIEC planner remains authoritative: + +`configured static RCB -> guarded exact proven dynamic RCB -> MMS polling residual/fallback` + +Static DataSet-backed reporting keeps normal coverage precedence. For residual points that are inside the exact proven InformationReport envelope, guarded dynamic reporting may use only: + +- the exact RCB stored in the successful activation + InformationReport evidence; +- the exact ordered InformationReport-proven member envelope; +- at most one dynamic RCB group. + +Anything outside that envelope remains on MMS polling. + +## Guarded dynamic authorization + +Before dynamic planning ARSAS loads the persisted qualification profile using the current stable IED identity/model fingerprint. ARIEC revalidates: + +- current association dynamic-report capability; +- profile schema and identity compatibility; +- state `InformationReportProven` or stronger; +- successful RCB activation evidence; +- successful actual `DataChange` InformationReport evidence; +- exact RCB/DataSet identity consistency; +- exact ordered member consistency with the accepted envelope. + +No alternate free RCB may substitute for the proven RCB. + +## Fresh execution gate + +Planning does not grant indefinite write permission. Immediately before activation ARSAS performs fresh report discovery and fresh RCB availability checks, then runs the same guarded ARIEC planner again with the PlanId-bound qualification context. + +If the exact dynamic segment cannot be reproduced, no dynamic write occurs and MMS polling remains active. + +## Runtime report + MMS validation + +When the dynamic RCB activates successfully, InformationReport traffic drives the live process values. The existing ARSAS runtime continues MMS verification/reconciliation. If MMS detects a process-value change that was not delivered by the armed report, the point is degraded to MMS fallback until report delivery is verified again. + +This is intentionally simpler than the physical shadow collector: report quality/timestamp certification is not a prerequisite for guarded runtime operation when the actual proven DataSet carries scalar process values such as `stVal`. + +## Failure handling + +A real dynamic activation failure opens the existing per-device, process-lifetime dynamic-write circuit breaker. ARSAS does not repeatedly mutate the RCB. Static reporting remains eligible and affected residual points use bounded MMS polling. + +Static-to-dynamic recovery also preserves the original PlanId-bound guarded context. Recovery therefore cannot select an arbitrary alternate dynamic RCB; it is still restricted to the exact InformationReport-proven RCB/member envelope and requires proven cleanup if a failed static activation already mutated RCB state. + +## State boundary + +This runtime path performs no qualification profile save and never calls `MarkProductionEligible`. + +The persisted profile may remain: + +`InformationReportProven` + +while guarded Smart Dynamic RCB is used for normal monitoring. + +This means: + +`Smart Dynamic runtime authorized != ProductionEligible certification` diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 268797b4..13723ef7 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "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." + "commit": "c899b05f18ba2bd4c82ebff6879e4748036e0d90", + "sourcePullRequest": 100, + "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. PR #97 closes the engine-side production-consumer gap without weakening quarantine: automatic dynamic planning remains blocked unless an identity-compatible ProductionEligible profile is supplied, and even then only exact InformationReport-proven RCB/member evidence may be consumed while unproven residuals remain on polling. PR #98, merged on main at ffd33bc44f7b8650e7de0e62e87568b1eca6b5fa, adds the pure typed G2.6 report-vs-independent-MMS shadow evaluator: exact DataSet index/member identity, value/quality/timestamp parity, report ordering, missing/duplicate edge detection, reconnect recovery, polling authority, and bounded dynamic-activation loop evidence. It can build the existing production-acceptance contract only from a successful shadow and never mutates a profile. PR #99, merged on main at 1efad9a2cdb6b4452b13687bbcd8c7ec41a9e53f, hardens the production-facing bridge so QualityRegressionPassed additionally requires actually observed paired report/poll quality evidence and actually observed paired report/poll device timestamp evidence; absence of q/t evidence cannot become a production PASS. PR #100, merged on main at c899b05f18ba2bd4c82ebff6879e4748036e0d90, adds a separate guarded runtime planner: an identity-compatible InformationReportProven data-change profile may drive at most one exact proven dynamic RCB/member envelope while static reporting remains eligible and all unproven points remain on polling. This guarded runtime does not call MarkProductionEligible, does not synthesize production acceptance, and keeps ProductionEligible as a separate certification boundary." } diff --git a/tests/ARSAS.Tests/EventDrivenSessionLiveMonitorRegressionTests.cs b/tests/ARSAS.Tests/EventDrivenSessionLiveMonitorRegressionTests.cs index 081a5b57..a52fe71b 100644 --- a/tests/ARSAS.Tests/EventDrivenSessionLiveMonitorRegressionTests.cs +++ b/tests/ARSAS.Tests/EventDrivenSessionLiveMonitorRegressionTests.cs @@ -39,9 +39,10 @@ public void LiveMonitor_HasFullWidthGlobalSearch_AndNoDuplicateSummaryBadge() } [Fact] - public void SclMonitoring_UsesAriecHybridStaticAndCircuitBrokenDynamicReports_BeforeResidualPolling() + public void SclMonitoring_UsesAriecHybridStaticAndGuardedDynamicReports_BeforeResidualPolling() { var bridge = File.ReadAllText(FindRepoFile(Path.Combine("Services", "NativeIec61850Client.HybridReporting.cs"))); + var guarded = File.ReadAllText(FindRepoFile(Path.Combine("Services", "NativeIec61850Client.HybridReporting.GuardedRuntime.cs"))); var models = File.ReadAllText(FindRepoFile(Path.Combine("Models", "MonitorModels.cs"))); Assert.Contains("AllowStaticBrcb = true", bridge, StringComparison.Ordinal); @@ -50,7 +51,10 @@ public void SclMonitoring_UsesAriecHybridStaticAndCircuitBrokenDynamicReports_Be Assert.Contains("AllowDynamicBrcb = allowDynamicWrites", bridge, StringComparison.Ordinal); Assert.Contains("AllowDynamicUrcb = allowDynamicWrites", bridge, StringComparison.Ordinal); Assert.Contains("DynamicWriteCircuitByDevice", bridge, StringComparison.Ordinal); - Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", bridge, StringComparison.Ordinal); + Assert.Contains("TryLoadGuardedRuntimeContextAsync(device", bridge, StringComparison.Ordinal); + Assert.Contains("BuildCapabilityPlanWithGuardedRuntime", bridge, StringComparison.Ordinal); + Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.Contains("MmsHybridDynamicAttemptEvidenceBuilder.Build", bridge, StringComparison.Ordinal); Assert.Contains("_session.LastNegotiatedCapabilities", bridge, StringComparison.Ordinal); Assert.Contains("StartPersistentReportMonitorWithAttemptEvidenceAsync", bridge, StringComparison.Ordinal); @@ -79,4 +83,4 @@ private static string FindRepoFile(string relativePath) } throw new FileNotFoundException(relativePath); } -} \ No newline at end of file +} diff --git a/tests/ARSAS.Tests/FieldRegressionFixTests.cs b/tests/ARSAS.Tests/FieldRegressionFixTests.cs index f15e789e..2d4eded0 100644 --- a/tests/ARSAS.Tests/FieldRegressionFixTests.cs +++ b/tests/ARSAS.Tests/FieldRegressionFixTests.cs @@ -49,6 +49,7 @@ public void TopBar_ParentContainersCannotClipResponsiveNavigation() public void SclFastConnect_UsesTypedDesignModelForHybridCatalog_ButKeepsFreshLiveRcbValidation() { var source = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.HybridReporting.cs")); + var guarded = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs")); Assert.Contains( "device?.LiveDiscoveryModel ?? device?.SclWorkspace?.DesignModel", @@ -60,7 +61,9 @@ public void SclFastConnect_UsesTypedDesignModelForHybridCatalog_ButKeepsFreshLiv Assert.Contains("CheckReportControlAvailabilityAsync", source, StringComparison.Ordinal); Assert.Contains("RequireExactAvailabilityEvidence = true", source, StringComparison.Ordinal); Assert.Contains("fresh capability-aware engine evidence", source, StringComparison.Ordinal); - Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", source, StringComparison.Ordinal); + Assert.Contains("BuildCapabilityPlanWithGuardedRuntime", source, StringComparison.Ordinal); + Assert.Contains("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", guarded, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportRuntimePlanner.Build", guarded, StringComparison.Ordinal); Assert.DoesNotContain( "CanUseHybridReportPlanner(Iec61850MonitorDevice device)\n => device?.LiveDiscoveryModel is not null", source, diff --git a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs index f30b60e4..7cde5d97 100644 --- a/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs +++ b/tests/ARSAS.Tests/G1ControlCorrectnessRegressionTests.cs @@ -5,18 +5,18 @@ namespace ARSAS.Tests; public sealed class G1ControlCorrectnessRegressionTests { [Fact] - public void EngineLock_PinsReviewedG24P1EngineAndPreservesExactG1FieldProvenAncestry() + public void EngineLock_PinsGuardedRuntimeEngineAndPreservesExactG1FieldProvenAncestry() { 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("26c85400a4da230c4429e6302847f230385b6687", json.GetProperty("commit").GetString()); - Assert.Equal(95, json.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal("c899b05f18ba2bd4c82ebff6879e4748036e0d90", json.GetProperty("commit").GetString()); + Assert.Equal(100, 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 + // G2.6 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); @@ -37,7 +37,24 @@ public void EngineLock_PinsReviewedG24P1EngineAndPreservesExactG1FieldProvenAnce 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); + + // PR #97 adds the ProductionEligible consumer, PR #98/#99 preserve strict + // certification evidence, and PR #100 adds a separate guarded runtime boundary. + Assert.Contains("PR #97", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductionEligible profile", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exact InformationReport-proven RCB/member evidence", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #98", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("report-vs-independent-MMS shadow evaluator", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("never mutates a profile", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #99", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("actually observed paired report/poll quality evidence", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("actually observed paired report/poll device timestamp evidence", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("absence of q/t evidence cannot become a production PASS", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #100", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("identity-compatible InformationReportProven", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("at most one exact proven dynamic RCB/member envelope", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("does not call MarkProductionEligible", purpose, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductionEligible as a separate certification boundary", purpose, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -123,11 +140,14 @@ public void G11_FieldRejection_IsExplicitAndManualOriginIsStationControl() } [Fact] - public void G1_DoesNotReenableDynamicReportingOrChangeReconnectPolicy() + public void G1_ControlPathDoesNotOwnGuardedDynamicRuntimeOrChangeReconnectPolicy() { var engineLock = File.ReadAllText(Path.Combine(RepoRoot(), "engines", "ARIEC61850.lock.json")); Assert.Contains("PR #89 quarantines automatic full dynamic DataSet activation", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #100", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductionEligible as a separate certification boundary", engineLock, StringComparison.OrdinalIgnoreCase); + // G1 control remains independent from the G2.6 report acquisition bridge. var runtime = File.ReadAllText(Path.Combine(RepoRoot(), "Services", "Iec61850MonitorRuntime.cs")); Assert.Contains("SmartReconnectPolicy", runtime, StringComparison.Ordinal); Assert.DoesNotContain("AllowDynamicBrcb = true", runtime, StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/G26P1CommandFocusRequalificationRegressionTests.cs b/tests/ARSAS.Tests/G26P1CommandFocusRequalificationRegressionTests.cs new file mode 100644 index 00000000..28a2667a --- /dev/null +++ b/tests/ARSAS.Tests/G26P1CommandFocusRequalificationRegressionTests.cs @@ -0,0 +1,106 @@ +namespace ARSAS.Tests; + +public sealed class G26P1CommandFocusRequalificationRegressionTests +{ + [Fact] + public void Recovery_StagesAwayFromLiveProfile_AndCommitsOnlyAfterFreshCleanupClosure() + { + var source = Read("Services/DynamicReportCommandFocusRequalificationCommissioningService.cs"); + + var stagingRoot = source.IndexOf("g26-p1-command-focus-", StringComparison.Ordinal); + var stagingStore = source.IndexOf("new DynamicReportQualificationProfileStore(stagingRoot)", StringComparison.Ordinal); + var activation = source.IndexOf("new DynamicReportActivationCommissioningServiceV2(stagingStore)", StringComparison.Ordinal); + var closure = source.IndexOf("new DynamicReportCleanupClosureCommissioningService(stagingStore)", StringComparison.Ordinal); + var closureGate = source.IndexOf("if (!closure.IsSuccess)", StringComparison.Ordinal); + var concurrencyGate = source.IndexOf("SameProfileEvidence(originalProfile, currentLoad.Profile)", StringComparison.Ordinal); + var liveSave = source.IndexOf("await _liveProfileStore.SaveAsync(finalProfile", StringComparison.Ordinal); + + Assert.True(stagingRoot >= 0); + Assert.True(stagingStore > stagingRoot); + Assert.True(activation > stagingStore); + Assert.True(closure > activation); + Assert.True(closureGate > closure); + Assert.True(concurrencyGate > closureGate); + Assert.True(liveSave > concurrencyGate); + Assert.Contains("atomic replacement", source, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Recovery_NeverIssuesControl_AndCannotProduceProductionEligible() + { + var source = Read("Services/DynamicReportCommandFocusRequalificationCommissioningService.cs"); + + Assert.DoesNotContain("ExecuteControlAsync(", source, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible", source, StringComparison.Ordinal); + Assert.Contains("profile.State == ArMms.MmsDynamicReportQualificationState.ProductionEligible", source, StringComparison.Ordinal); + Assert.Contains("forbidden in P1 recovery", source, StringComparison.Ordinal); + Assert.Contains("ZERO control execution", source, StringComparison.Ordinal); + } + + [Fact] + public void Recovery_RequiresExactCommandStatusMember_ToSurviveQualificationAndG24() + { + var source = Read("Services/DynamicReportCommandFocusRequalificationCommissioningService.cs"); + + Assert.Contains("ResolveCommandStatusPoints", source, StringComparison.Ordinal); + Assert.Contains("commandStatusReferences", source, StringComparison.Ordinal); + Assert.Contains("acceptedStatuses.Length == 0", source, StringComparison.Ordinal); + Assert.Contains("FinalProfileMatchesCommandFocus", source, StringComparison.Ordinal); + Assert.Contains("final G2.4 exact member sequence has no retained command-status member", source, StringComparison.Ordinal); + Assert.Contains("MaximumCommandFocusMembers = DynamicReportActivationCommissioningService.MaximumG24Members", source, StringComparison.Ordinal); + } + + [Fact] + public void Recovery_UsesExistingG23QualificationPrimitive_AndExistingG24PhysicalProof() + { + var source = Read("Services/DynamicReportCommandFocusRequalificationCommissioningService.cs"); + + Assert.Contains("RunDynamicDataSetQualificationCommissioningAsync", source, StringComparison.Ordinal); + Assert.Contains("MmsDynamicDataSetQualificationExecutionMode.ExplicitCommissioning", source, StringComparison.Ordinal); + Assert.Contains("AcceptExactEnvelope", source, StringComparison.Ordinal); + Assert.Contains("CreateEnvelopeQualifiedProfile", source, StringComparison.Ordinal); + Assert.Contains("DynamicReportActivationCommissioningServiceV2", source, StringComparison.Ordinal); + Assert.Contains("DynamicReportCleanupClosureCommissioningService", source, StringComparison.Ordinal); + } + + [Fact] + public void Q0AutoCoordinator_AssessesThenRecoversThenReassessesBeforeA3Arm() + { + var auto = Read("Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs"); + var ui = Read("DynamicReportCommandBoundWitnessUiBehavior.cs"); + + var assess = auto.IndexOf("var assessment = await recovery.AssessAsync", StringComparison.Ordinal); + var requiresRecovery = auto.IndexOf("if (assessment.RequiresRequalification)", StringComparison.Ordinal); + var run = auto.IndexOf("var recoveryResult = await recovery.RunAsync", StringComparison.Ordinal); + var post = auto.IndexOf("var postRecovery = await recovery.AssessAsync", StringComparison.Ordinal); + var preArm = auto.IndexOf("post-recovery pre-arm", StringComparison.Ordinal); + var a3 = auto.IndexOf("new DynamicReportCommandBoundDataChangeCommissioningService", StringComparison.Ordinal); + + Assert.True(assess >= 0); + Assert.True(requiresRecovery > assess); + Assert.True(run > requiresRecovery); + Assert.True(post > run); + Assert.True(preArm > post); + Assert.True(a3 > preArm); + Assert.Contains("ZERO control commands are permitted during recovery", auto, StringComparison.Ordinal); + Assert.Contains("The previous live profile remains authoritative and no control command was sent", auto, StringComparison.Ordinal); + Assert.Contains("DynamicReportQ0TargetLockedAutoA3CommissioningService", ui, StringComparison.Ordinal); + Assert.DoesNotContain("Run transactional command-focus recovery now?", ui, StringComparison.Ordinal); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} diff --git a/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs new file mode 100644 index 00000000..61d1bc34 --- /dev/null +++ b/tests/ARSAS.Tests/G26P1DeterministicA3RegressionTests.cs @@ -0,0 +1,216 @@ +namespace ARSAS.Tests; + +public sealed class G26P1DeterministicA3RegressionTests +{ + [Fact] + public void A3_CoreStillObservesRuntimeCommand_AndNeverExecutesControlItself() + { + var source = Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"); + + Assert.Contains("runtime.Diagnostic += RuntimeDiagnosticHandler", source, StringComparison.Ordinal); + Assert.Contains("DynamicReportCommandBoundStimulusWitnessServiceV3.TryBuildRuntimeIntent", source, StringComparison.Ordinal); + Assert.Contains("Control execution requested:", Read("Services/DynamicReportCommandBoundStimulusWitnessServiceV3.cs"), StringComparison.Ordinal); + Assert.DoesNotContain("ExecuteControlAsync(", source, StringComparison.Ordinal); + Assert.DoesNotContain("WriteControl", source, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void A3_PreflightRequiresQualifiedCommandFocusIntersection_BeforeCoreReportMutation() + { + var source = Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"); + + var targetGate = source.IndexOf("BuildEligibleCommandTargets(", StringComparison.Ordinal); + var noTargetBlock = source.IndexOf("if (eligibleTargets.Count == 0)", StringComparison.Ordinal); + var coreStart = source.IndexOf("new DynamicReportSpontaneousDataChangeCommissioningService", StringComparison.Ordinal); + + Assert.True(targetGate >= 0); + Assert.True(noTargetBlock > targetGate); + Assert.True(coreStart > noTargetBlock); + Assert.Contains("No RCB mutation was attempted", source, StringComparison.Ordinal); + Assert.Contains("Re-qualify an envelope containing CSWI/XCBR status before A3", source, StringComparison.Ordinal); + } + + [Fact] + public void A3_PassRequiresSameDataSetIndexForCommandTransitionAndDchgReport() + { + var source = Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"); + + Assert.Contains("CorrelateIndexes(coreResult.IncludedIndexes, changedIndexes)", source, StringComparison.Ordinal); + Assert.Contains("coreResult.SpontaneousDataChangeProven &&", source, StringComparison.Ordinal); + Assert.Contains("witnessResult.CommandCaptured &&", source, StringComparison.Ordinal); + Assert.Contains("witnessResult.CommandBoundTransitionProven &&", source, StringComparison.Ordinal); + Assert.Contains("correlatedIndexes.Length > 0", source, StringComparison.Ordinal); + Assert.Contains("var success = coreResult.IsSuccess && correlation", source, StringComparison.Ordinal); + } + + [Fact] + public void A3_PassRequiresSuccessfulNativeControlEvidence_AndReportStrictlyAfterCommand() + { + var wrapper = Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"); + var core = Read("Services/DynamicReportSpontaneousDataChangeCommissioningService.cs"); + + Assert.Contains("IsAcceptedNativeControlResultDiagnostic", wrapper, StringComparison.Ordinal); + Assert.Contains("nativeCommandAcceptance", wrapper, StringComparison.Ordinal); + Assert.Contains("nativeControlAccepted &&", wrapper, StringComparison.Ordinal); + Assert.Contains("Request intent alone cannot prove command acceptance", wrapper, StringComparison.Ordinal); + Assert.Contains("coreResult.ReportReceivedAtUtc.Value > witnessResult.CommandObservedAtUtc.Value", wrapper, StringComparison.Ordinal); + Assert.Contains("reportAfterCommand &&", wrapper, StringComparison.Ordinal); + Assert.Contains("Pre-command report traffic cannot satisfy command-bound A3", wrapper, StringComparison.Ordinal); + Assert.Contains("public DateTimeOffset? ReportReceivedAtUtc", core, StringComparison.Ordinal); + Assert.Contains("receivedAt={frame.ReceivedAt:O}", core, StringComparison.Ordinal); + Assert.Contains("reportReceivedAtUtc = frame.ReceivedAt", core, StringComparison.Ordinal); + } + + [Fact] + public void A3_ReusesStrictDchgOnlyCoreAndMandatoryCleanup() + { + var wrapper = Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"); + var core = Read("Services/DynamicReportSpontaneousDataChangeCommissioningService.cs"); + + Assert.Contains("DynamicReportSpontaneousDataChangeCommissioningService", wrapper, StringComparison.Ordinal); + Assert.Contains("internal const string TemporaryTriggerOptions = \"dchg\"", core, StringComparison.Ordinal); + Assert.Contains("internal const string TemporaryOptionalFields = \"reason-for-inclusion data-set-name\"", core, StringComparison.Ordinal); + Assert.Contains("GI=false, integrity=false, qchg=false, dupd=false", core, StringComparison.Ordinal); + Assert.Contains("triggerGeneralInterrogation: false", core, StringComparison.Ordinal); + Assert.Contains("carries a non-dchg reason under a dchg-only lease", core, StringComparison.Ordinal); + Assert.Contains("MonitorCleanupSucceeded", core, StringComparison.Ordinal); + Assert.Contains("ProofFieldRestoreSucceeded", core, StringComparison.Ordinal); + Assert.Contains("FreshCleanupClosureSucceeded", core, StringComparison.Ordinal); + } + + [Fact] + public void A3_CannotAdvanceProductionEligibility() + { + var source = Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"); + var auto = Read("Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs"); + var evidenceWindow = Read("DynamicReportQualificationResultWindow.G26P1A3.cs"); + + Assert.DoesNotContain("MarkProductionEligible", source, StringComparison.Ordinal); + Assert.DoesNotContain("SaveAsync(", source, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible", auto, StringComparison.Ordinal); + Assert.Contains("profile remains InformationReportProven", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("A3 command-bound dchg PASS != ProductionEligible", evidenceWindow, StringComparison.Ordinal); + Assert.Contains("Production automatic dynamic reporting remains OFF", evidenceWindow, StringComparison.Ordinal); + } + + [Fact] + public void A3_HasSeparateExplicitHotkeyFromA21Witness_AndUsesQ0AutoCoordinator() + { + var ui = Read("DynamicReportCommandBoundWitnessUiBehavior.cs"); + + Assert.Contains("e.Key != Key.F", ui, StringComparison.Ordinal); + Assert.Contains("e.Key != Key.A", ui, StringComparison.Ordinal); + Assert.Contains("var a3 = e.Key == Key.A", ui, StringComparison.Ordinal); + Assert.Contains("DynamicReportQ0TargetLockedAutoA3CommissioningService", ui, StringComparison.Ordinal); + Assert.Contains("G2.6-P1 A3 READY", Read("Services/DynamicReportCommandBoundDataChangeCommissioningService.cs"), StringComparison.Ordinal); + Assert.DoesNotContain("Arm G2.6-P1 deterministic A3", ui, StringComparison.Ordinal); + Assert.DoesNotContain("G2.6-P1 Transactional Recovery\"", ui, StringComparison.Ordinal); + } + + [Fact] + public void Q0AutoA3_IsHardBoundToExactFieldIdentityControlStatusAndOpenStimulus() + { + var auto = Read("Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs"); + + Assert.Contains("ExpectedStableIdentity = \"ied:AA1C1F08R4\"", auto, StringComparison.Ordinal); + Assert.Contains("sha256:50c691318c6d6a16b68b121ac48627c26e6e32b937836d559dca1b9eb559f0d9", auto, StringComparison.OrdinalIgnoreCase); + Assert.Contains("TargetControlReference = \"AA1C1F08R4Q0/CSWI1.Pos\"", auto, StringComparison.Ordinal); + Assert.Contains("TargetStatusReference = \"AA1C1F08R4Q0/CSWI1.Pos.stVal\"", auto, StringComparison.Ordinal); + Assert.Contains("AutoStimulusValue = \"Open\"", auto, StringComparison.Ordinal); + Assert.Contains("CurrentState.Equals(\"Closed\"", auto, StringComparison.Ordinal); + } + + [Fact] + public void Q0AutoA3_UsesExistingRuntimeControlPathExactlyOnceWithoutToggleRetryOrClose() + { + var auto = Read("Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs"); + + Assert.Contains("Interlocked.CompareExchange(ref autoDispatchStarted, 1, 0)", auto, StringComparison.Ordinal); + Assert.Contains("runtime.ExecuteControlAsync(device.DeviceId, request, cancellationToken)", auto, StringComparison.Ordinal); + Assert.Contains("InterlockCheck = true", auto, StringComparison.Ordinal); + Assert.Contains("SynchroCheck = false", auto, StringComparison.Ordinal); + Assert.Contains("TestMode = false", auto, StringComparison.Ordinal); + Assert.Contains("retry=false", auto, StringComparison.OrdinalIgnoreCase); + Assert.Contains("No CLOSE/toggle/restore command is allowed", auto, StringComparison.Ordinal); + Assert.DoesNotContain("ValueText = \"Close\"", auto, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, CountOccurrences(auto, "runtime.ExecuteControlAsync(")); + } + + [Fact] + public void Q0AutoA3_RechecksClosedStateAfterFinalA3ReadyBeforeDispatch() + { + var auto = Read("Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs"); + + var readyIntercept = auto.IndexOf("ReadyMarker", StringComparison.Ordinal); + var dispatch = auto.IndexOf("DispatchOneShotOpenAsync", readyIntercept, StringComparison.Ordinal); + var readyRecheck = auto.IndexOf("A3 READY recheck", dispatch, StringComparison.Ordinal); + var execute = auto.IndexOf("runtime.ExecuteControlAsync", dispatch, StringComparison.Ordinal); + + Assert.True(readyIntercept >= 0); + Assert.True(dispatch > readyIntercept); + Assert.True(readyRecheck > dispatch); + Assert.True(execute > readyRecheck); + } + + [Fact] + public void Q0AutoA3_TargetScopesRecoveryOnPrivateSignalClonesWithoutChangingIdentityEvidence() + { + var auto = Read("Services/DynamicReportQ0TargetLockedAutoA3CommissioningService.cs"); + var identity = Read("Services/DynamicReportQualificationIdentity.cs"); + + Assert.Contains("MemberwiseClone", auto, StringComparison.Ordinal); + Assert.Contains("CreateTargetScopedRecoveryModel", auto, StringComparison.Ordinal); + Assert.Contains("backingField.SetValue(clone, string.Empty)", auto, StringComparison.Ordinal); + Assert.Contains("statusSetter!.Invoke(clone, [string.Empty])", auto, StringComparison.Ordinal); + Assert.Contains("scopedCommands.Length != 1", auto, StringComparison.Ordinal); + Assert.Contains("DynamicReportQualificationIdentity.Build(device, recoverySignals)", auto, StringComparison.Ordinal); + Assert.DoesNotContain("ControlStatusReference", identity, StringComparison.Ordinal); + } + + [Fact] + public void EngineLock_PreservesStrictShadowEvidenceAndAddsSeparateGuardedRuntimeBoundary() + { + var engineLock = Read("engines/ARIEC61850.lock.json"); + + Assert.Contains("\"ref\": \"main\"", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("c899b05f18ba2bd4c82ebff6879e4748036e0d90", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 100", engineLock, StringComparison.Ordinal); + Assert.Contains("PR #98", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("report-vs-independent-MMS shadow evaluator", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #99", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("paired report/poll quality evidence", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("paired report/poll device timestamp evidence", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #100", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); + Assert.Contains("does not call MarkProductionEligible", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductionEligible as a separate certification boundary", engineLock, StringComparison.OrdinalIgnoreCase); + } + + private static int CountOccurrences(string source, string value) + { + var count = 0; + var index = 0; + while ((index = source.IndexOf(value, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += value.Length; + } + return count; + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} diff --git a/tests/ARSAS.Tests/G26ShadowEvidenceRecorderRegressionTests.cs b/tests/ARSAS.Tests/G26ShadowEvidenceRecorderRegressionTests.cs new file mode 100644 index 00000000..fa65c8d2 --- /dev/null +++ b/tests/ARSAS.Tests/G26ShadowEvidenceRecorderRegressionTests.cs @@ -0,0 +1,59 @@ +namespace ARSAS.Tests; + +public sealed class G26ShadowEvidenceRecorderRegressionTests +{ + [Fact] + public void Recorder_IsExactMemberBoundedAndDoesNotSynthesizeQualityOrTimestamp() + { + var source = Read("Services/DynamicReportShadowEvidenceRecorder.cs"); + + Assert.Contains("ValidateExactMember(dataSetIndex, memberReference)", source, StringComparison.Ordinal); + Assert.Contains("MaximumReportObservations = 4096", source, StringComparison.Ordinal); + Assert.Contains("MaximumPollObservations = 16384", source, StringComparison.Ordinal); + Assert.Contains("Quality = NormalizeOptional(quality)", source, StringComparison.Ordinal); + Assert.Contains("DeviceTimestampUtc = deviceTimestampUtc", source, StringComparison.Ordinal); + Assert.DoesNotContain("Quality = \"good\"", source, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("DeviceTimestampUtc = DateTimeOffset.UtcNow", source, StringComparison.Ordinal); + } + + [Fact] + public void Recorder_TracksReconnectRecoveryAndDynamicAttemptsExplicitly() + { + var source = Read("Services/DynamicReportShadowEvidenceRecorder.cs"); + + Assert.Contains("RecordDynamicActivationAttempt", source, StringComparison.Ordinal); + Assert.Contains("RecordReconnectAttempt", source, StringComparison.Ordinal); + Assert.Contains("RecordReconnectSuccess", source, StringComparison.Ordinal); + Assert.Contains("ReportResubscriptionsAfterReconnect = _reportResubscriptionsAfterReconnect", source, StringComparison.Ordinal); + Assert.Contains("PollReferenceRecoveriesAfterReconnect = _pollReferenceRecoveriesAfterReconnect", source, StringComparison.Ordinal); + Assert.Contains("DynamicActivationAttempts = _dynamicActivationAttempts", source, StringComparison.Ordinal); + } + + [Fact] + public void Recorder_PerformsNoNetworkOrProfileMutation() + { + var source = Read("Services/DynamicReportShadowEvidenceRecorder.cs"); + + Assert.DoesNotContain("MmsClientSession", source, StringComparison.Ordinal); + Assert.DoesNotContain("SaveAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible", source, StringComparison.Ordinal); + Assert.DoesNotContain("StartPersistentReportMonitor", source, StringComparison.Ordinal); + Assert.DoesNotContain("ExecuteControlAsync", source, StringComparison.Ordinal); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} diff --git a/tests/ARSAS.Tests/G26ShadowPhysicalCollectorRegressionTests.cs b/tests/ARSAS.Tests/G26ShadowPhysicalCollectorRegressionTests.cs new file mode 100644 index 00000000..28597366 --- /dev/null +++ b/tests/ARSAS.Tests/G26ShadowPhysicalCollectorRegressionTests.cs @@ -0,0 +1,143 @@ +namespace ARSAS.Tests; + +public sealed class G26ShadowPhysicalCollectorRegressionTests +{ + [Fact] + public void Collector_UsesIndependentReadOnlyPollingAndExactDchgReportAssociation() + { + var source = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); + + Assert.Contains("new ArMms.MmsClientSession()", source, StringComparison.Ordinal); + Assert.Contains("probeReportAttributes: false", source, StringComparison.Ordinal); + Assert.Contains("maxReportAttributeProbes: 0", source, StringComparison.Ordinal); + Assert.Contains("ReadSingleVariableAsync", source, StringComparison.Ordinal); + Assert.Contains("PrepareDynamicRcbCommissioningFieldsAsync", source, StringComparison.Ordinal); + Assert.Contains("TemporaryTriggerOptions = \"dchg\"", source, StringComparison.Ordinal); + Assert.Contains("triggerGeneralInterrogation: false", source, StringComparison.Ordinal); + Assert.Contains("ValidateSpontaneousDataChangeFrame", source, StringComparison.Ordinal); + } + + [Fact] + public void Collector_RequiresTwoPhasesAndOneDeliberateReconnectWithBothPathsRecovered() + { + var source = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); + + Assert.Contains("RunPhaseAsync(\n 1", source, StringComparison.Ordinal); + Assert.Contains("recorder.RecordReconnectAttempt()", source, StringComparison.Ordinal); + Assert.Contains("RunPhaseAsync(\n 2", source, StringComparison.Ordinal); + Assert.Contains("recorder.RecordReconnectSuccess", source, StringComparison.Ordinal); + Assert.Contains("reportResubscribed: phase2.ActivationProven", source, StringComparison.Ordinal); + Assert.Contains("pollReferenceRecovered: phase2.PollReferenceRecovered", source, StringComparison.Ordinal); + Assert.Contains("ReconnectAttempts == 1", source, StringComparison.Ordinal); + Assert.Contains("SuccessfulReconnects == 1", source, StringComparison.Ordinal); + } + + [Fact] + public void Collector_KeepsReportMetadataPhysicalAndNeverUsesHeaderTimeAsDeviceTimestamp() + { + var source = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); + + Assert.Contains("Report quality/timestamp evidence is accepted only when it is physically carried", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("MmsReportValueProjector.Project(frame)", source, StringComparison.Ordinal); + Assert.Contains("projected?.HasQuality == true", source, StringComparison.Ordinal); + Assert.Contains("projected?.HasTimestamp == true", source, StringComparison.Ordinal); + Assert.DoesNotContain("frame.Header.TimeOfEntry", source, StringComparison.Ordinal); + } + + [Fact] + public void Collector_ReadsPollQualityAndTimestampFromExactIndependentCompanions() + { + var source = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); + var helper = Read("Services/DynamicReportShadowPollingCompanionReader.cs"); + + Assert.Contains("DynamicReportShadowPollingCompanionReader.ReadAsync", source, StringComparison.Ordinal); + Assert.Contains("pollDiscovery.IedDirectory", source, StringComparison.Ordinal); + Assert.Contains("companion.Quality", source, StringComparison.Ordinal); + Assert.Contains("companion.DeviceTimestampUtc", source, StringComparison.Ordinal); + + Assert.Contains(".stVal", helper, StringComparison.Ordinal); + Assert.Contains("qualityReference = dataObjectReference + \".q\"", helper, StringComparison.Ordinal); + Assert.Contains("timestampReference = dataObjectReference + \".t\"", helper, StringComparison.Ordinal); + Assert.Contains("directory.TryFindByMmsReference", helper, StringComparison.Ordinal); + Assert.Contains("Iec61850QualityDecoder.Decode(read.Value)", helper, StringComparison.Ordinal); + Assert.Contains("Iec61850TimestampDecoder.Decode(read.Value)", helper, StringComparison.Ordinal); + Assert.Contains("ReadSingleVariableAsync(qualityPoint.ToObjectReference()", helper, StringComparison.Ordinal); + Assert.Contains("ReadSingleVariableAsync(timestampPoint.ToObjectReference()", helper, StringComparison.Ordinal); + Assert.Contains("utcTime.Value.ToUniversalTime()", helper, StringComparison.Ordinal); + Assert.DoesNotContain("DateTimeOffset.UtcNow", helper, StringComparison.Ordinal); + Assert.DoesNotContain("TimeOfEntry", helper, StringComparison.Ordinal); + Assert.DoesNotContain("MmsReportValueProjector", helper, StringComparison.Ordinal); + } + + [Fact] + public void PollCompanionReader_IsBoundedReadOnlyAndFailClosed() + { + var helper = Read("Services/DynamicReportShadowPollingCompanionReader.cs"); + + Assert.Contains("at most one q read plus one t read", helper, StringComparison.OrdinalIgnoreCase); + Assert.Contains("quality = null", helper, StringComparison.Ordinal); + Assert.Contains("DateTimeOffset? deviceTimestampUtc = null", helper, StringComparison.Ordinal); + Assert.Contains("if (decoded.IsDecoded)", helper, StringComparison.Ordinal); + Assert.Contains("if (decoded.IsDecoded && TryFindUtcTime", helper, StringComparison.Ordinal); + Assert.DoesNotContain("Write", helper, StringComparison.Ordinal); + Assert.DoesNotContain("ExecuteControl", helper, StringComparison.Ordinal); + Assert.DoesNotContain("RecordReport", helper, StringComparison.Ordinal); + } + + [Fact] + public void Collector_PerformsMandatoryMonitorProofFieldAndFreshAssociationCleanup() + { + var source = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); + + Assert.Contains("StopPersistentReportMonitorAsync", source, StringComparison.Ordinal); + Assert.Contains("RestoreDynamicRcbCommissioningFieldsAsync", source, StringComparison.Ordinal); + Assert.Contains("IsTemporaryDataSetAbsentFromNameList", source, StringComparison.Ordinal); + Assert.Contains("IsFreshCleanupClosed", source, StringComparison.Ordinal); + Assert.Contains("directoryAbsent = !directory.IsSuccess", source, StringComparison.Ordinal); + Assert.Contains("monitorCleanup && fieldRestore && freshClosure", source, StringComparison.Ordinal); + } + + [Fact] + public void Collector_NeverIssuesControlOrPromotesProfile() + { + var source = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); + + Assert.DoesNotContain("ExecuteControlAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("WriteControl", source, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("_profileStore.SaveAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("MmsDynamicReportQualificationProfilePolicy.MarkProductionEligible(", source, StringComparison.Ordinal); + Assert.Contains("never calls MarkProductionEligible", source, StringComparison.Ordinal); + Assert.Contains("ZERO control commands", source, StringComparison.Ordinal); + Assert.Contains("Shadow PASS != ProductionEligible", source, StringComparison.Ordinal); + Assert.Contains("production automatic dynamic reporting remains OFF", source, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Collector_FeedsStrictAcceptanceButLeavesIndependentRegressionsExplicit() + { + var source = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); + + Assert.Contains("bool controlRegressionPassed = false", source, StringComparison.Ordinal); + Assert.Contains("bool staticReportingRegressionPassed = false", source, StringComparison.Ordinal); + Assert.Contains("_acceptanceService.EvaluateAsync", source, StringComparison.Ordinal); + Assert.Contains("controlRegressionPassed,", source, StringComparison.Ordinal); + Assert.Contains("staticReportingRegressionPassed,", source, StringComparison.Ordinal); + Assert.Contains("strictProductionCandidate", source, StringComparison.Ordinal); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} diff --git a/tests/ARSAS.Tests/G26ShadowUiRegressionTests.cs b/tests/ARSAS.Tests/G26ShadowUiRegressionTests.cs new file mode 100644 index 00000000..54ce0672 --- /dev/null +++ b/tests/ARSAS.Tests/G26ShadowUiRegressionTests.cs @@ -0,0 +1,49 @@ +namespace ARSAS.Tests; + +public sealed class G26ShadowUiRegressionTests +{ + [Fact] + public void Shadow_HasDedicatedCtrlShiftSActionSeparateFromA21AndA3() + { + var ui = Read("DynamicReportCommandBoundWitnessUiBehavior.cs"); + + Assert.Contains("e.Key != Key.F && e.Key != Key.A && e.Key != Key.S", ui, StringComparison.Ordinal); + Assert.Contains("var shadow = e.Key == Key.S", ui, StringComparison.Ordinal); + Assert.Contains("RunPhysicalShadowAsync", ui, StringComparison.Ordinal); + Assert.Contains("DynamicReportShadowVerificationCommissioningService", ui, StringComparison.Ordinal); + Assert.Contains("Ctrl+Shift+S issues ZERO automatic control commands", ui, StringComparison.Ordinal); + Assert.Contains("controlRegressionPassed: false", ui, StringComparison.Ordinal); + Assert.Contains("staticReportingRegressionPassed: false", ui, StringComparison.Ordinal); + } + + [Fact] + public void Shadow_UiRequiresTwoExplicitReadyMarkersAndShowsEvidenceWindow() + { + var ui = Read("DynamicReportCommandBoundWitnessUiBehavior.cs"); + var service = Read("Services/DynamicReportShadowVerificationCommissioningService.cs"); + var window = Read("DynamicReportQualificationResultWindow.G26Shadow.cs"); + + Assert.Contains("G2.6 SHADOW PHASE 1 READY — CAUSE ONE SAFE CHANGE", ui, StringComparison.Ordinal); + Assert.Contains("Phase1ReadyMarker", service, StringComparison.Ordinal); + Assert.Contains("Phase2ReadyMarker", service, StringComparison.Ordinal); + Assert.Contains("new DynamicReportQualificationResultWindow(result)", ui, StringComparison.Ordinal); + Assert.Contains("Shadow PASS != ProductionEligible", window, StringComparison.Ordinal); + Assert.Contains("production automatic dynamic reporting remains OFF", window, StringComparison.OrdinalIgnoreCase); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} diff --git a/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs b/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs new file mode 100644 index 00000000..a9df57cc --- /dev/null +++ b/tests/ARSAS.Tests/G26ShadowVerificationAcceptanceRegressionTests.cs @@ -0,0 +1,107 @@ +namespace ARSAS.Tests; + +public sealed class G26ShadowVerificationAcceptanceRegressionTests +{ + [Fact] + public void ShadowAcceptance_RequiresExactInformationReportProvenProfileAndMemberSequence() + { + var source = Read("Services/DynamicReportShadowVerificationAcceptanceService.cs"); + + Assert.Contains("profile.State != ArMms.MmsDynamicReportQualificationState.InformationReportProven", source, StringComparison.Ordinal); + Assert.Contains("profile.RcbActivationProof?.IsSuccess != true", source, StringComparison.Ordinal); + Assert.Contains("profile.InformationReportProof?.IsSuccess != true", source, StringComparison.Ordinal); + Assert.Contains("ExactSequenceEquals(qualifiedMembers, evidence.MemberReferences)", source, StringComparison.Ordinal); + Assert.Contains("Shadow evidence member sequence does not exactly match", source, StringComparison.Ordinal); + } + + [Fact] + public void ShadowAcceptance_UsesTypedAriecEvaluatorWithStrictPhysicalGates() + { + var source = Read("Services/DynamicReportShadowVerificationAcceptanceService.cs"); + + Assert.Contains("MmsDynamicReportShadowVerificationPolicy.Evaluate", source, StringComparison.Ordinal); + Assert.Contains("MinimumReportEdges = 2", source, StringComparison.Ordinal); + Assert.Contains("RequireQualityEvidence = true", source, StringComparison.Ordinal); + Assert.Contains("RequireDeviceTimestampEvidence = true", source, StringComparison.Ordinal); + Assert.Contains("RequireReconnectCycle = true", source, StringComparison.Ordinal); + Assert.Contains("MaximumDynamicActivationAttemptsPerAssociation = 1", source, StringComparison.Ordinal); + Assert.Contains("NoMissingReportEdgesPassed", source, StringComparison.Ordinal); + Assert.Contains("NoDuplicateReportEdgesPassed", source, StringComparison.Ordinal); + Assert.Contains("PollingAuthorityGuardPassed", source, StringComparison.Ordinal); + Assert.Contains("ReconnectRegressionPassed", source, StringComparison.Ordinal); + Assert.Contains("NoRepeatedMutationLoopPassed", source, StringComparison.Ordinal); + } + + [Fact] + public void ShadowAcceptance_UsesStrictObservedQualityTimestampProductionBridge() + { + var source = Read("Services/DynamicReportShadowVerificationAcceptanceService.cs"); + + Assert.Contains("MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedQualityEvidence", source, StringComparison.Ordinal); + Assert.Contains("MmsDynamicReportShadowProductionAcceptancePolicy.HasPairedTimestampEvidence", source, StringComparison.Ordinal); + Assert.Contains("MmsDynamicReportShadowProductionAcceptancePolicy.BuildStrict", source, StringComparison.Ordinal); + Assert.DoesNotContain("MmsDynamicReportShadowVerificationPolicy.BuildProductionAcceptance", source, StringComparison.Ordinal); + Assert.Contains("absenceCannotPass=true", source, StringComparison.Ordinal); + Assert.Contains("missing evidence is never synthesized or treated as PASS", source, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ShadowAcceptance_CannotPromoteOrPersistProfile() + { + var source = Read("Services/DynamicReportShadowVerificationAcceptanceService.cs"); + + Assert.DoesNotContain("_profileStore.SaveAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("MmsDynamicReportQualificationProfilePolicy.MarkProductionEligible(", source, StringComparison.Ordinal); + Assert.Contains("Shadow PASS != ProductionEligible", source, StringComparison.Ordinal); + Assert.Contains("production automatic dynamic reporting remains OFF", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("candidate was NOT persisted", source, StringComparison.Ordinal); + } + + [Fact] + public void ShadowAcceptance_LeavesControlAndStaticRegressionAsIndependentInputs() + { + var source = Read("Services/DynamicReportShadowVerificationAcceptanceService.cs"); + + Assert.Contains("bool controlRegressionPassed", source, StringComparison.Ordinal); + Assert.Contains("bool staticReportingRegressionPassed", source, StringComparison.Ordinal); + Assert.Contains("BuildStrict(", source, StringComparison.Ordinal); + Assert.Contains("controlRegressionPassed,", source, StringComparison.Ordinal); + Assert.Contains("staticReportingRegressionPassed);", source, StringComparison.Ordinal); + Assert.Contains("IsSuccess = shadow.IsSuccess && acceptance.AllPassed", source, StringComparison.Ordinal); + } + + [Fact] + public void EngineLock_PreservesPr99StrictCertificationAndPinsPr100GuardedRuntime() + { + var lockFile = Read("engines/ARIEC61850.lock.json"); + + Assert.Contains("c899b05f18ba2bd4c82ebff6879e4748036e0d90", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\"sourcePullRequest\": 100", lockFile, StringComparison.Ordinal); + Assert.Contains("PR #98", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("report-vs-independent-MMS shadow evaluator", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #99", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("paired report/poll quality evidence", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("paired report/poll device timestamp evidence", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("absence of q/t evidence cannot become a production PASS", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #100", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("InformationReportProven", lockFile, StringComparison.Ordinal); + Assert.Contains("does not call MarkProductionEligible", lockFile, StringComparison.OrdinalIgnoreCase); + Assert.Contains("ProductionEligible as a separate certification boundary", lockFile, StringComparison.OrdinalIgnoreCase); + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} diff --git a/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs new file mode 100644 index 00000000..3080432c --- /dev/null +++ b/tests/ARSAS.Tests/G26SmartDynamicRuntimeRegressionTests.cs @@ -0,0 +1,116 @@ +namespace ARSAS.Tests; + +public sealed class G26SmartDynamicRuntimeRegressionTests +{ + [Fact] + public void NormalMonitoring_LoadsIdentityCompatibleInformationReportProvenContext() + { + var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); + var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); + + Assert.Contains("DynamicReportQualificationIdentity.Build(device, device.Signals.ToArray())", guarded, StringComparison.Ordinal); + Assert.Contains("DynamicReportQualificationProfileStore", guarded, StringComparison.Ordinal); + Assert.Contains("LoadAsync(identity", guarded, StringComparison.Ordinal); + Assert.Contains("MmsDynamicReportQualificationState.InformationReportProven", guarded, StringComparison.Ordinal); + Assert.Contains("MmsDynamicInformationReportKind.DataChange", guarded, StringComparison.Ordinal); + Assert.Contains("TryLoadGuardedRuntimeContextAsync(device", bridge, StringComparison.Ordinal); + Assert.Contains("MmsDynamicReportGuardedRuntimePlanningContext", guarded, StringComparison.Ordinal); + } + + [Fact] + public void InitialPlanningAndExecutionRevalidation_UseSameGuardedPlannerFamily() + { + var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); + var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); + + Assert.Contains("MmsGuardedDynamicReportRuntimePlanner.Build", guarded, StringComparison.Ordinal); + Assert.True(Count(bridge, "BuildCapabilityPlanWithGuardedRuntime(") >= 2); + Assert.Contains("_guardedRuntimeContexts[appPlan.PlanId] = guardedRuntime.Context", bridge, StringComparison.Ordinal); + Assert.Contains("TryGetGuardedRuntimeContext(plan.PlanId", bridge, StringComparison.Ordinal); + Assert.Contains("guardedRuntimeContext", bridge, StringComparison.Ordinal); + } + + [Fact] + public void GuardedRuntime_DoesNotPromoteOrSaveQualificationProfile() + { + var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); + var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); + var recovery = Read("Services/NativeIec61850Client.HybridReporting.P4.cs"); + + Assert.DoesNotContain("MarkProductionEligible(", guarded, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible(", bridge, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible(", recovery, StringComparison.Ordinal); + Assert.DoesNotContain("SaveAsync(", guarded, StringComparison.Ordinal); + Assert.DoesNotContain("SaveAsync(", bridge, StringComparison.Ordinal); + Assert.DoesNotContain("SaveAsync(", recovery, StringComparison.Ordinal); + Assert.Contains("ProductionEligible certification remains separate", guarded, StringComparison.Ordinal); + } + + [Fact] + public void RuntimeStillHasFreshRevalidationCircuitBreakerAndPollingFallback() + { + var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); + var recovery = Read("Services/NativeIec61850Client.HybridReporting.P4.cs"); + var runtime = Read("Services/Iec61850MonitorRuntime.cs"); + + Assert.Contains("CheckReportControlAvailabilityAsync", bridge, StringComparison.Ordinal); + Assert.Contains("DynamicWriteCircuitByDevice", bridge, StringComparison.Ordinal); + Assert.Contains("DynamicWriteCircuitByDevice", recovery, StringComparison.Ordinal); + Assert.Contains("AllowPollingFallback = true", bridge, StringComparison.Ordinal); + Assert.Contains("AllowPollingFallback = true", recovery, StringComparison.Ordinal); + Assert.Contains("value changed without matching report", runtime, StringComparison.Ordinal); + Assert.Contains("MMS fallback", runtime, StringComparison.Ordinal); + Assert.Contains("Live / report verified + MMS validation", runtime, StringComparison.Ordinal); + } + + [Fact] + public void StaticRecovery_PreservesPlanBoundGuardedContext() + { + var recovery = Read("Services/NativeIec61850Client.HybridReporting.P4.cs"); + + Assert.Contains("TryGetGuardedRuntimeContext(appPlan.PlanId", recovery, StringComparison.Ordinal); + Assert.Contains("BuildCapabilityPlanWithGuardedRuntime", recovery, StringComparison.Ordinal); + Assert.Contains("_authoritativeHybridSubscriptions[appPlan.PlanId]", recovery, StringComparison.Ordinal); + Assert.Contains("return await StartHybridReportMonitorAsync(appPlan", recovery, StringComparison.Ordinal); + Assert.DoesNotContain("MarkProductionEligible(", recovery, StringComparison.Ordinal); + } + + [Fact] + public void EngineLock_PinsMergedGuardedRuntimeEngine() + { + var engineLock = Read("engines/ARIEC61850.lock.json"); + + Assert.Contains("\"commit\": \"c899b05f18ba2bd4c82ebff6879e4748036e0d90\"", engineLock, StringComparison.Ordinal); + Assert.Contains("\"sourcePullRequest\": 100", engineLock, StringComparison.Ordinal); + Assert.Contains("guarded runtime planner", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); + } + + private static int Count(string source, string value) + { + var count = 0; + var offset = 0; + while ((offset = source.IndexOf(value, offset, StringComparison.Ordinal)) >= 0) + { + count++; + offset += value.Length; + } + return count; + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + throw new FileNotFoundException(relativePath); + } +} diff --git a/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs b/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs index 88ff45a7..ec8b4a12 100644 --- a/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs +++ b/tests/ARSAS.Tests/HybridReportAssociationCapabilityP3RegressionTests.cs @@ -8,15 +8,18 @@ public sealed class HybridReportAssociationCapabilityP3RegressionTests public void HybridPlanning_UsesAssociationCapabilityForInitialPlanAndFreshRevalidation() { var source = Read("Services/NativeIec61850Client.HybridReporting.cs"); + var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); - const string call = "ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build("; - Assert.Equal(2, Count(source, call)); + Assert.True(Count(source, "BuildCapabilityPlanWithGuardedRuntime(") >= 2); + Assert.Contains("ArMms.MmsCapabilityAwareHybridReportAcquisitionPlanner.Build(", guarded, StringComparison.Ordinal); + Assert.Contains("ArMms.MmsGuardedDynamicReportRuntimePlanner.Build(", guarded, StringComparison.Ordinal); Assert.True(Count(source, "_session.LastNegotiatedCapabilities") >= 2); Assert.Contains("var enginePlan = capabilityAwarePlan.AcquisitionPlan;", source, StringComparison.Ordinal); Assert.Contains("var associationCapability = capabilityAwarePlan.AssociationCapability;", source, StringComparison.Ordinal); Assert.Contains("Summary = $\"{enginePlan.Summary} {associationCapability.Summary}\"", source, StringComparison.Ordinal); Assert.Contains("var revalidatedPlan = revalidatedCapabilityAwarePlan.AcquisitionPlan;", source, StringComparison.Ordinal); Assert.DoesNotContain("ArMms.MmsHybridReportAcquisitionPlanner.Build(", source, StringComparison.Ordinal); + Assert.DoesNotContain("ArMms.MmsHybridReportAcquisitionPlanner.Build(", guarded, StringComparison.Ordinal); } [Fact] @@ -44,6 +47,8 @@ public void EngineLock_PreservesP62BStabilityHistoryAcrossLaterReviewedEnginePin Assert.Contains("instMag/mag", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("instCVal/cVal", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("ambiguous structures remain raw", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #100", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exact proven dynamic RCB/member envelope", source, StringComparison.OrdinalIgnoreCase); } private static int Count(string source, string value) diff --git a/tests/ARSAS.Tests/HybridReportDynamicAttemptP4RegressionTests.cs b/tests/ARSAS.Tests/HybridReportDynamicAttemptP4RegressionTests.cs index 037ef597..c141cc47 100644 --- a/tests/ARSAS.Tests/HybridReportDynamicAttemptP4RegressionTests.cs +++ b/tests/ARSAS.Tests/HybridReportDynamicAttemptP4RegressionTests.cs @@ -19,28 +19,59 @@ public void Planning_ProjectsEngineAttemptEvidenceInsteadOfSilentPolling() } [Fact] - public void StaticFailure_IsIsolatedAndNeverStartsDynamicMutation() + public void StaticFailure_GetsGuardedExactDynamicRecoveryBeforePolling() { var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); var recovery = Read("Services/NativeIec61850Client.HybridReporting.P4.cs"); + var guarded = Read("Services/NativeIec61850Client.HybridReporting.GuardedRuntime.cs"); - // The ordinary residual dynamic path is still attempt-aware. Assert.Contains("StartPersistentReportMonitorWithAttemptEvidenceAsync", bridge, StringComparison.Ordinal); Assert.True(Count(bridge, "TryStartDynamicRecoveryAfterStaticFailureP4Async") >= 4); - // P6.1 intentionally keeps the old method name only as a source-compatible, - // fail-closed hook. Static failure must never create a new DataSet or write another RCB. - Assert.Contains("baseline static-failure isolation", recovery, StringComparison.OrdinalIgnoreCase); - Assert.Contains("no dynamic DataSet/RCB write was attempted", recovery, StringComparison.OrdinalIgnoreCase); - Assert.Contains("UsedDynamicDataSet = false", recovery, StringComparison.Ordinal); - Assert.Contains("DynamicAttempted = false", recovery, StringComparison.Ordinal); - Assert.Contains("FailureReason = \"StaticActivationFailed\"", recovery, StringComparison.Ordinal); - Assert.Contains("PollingFallbackReason = \"StaticActivationFailed\"", recovery, StringComparison.Ordinal); - Assert.DoesNotContain("AllowStaticBrcb = false", recovery, StringComparison.Ordinal); - Assert.DoesNotContain("AllowStaticUrcb = false", recovery, StringComparison.Ordinal); - Assert.DoesNotContain("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", recovery, StringComparison.Ordinal); + // G2.6 may recover a failed static segment, but only through the ARIEC guarded + // planner, the same PlanId-bound InformationReportProven context, and a different + // freshly classified RCB. P4 never writes an RCB directly. + Assert.Contains("alternateSnapshots", recovery, StringComparison.Ordinal); + Assert.Contains("!SameLiteralReference(snapshot.Reference, authoritative.ReportControlReference)", recovery, StringComparison.Ordinal); + Assert.Contains("AllowStaticBrcb = false", recovery, StringComparison.Ordinal); + Assert.Contains("AllowStaticUrcb = false", recovery, StringComparison.Ordinal); + Assert.Contains("AllowDynamicBrcb = authoritative.Options.AllowDynamicBrcb", recovery, StringComparison.Ordinal); + Assert.Contains("AllowDynamicUrcb = authoritative.Options.AllowDynamicUrcb", recovery, StringComparison.Ordinal); + Assert.Contains("RequireExactAvailabilityEvidence = true", recovery, StringComparison.Ordinal); + Assert.Contains("TryGetGuardedRuntimeContext(appPlan.PlanId", recovery, StringComparison.Ordinal); + Assert.Contains("BuildCapabilityPlanWithGuardedRuntime", recovery, StringComparison.Ordinal); + Assert.Contains("MmsGuardedDynamicReportRuntimePlanner.Build", guarded, StringComparison.Ordinal); + Assert.Contains("_authoritativeHybridSubscriptions[appPlan.PlanId]", recovery, StringComparison.Ordinal); + Assert.Contains("return await StartHybridReportMonitorAsync(appPlan", recovery, StringComparison.Ordinal); Assert.DoesNotContain("StartPersistentReportMonitorWithAttemptEvidenceAsync", recovery, StringComparison.Ordinal); - Assert.DoesNotContain("DynamicWriteCircuitByDevice[appPlan.RelayId]", recovery, StringComparison.Ordinal); + Assert.DoesNotContain("DefineNamedVariableList", recovery, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void StaticPostMutationRecovery_RequiresProvenCleanup() + { + var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); + var recovery = Read("Services/NativeIec61850Client.HybridReporting.P4.cs"); + + Assert.Contains("bool staticCleanupProven = false", recovery, StringComparison.Ordinal); + Assert.Contains("staticMutationWasAttempted", recovery, StringComparison.Ordinal); + Assert.Contains("StaticCleanupUnproven", recovery, StringComparison.Ordinal); + Assert.Contains("a second RCB mutation is forbidden", recovery, StringComparison.OrdinalIgnoreCase); + Assert.Contains("staticCleanupProven: attempt.CleanupSucceeded", bridge, StringComparison.Ordinal); + } + + [Fact] + public void DynamicRecovery_RetainsCircuitBreakerPlanIdentityAndGuardedAuthority() + { + var bridge = Read("Services/NativeIec61850Client.HybridReporting.cs"); + var recovery = Read("Services/NativeIec61850Client.HybridReporting.P4.cs"); + + Assert.Contains("DynamicWriteCircuitByDevice.TryGetValue(appPlan.RelayId", recovery, StringComparison.Ordinal); + Assert.Contains("DynamicWriteCircuitOpen", recovery, StringComparison.Ordinal); + Assert.Contains("appPlan.EngineAcquisitionKind = dynamicSegment.Kind.ToString()", recovery, StringComparison.Ordinal); + Assert.Contains("_authoritativeHybridSubscriptions[appPlan.PlanId]", recovery, StringComparison.Ordinal); + Assert.Contains("TryGetGuardedRuntimeContext(appPlan.PlanId", recovery, StringComparison.Ordinal); + Assert.Contains("DynamicWriteCircuitByDevice[plan.RelayId] = reason", bridge, StringComparison.Ordinal); } [Fact] @@ -59,12 +90,15 @@ public void PhysicalValidation_PersistsAttemptFailureAndSkipTelemetry() } [Fact] - public void EngineLock_PinsAttemptAwareEngine() + public void EngineLock_PinsAttemptAwareAndGuardedRuntimeEngine() { var engineLock = Read("engines/ARIEC61850.lock.json"); Assert.Contains("dynamic-attempt", engineLock, StringComparison.OrdinalIgnoreCase); Assert.Contains("rollback", engineLock, StringComparison.OrdinalIgnoreCase); + Assert.Contains("PR #100", engineLock, StringComparison.Ordinal); + Assert.Contains("c899b05f18ba2bd4c82ebff6879e4748036e0d90", engineLock, StringComparison.Ordinal); + Assert.Contains("InformationReportProven", engineLock, StringComparison.Ordinal); } private static int Count(string source, string value) diff --git a/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs b/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs index 3910c301..1cd82fdc 100644 --- a/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/P62BFieldStabilityRegressionTests.cs @@ -88,13 +88,25 @@ public void AmbiguousStructuredStaticValue_CannotOverwriteScalarProcessState() } [Fact] - public void P61StaticFailureIsolation_RemainsIntact() + public void G26SmartRecovery_DoesNotRegressP62BMutationQuarantine() { - var source = ReadRepoFile("Services/NativeIec61850Client.HybridReporting.P4.cs"); + var bridge = ReadRepoFile("Services/NativeIec61850Client.HybridReporting.cs"); + var recovery = ReadRepoFile("Services/NativeIec61850Client.HybridReporting.P4.cs"); - Assert.Contains("no dynamic DataSet/RCB write was attempted", source, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("StartPersistentReportMonitorWithAttemptEvidenceAsync", source, StringComparison.Ordinal); + // P4 is still not a wire writer. It can only ask the guarded/capability planner for + // the PlanId-bound proven target, replace the authoritative plan, then re-enter the + // normal StartHybrid path where fresh availability and the dynamic circuit are enforced. + Assert.DoesNotContain("DefineNamedVariableList", recovery, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("StartPersistentReportMonitorWithAttemptEvidenceAsync", recovery, StringComparison.Ordinal); + Assert.Contains("TryGetGuardedRuntimeContext(appPlan.PlanId", recovery, StringComparison.Ordinal); + Assert.Contains("BuildCapabilityPlanWithGuardedRuntime", recovery, StringComparison.Ordinal); + Assert.Contains("StartHybridReportMonitorAsync(appPlan", recovery, StringComparison.Ordinal); + Assert.Contains("DynamicWriteCircuitByDevice.TryGetValue(appPlan.RelayId", recovery, StringComparison.Ordinal); + + // Post-mutation static recovery is gated by the engine's actual rollback result. + Assert.Contains("StaticCleanupUnproven", recovery, StringComparison.Ordinal); + Assert.Contains("staticCleanupProven: attempt.CleanupSucceeded", bridge, StringComparison.Ordinal); + Assert.Contains("!SameLiteralReference(snapshot.Reference, authoritative.ReportControlReference)", recovery, StringComparison.Ordinal); } private static string ReadRepoFile(string relativePath) diff --git a/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs b/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs index f9e22ee2..91776721 100644 --- a/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/P6FieldStabilityRegressionTests.cs @@ -131,20 +131,29 @@ public void FailedRealDynamicAttempt_OpensProcessLifetimeCircuitBreaker() } [Fact] - public void StaticFailure_IsBaselineIsolatedAndCannotOpenOrUseDynamicCircuit() + public void StaticFailure_RecoveryPreservesP6FieldSafety() { - var source = ReadRepoFile("Services/NativeIec61850Client.HybridReporting.P4.cs"); - - Assert.Contains("baseline static-failure isolation", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("no dynamic DataSet/RCB write was attempted", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("UsedDynamicDataSet = false", source, StringComparison.Ordinal); - Assert.Contains("DynamicAttempted = false", source, StringComparison.Ordinal); - Assert.Contains("FailureReason = \"StaticActivationFailed\"", source, StringComparison.Ordinal); - Assert.Contains("PollingFallbackReason = \"StaticActivationFailed\"", source, StringComparison.Ordinal); - Assert.DoesNotContain("StartPersistentReportMonitorWithAttemptEvidenceAsync", source, StringComparison.Ordinal); - Assert.DoesNotContain("MmsCapabilityAwareHybridReportAcquisitionPlanner.Build", source, StringComparison.Ordinal); - Assert.DoesNotContain("DynamicWriteCircuitByDevice.TryGetValue(appPlan.RelayId", source, StringComparison.Ordinal); - Assert.DoesNotContain("DynamicWriteCircuitByDevice[appPlan.RelayId]", source, StringComparison.Ordinal); + var bridge = ReadRepoFile("Services/NativeIec61850Client.HybridReporting.cs"); + var recovery = ReadRepoFile("Services/NativeIec61850Client.HybridReporting.P4.cs"); + + // Recovery cannot reuse the failed static RCB and cannot write directly from the + // compatibility layer. The same PlanId-bound ARIEC guarded/capability planner must + // select the only exact proven alternate dynamic target from fresh evidence. + Assert.Contains("alternateSnapshots", recovery, StringComparison.Ordinal); + Assert.Contains("!SameLiteralReference(snapshot.Reference, authoritative.ReportControlReference)", recovery, StringComparison.Ordinal); + Assert.Contains("AllowStaticBrcb = false", recovery, StringComparison.Ordinal); + Assert.Contains("AllowStaticUrcb = false", recovery, StringComparison.Ordinal); + Assert.Contains("RequireExactAvailabilityEvidence = true", recovery, StringComparison.Ordinal); + Assert.Contains("TryGetGuardedRuntimeContext(appPlan.PlanId", recovery, StringComparison.Ordinal); + Assert.Contains("BuildCapabilityPlanWithGuardedRuntime", recovery, StringComparison.Ordinal); + Assert.DoesNotContain("StartPersistentReportMonitorWithAttemptEvidenceAsync", recovery, StringComparison.Ordinal); + Assert.DoesNotContain("DefineNamedVariableList", recovery, StringComparison.OrdinalIgnoreCase); + + // A static activation that already touched RCB state needs positive rollback evidence + // before Smart Auto is allowed to attempt the alternate dynamic RCB. + Assert.Contains("StaticCleanupUnproven", recovery, StringComparison.Ordinal); + Assert.Contains("staticCleanupProven: attempt.CleanupSucceeded", bridge, StringComparison.Ordinal); + Assert.Contains("DynamicWriteCircuitByDevice.TryGetValue(appPlan.RelayId", recovery, StringComparison.Ordinal); } [Fact]