From c4abd9c16e03acd9b1d529b78a48576086f7e5a0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 17:54:44 +0700 Subject: [PATCH 001/115] P10.9L: restore command catalog and exact read-only live ctlModel diagnostics --- MainWindow.xaml | 11 ++- MainWindow.xaml.cs | 26 ++++-- Models/MonitorModels.cs | 7 +- Services/Iec61850ControlIdentity.cs | 15 +++- Services/NativeIec61850Client.cs | 39 ++++++++- engines/ARIEC61850.lock.json | 19 ++++- scripts/test-p10-9f-core-routing-trial.ps1 | 31 ++++++- .../CanonicalLiveSclExportRegressionTests.cs | 6 +- .../ARSAS.Tests/P109LControlRecoveryTests.cs | 81 +++++++++++++++++++ ...teroperabilityTrialGuardRegressionTests.cs | 9 ++- 10 files changed, 220 insertions(+), 24 deletions(-) create mode 100644 tests/ARSAS.Tests/P109LControlRecoveryTests.cs diff --git a/MainWindow.xaml b/MainWindow.xaml index 8ee6d221f..7f372c173 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -652,7 +652,14 @@ - + + + + + @@ -793,7 +800,7 @@ - diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 8b5553833..ca8d7b3cb 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -1018,6 +1018,7 @@ private async Task ConnectUsingSavedModelAsync( if (!device.HasDiscoveryCache || device.Signals.Count == 0) return await ConnectAndConfigureDeviceAsync(device, openWizard: true, selectDevice: selectDevice); + ResetControlReadinessForNewAssociation(device); RemoveDevicePoints(device.DeviceId); device.Points.Clear(); device.RefreshComputed(); @@ -1368,11 +1369,13 @@ private async Task RefreshControlValuesAsync(Iec61850MonitorDevice device, bool signal.ControlLastResult = "Ready • live feedback available"; return; } - if (!device.IsConnected || device.CommandSignals.Count == 0) + if (!device.IsConnected) return; - var candidates = device.CommandSignals - .Where(signal => force || signal.ControlCurrentValue == "-" || signal.ControlModelText == "Auto-detect") + // Never gate inspection on an operable-only UI projection. + var candidates = Iec61850ControlIdentity.DistinctSelected(device.Signals) + .Where(signal => force || !signal.ControlModelResolved || + signal.ControlCurrentValue == "-") .ToArray(); if (candidates.Length == 0) { @@ -1394,9 +1397,7 @@ await Task.WhenAll(candidates.Select(async signal => if (!signal.ControlCommandBusy) { signal.ControlCurrentValue = capabilities.CurrentValue; - signal.ControlLastResult = capabilities.SupportsOperate - ? capabilities.ControlModelText - : "Control unavailable"; + signal.ControlLastResult = capabilities.EngineControlServiceStatus; } } catch (OperationCanceledException) @@ -2116,9 +2117,22 @@ private async Task StopDeviceMonitorAsync(Iec61850MonitorDevice device) SetStatus($"{device.Name}: monitoring stopped; MMS connection remains available."); } + private static void ResetControlReadinessForNewAssociation(Iec61850MonitorDevice device) + { + foreach (var signal in device.Signals.Where(s => s.IsValidControlObject)) + { + signal.ClearControlConfirmation(); + signal.ControlSemanticVerified = false; + signal.ControlModelText = "Auto-detect"; + signal.ControlLastResult = "Inspection pending for the current MMS association."; + } + device.RefreshCommandSignalProjection(); + } + private async Task StopDeviceConnectionAsync(Iec61850MonitorDevice device) { await _runtime.StopDeviceAsync(device.DeviceId); + ResetControlReadinessForNewAssociation(device); device.HasReportStream = false; device.ReportPulseActive = false; _reportPulseUntil.Remove(device.DeviceId); diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index eeb30e767..f5a992bfa 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -595,10 +595,9 @@ public void RefreshCommandSignalProjection() private void RefreshCommandSignals() { - // The Command Panel is an operating surface, not a second signal browser. - // Keep unresolved, StatusOnly, unsupported, and feedback-only objects out so - // the panel contains only actions the connected IED has proven executable. - var selected = Iec61850ControlIdentity.DistinctOperable(Signals) + // Show every selected valid control DataObject. Operate authorization remains + // independently gated by ControlSupportsOperate on each command action. + var selected = Iec61850ControlIdentity.DistinctSelected(Signals) .OrderBy(signal => signal.SortPriority) .ThenBy(signal => signal.LogicalNode, StringComparer.OrdinalIgnoreCase) .ThenBy(signal => signal.Name, StringComparer.OrdinalIgnoreCase) diff --git a/Services/Iec61850ControlIdentity.cs b/Services/Iec61850ControlIdentity.cs index ae60fc313..c95ad6aac 100644 --- a/Services/Iec61850ControlIdentity.cs +++ b/Services/Iec61850ControlIdentity.cs @@ -16,6 +16,16 @@ public static int CountSelected(IEnumerable signals) .Select(s => Normalize(s.ObjectReference)) .Where(s => s.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).Count(); + // Catalog membership and operational authorization are separate concerns. + public static SignalDefinition[] DistinctSelected(IEnumerable signals) + => signals.Where(s => s.IsSelected && s.IsValidControlObject) + .GroupBy(s => Normalize(s.ObjectReference), StringComparer.OrdinalIgnoreCase) + .Select(group => group.OrderByDescending(s => s.ControlCommandBusy) + .ThenByDescending(s => s.ControlConfirmationPending) + .ThenByDescending(s => s.ControlSupportsOperate) + .ThenByDescending(s => s.ControlModelResolved).First()) + .ToArray(); + public static SignalDefinition[] DistinctOperable(IEnumerable signals) => signals.Where(s => s.IsSelected && s.IsValidControlObject && s.ControlModelResolved && s.ControlSupportsOperate && !s.IsGenericControl) @@ -25,8 +35,5 @@ public static SignalDefinition[] DistinctOperable(IEnumerable .ThenByDescending(s => s.ControlConfirmationPending).First()).ToArray(); public static SignalDefinition[] InspectionRepresentatives(IEnumerable signals) - => signals.Where(s => s.IsSelected && s.IsValidControlObject) - .GroupBy(s => Normalize(s.ObjectReference), StringComparer.OrdinalIgnoreCase) - .Select(group => group.OrderByDescending(s => s.ControlModelResolved).First()) - .Where(s => !s.ControlModelResolved).ToArray(); + => DistinctSelected(signals).Where(s => !s.ControlModelResolved).ToArray(); } diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index 8ab80c5c0..beb17b12d 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -1096,7 +1096,44 @@ public async Task InspectControlAsync( if (!_session.IsMmsInitiated) throw new InvalidOperationException("The IEC 61850 MMS association is not active."); - var control = await GetOrOpenControlSessionAsync(signal, cancellationToken).ConfigureAwait(false); + ArControl.Iec61850ControlObjectSession control; + try + { + control = await GetOrOpenControlSessionAsync(signal, cancellationToken).ConfigureAwait(false); + } + catch (InvalidOperationException discoveryError) when (_session.IsMmsInitiated) + { + // Distinguish an exact StatusOnly read from a failed/unsupported + // ctlModel read without a service write or full NameList discovery. + var probe = await RunMmsOperationAsync( + () => new ArControl.Iec61850ControlService().ProbeControlModelAsync( + _session, signal.ObjectReference, cancellationToken), + cancellationToken).ConfigureAwait(false); + signal.ControlSemanticVerified = false; + signal.ControlModelText = probe.Decoded + ? FriendlyControlModel(probe.ControlModel) + : "Unknown"; + signal.ControlModelReference = $"{signal.ObjectReference}.ctlModel"; + var reason = $"{probe.Detail} Descriptor not ready: {discoveryError.Message}"; + signal.ControlLastResult = reason; + return new Iec61850ControlCapabilities + { + ObjectReference = signal.ObjectReference, + StatusReference = signal.ControlStatusReference, + ControlModelReference = signal.ControlModelReference, + ControlModel = probe.Decoded + ? MapControlModel(probe.ControlModel) + : Iec61850ControlModelKind.Unknown, + ControlModelText = signal.ControlModelText, + ControlCdc = signal.ControlCdc, + ControlValueType = signal.ControlValueType, + CurrentValue = signal.ControlCurrentValue, + EngineControlServiceAvailable = probe.ReadSucceeded, + IsOperationallyReady = false, + DiscoveryEvidence = $"Read-only {probe.MmsReference}; kind={probe.RawKind}; scalar={probe.RawScalar}; request={probe.RequestHexPreview}; response={probe.ResponseHexPreview}", + EngineControlServiceStatus = reason + }; + } var descriptor = control.Descriptor; var semantic = ArControl.Iec61850ControlSemanticResolver.Resolve( descriptor, signal.ControlCdc, signal.ControlStatusReference); diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 449ad8e69..4e30fc349 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "a43b9b4864a9565c02f544d4135fe157c2fac577", - "sourcePullRequest": 168, - "purpose": "P10.9K CodeVerified-only: vendor-neutral control CDC fail-closed semantics with live ctlVal/status evidence plus association-scoped NameList single-flight reuse; ARIEC PR #168 CI #38231327892. Retains P10.9J exact OID/BSC interoperability, P10.9I FIFO protection, physical R7/R10 baseline and no-automatic-dynamic reporting. Physical ZIV control still NOT accepted.", + "commit": "55fa34cef9a4abee589b9a6d94be1d0361375d71", + "sourcePullRequest": 169, + "purpose": "P10.9L code-trial only: exact read-only ctlModel diagnosis and selected control catalog visibility. ARIEC PR #169; no physical command acceptance, no discovery/reporting authority change.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, @@ -161,5 +161,18 @@ "mainlinePromotionForbidden": true, "fullDiscoveryFallbackForbidden": true, "cyclicMmsProcessPollingForbidden": true + }, + "p109LControlRecoveryTrial": { + "status": "code-trial-not-physical", + "issue": 525, + "enginePullRequest": 169, + "baseEngineCommit": "a43b9b4864a9565c02f544d4135fe157c2fac577", + "exactCommit": "55fa34cef9a4abee589b9a6d94be1d0361375d71", + "readOnlyCtlModelProbeRequired": true, + "noControlWriteOnProbe": true, + "physicalQualificationRequired": true, + "mainlinePromotionForbidden": true, + "fullDiscoveryFallbackForbidden": true, + "cyclicMmsProcessPollingForbidden": true } } diff --git a/scripts/test-p10-9f-core-routing-trial.ps1 b/scripts/test-p10-9f-core-routing-trial.ps1 index 2019e2253..6bd3a7c8f 100644 --- a/scripts/test-p10-9f-core-routing-trial.ps1 +++ b/scripts/test-p10-9f-core-routing-trial.ps1 @@ -91,7 +91,36 @@ function Test-P109FCoreRoutingTrial { [bool]$generic.fullDiscoveryFallbackForbidden -and [bool]$generic.cyclicMmsProcessPollingForbidden ) - if (-not ($previousHead -or $subscriberHead -or $canonicalHead -or $zivHead -or $genericHead) -or + # Exact P10.9L read-only engine candidate, preserving every historical gate. + $probe = $Lock.p109LControlRecoveryTrial + $probeHead = ( + [string]$Lock.commit -ceq '55fa34cef9a4abee589b9a6d94be1d0361375d71' -and + [int]$Lock.sourcePullRequest -eq 169 -and + $zivIntegrityValid -and $null -ne $generic -and $null -ne $probe -and + [string]$generic.exactCommit -ceq 'a43b9b4864a9565c02f544d4135fe157c2fac577' -and + [string]$generic.baseEngineCommit -ceq [string]$ziv.exactCommit -and + [int]$generic.enginePullRequest -eq 168 -and + [long]$generic.engineCiRun -eq 38231327892 -and + [bool]$generic.typedSemanticGateRequired -and + [bool]$generic.associationScopedInventoryRequired -and + [bool]$generic.vendorDispatchForbidden -and + [bool]$generic.physicalQualificationRequired -and + [bool]$generic.mainlinePromotionForbidden -and + [bool]$generic.fullDiscoveryFallbackForbidden -and + [bool]$generic.cyclicMmsProcessPollingForbidden -and + [string]$probe.status -ceq 'code-trial-not-physical' -and + [int]$probe.issue -eq 525 -and + [int]$probe.enginePullRequest -eq 169 -and + [string]$probe.baseEngineCommit -ceq [string]$generic.exactCommit -and + [string]$probe.exactCommit -ceq '55fa34cef9a4abee589b9a6d94be1d0361375d71' -and + [bool]$probe.readOnlyCtlModelProbeRequired -and + [bool]$probe.noControlWriteOnProbe -and + [bool]$probe.physicalQualificationRequired -and + [bool]$probe.mainlinePromotionForbidden -and + [bool]$probe.fullDiscoveryFallbackForbidden -and + [bool]$probe.cyclicMmsProcessPollingForbidden + ) + if (-not ($previousHead -or $subscriberHead -or $canonicalHead -or $zivHead -or $genericHead -or $probeHead) -or [string]$Lock.repository -cne 'masarray/ARIEC61850' -or [string]$Lock.ref -cne 'main' -or $null -eq $candidate) { return $false } diff --git a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs index f2e110668..c9f92d6f5 100644 --- a/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs +++ b/tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs @@ -445,8 +445,12 @@ public void EngineLock_PreservesPhysicalSclRepairBaselineAcrossCodeVerifiedInter var genericTrial = root.GetProperty("genericControlInteroperabilityTrial"); Assert.Equal(zivTrial.GetProperty("exactCommit").GetString(), genericTrial.GetProperty("baseEngineCommit").GetString()); + var recovery = root.GetProperty("p109LControlRecoveryTrial"); Assert.Equal(genericTrial.GetProperty("exactCommit").GetString(), + recovery.GetProperty("baseEngineCommit").GetString()); + Assert.Equal(recovery.GetProperty("exactCommit").GetString(), root.GetProperty("commit").GetString()); + Assert.True(recovery.GetProperty("noControlWriteOnProbe").GetBoolean()); Assert.Equal("a43b9b4864a9565c02f544d4135fe157c2fac577", genericTrial.GetProperty("exactCommit").GetString()); Assert.Equal("code-verified-not-physical", canonicalTrial.GetProperty("status").GetString()); @@ -467,7 +471,7 @@ public void EngineLock_PreservesPhysicalSclRepairBaselineAcrossCodeVerifiedInter Assert.True(zivTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); Assert.True(zivTrial.GetProperty("fullDiscoveryFallbackForbidden").GetBoolean()); Assert.True(zivTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); - Assert.Equal(168, root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal(169, root.GetProperty("sourcePullRequest").GetInt32()); Assert.True(genericTrial.GetProperty("typedSemanticGateRequired").GetBoolean()); Assert.True(genericTrial.GetProperty("associationScopedInventoryRequired").GetBoolean()); Assert.True(genericTrial.GetProperty("vendorDispatchForbidden").GetBoolean()); diff --git a/tests/ARSAS.Tests/P109LControlRecoveryTests.cs b/tests/ARSAS.Tests/P109LControlRecoveryTests.cs new file mode 100644 index 000000000..78bf68e39 --- /dev/null +++ b/tests/ARSAS.Tests/P109LControlRecoveryTests.cs @@ -0,0 +1,81 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class P109LControlRecoveryTests +{ + private static SignalDefinition Control(string reference, string cdc, bool selected = true) + => new() + { + Name = reference[(reference.LastIndexOf('.') + 1)..], + ObjectReference = reference, + FunctionalConstraint = "CO", + IsControlSignal = true, + IsSelected = selected, + ControlCdc = cdc, + ControlModelText = "Auto-detect" + }; + + [Fact] + public void FiveZivControlsRemainVisibleBeforeInspection() + { + var device = new Iec61850MonitorDevice(); + foreach (var (reference, cdc) in new[] { + ("ZIVAVRLD1/ATCC1.TapChg", "BSC"), + ("ZIVAVRLD1/CSWI1.Pos", "DPC"), + ("ZIVAVRLD1/CSWI2.Pos", "DPC"), + ("ZIVAVRLD1/CSWI3.Pos", "DPC"), + ("ZIVAVRLD1/CSWI4.Pos", "DPC") + }) + device.Signals.Add(Control(reference, cdc)); + device.RecountSelectedSignals(); + + Assert.Equal(5, device.SelectedControlSignalCount); + Assert.Equal(5, device.CommandSignals.Count); + Assert.All(device.CommandSignals, signal => + { + Assert.False(signal.ControlSupportsOperate); + Assert.False(signal.ControlSemanticVerified); + }); + Assert.True(device.CommandSignals.Single(s => s.ControlCdc == "BSC").IsRaiseLowerControl); + Assert.All(device.CommandSignals.Where(s => s.ControlCdc == "DPC"), + s => Assert.True(s.IsPositionControl)); + } + + [Fact] + public void UninspectedAndStatusOnlyRemainInCatalogWithAllWritesBlocked() + { + var device = new Iec61850MonitorDevice(); + var pending = Control("LD0/CSWI1.Pos", "DPC"); + var readOnly = Control("LD0/ATCC1.TapChg", "BSC"); + readOnly.ControlModelText = "Status only"; + device.Signals.Add(pending); + device.Signals.Add(readOnly); + device.RecountSelectedSignals(); + + Assert.Equal(2, device.CommandSignals.Count); + Assert.All(device.CommandSignals, s => Assert.False(s.ControlSupportsOperate)); + Assert.Single(Iec61850ControlIdentity.InspectionRepresentatives(device.Signals)); + } + + [Fact] + public void SelectedCatalogUsesExactDataObjectAndIgnoresUnselectedObjects() + { + var a = Control("LD0/CSWI1.Pos", "DPC"); + var duplicate = Control("ld0/cswi1.pos", "DPC"); + var unselected = Control("LD0/CSWI2.Pos", "DPC", selected: false); + var catalog = Iec61850ControlIdentity.DistinctSelected(new[] { a, duplicate, unselected }); + Assert.Single(catalog); + Assert.Same(a, catalog[0]); + } + + [Fact] + public void EngineeringCtlModelIsNotAuthorizationWithoutLiveSemanticEvidence() + { + var tap = Control("ZIVAVRLD1/ATCC1.TapChg", "BSC"); + tap.ControlModelText = "ctlModel=4"; + Assert.True(tap.ControlModelResolved); + Assert.False(tap.ControlSupportsOperate); + } +} diff --git a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs index 96e1487dc..e42870789 100644 --- a/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs +++ b/tests/ARSAS.Tests/SmartInteroperabilityTrialGuardRegressionTests.cs @@ -89,8 +89,13 @@ public void TrialLock_PreservesHistoricalPhysicalAuthorityAndExplicitlyRequiresP var genericTrial = root.GetProperty("genericControlInteroperabilityTrial"); Assert.Equal(zivTrial.GetProperty("exactCommit").GetString(), genericTrial.GetProperty("baseEngineCommit").GetString()); + var recovery = root.GetProperty("p109LControlRecoveryTrial"); + Assert.Equal(genericTrial.GetProperty("exactCommit").GetString(), + recovery.GetProperty("baseEngineCommit").GetString()); Assert.Equal(root.GetProperty("commit").GetString(), - genericTrial.GetProperty("exactCommit").GetString()); + recovery.GetProperty("exactCommit").GetString()); + Assert.Equal("code-trial-not-physical", recovery.GetProperty("status").GetString()); + Assert.True(recovery.GetProperty("noControlWriteOnProbe").GetBoolean()); Assert.Equal("code-verified-not-physical", codeTrial.GetProperty("status").GetString()); Assert.Equal("code-verified-not-physical", subscriberTrial.GetProperty("status").GetString()); Assert.True(subscriberTrial.GetProperty("physicalQualificationRequired").GetBoolean()); @@ -115,7 +120,7 @@ public void TrialLock_PreservesHistoricalPhysicalAuthorityAndExplicitlyRequiresP Assert.True(zivTrial.GetProperty("mainlinePromotionForbidden").GetBoolean()); Assert.True(zivTrial.GetProperty("fullDiscoveryFallbackForbidden").GetBoolean()); Assert.True(zivTrial.GetProperty("cyclicMmsProcessPollingForbidden").GetBoolean()); - Assert.Equal(168,root.GetProperty("sourcePullRequest").GetInt32()); + Assert.Equal(169,root.GetProperty("sourcePullRequest").GetInt32()); Assert.Equal(540, genericTrial.GetProperty("issue").GetInt32()); Assert.Equal(38231327892L, genericTrial.GetProperty("engineCiRun").GetInt64()); Assert.True(genericTrial.GetProperty("typedSemanticGateRequired").GetBoolean()); From 5564d01f1a48bc0bdd6a36363713fe6f041717de Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 17:59:55 +0700 Subject: [PATCH 002/115] P10.9L: qualify exact engine probe CI SHA and preserve immutable ancestry --- engines/ARIEC61850.lock.json | 9 +++++---- scripts/test-p10-9f-core-routing-trial.ps1 | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 4e30fc349..0ea6be0fc 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,9 +2,9 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "55fa34cef9a4abee589b9a6d94be1d0361375d71", + "commit": "5e17da5f673eedce125b5c66e7dc2fa0abef2851", "sourcePullRequest": 169, - "purpose": "P10.9L code-trial only: exact read-only ctlModel diagnosis and selected control catalog visibility. ARIEC PR #169; no physical command acceptance, no discovery/reporting authority change.", + "purpose": "P10.9L code-verified-not-physical: exact read-only ctlModel diagnosis and selected control catalog visibility. ARIEC PR #169 CI 38239451501 verified engine SHA 5e17da5f673eedce125b5c66e7dc2fa0abef2851; no physical command acceptance, no discovery/reporting authority change.", "previousTrialPin": { "commit": "4467124775d8d9d76f3db194f9fbfd97144767a8", "sourcePullRequest": 134, @@ -167,12 +167,13 @@ "issue": 525, "enginePullRequest": 169, "baseEngineCommit": "a43b9b4864a9565c02f544d4135fe157c2fac577", - "exactCommit": "55fa34cef9a4abee589b9a6d94be1d0361375d71", + "exactCommit": "5e17da5f673eedce125b5c66e7dc2fa0abef2851", "readOnlyCtlModelProbeRequired": true, "noControlWriteOnProbe": true, "physicalQualificationRequired": true, "mainlinePromotionForbidden": true, "fullDiscoveryFallbackForbidden": true, - "cyclicMmsProcessPollingForbidden": true + "cyclicMmsProcessPollingForbidden": true, + "engineCiRun": 38239451501 } } diff --git a/scripts/test-p10-9f-core-routing-trial.ps1 b/scripts/test-p10-9f-core-routing-trial.ps1 index 6bd3a7c8f..cbc406827 100644 --- a/scripts/test-p10-9f-core-routing-trial.ps1 +++ b/scripts/test-p10-9f-core-routing-trial.ps1 @@ -112,7 +112,8 @@ function Test-P109FCoreRoutingTrial { [int]$probe.issue -eq 525 -and [int]$probe.enginePullRequest -eq 169 -and [string]$probe.baseEngineCommit -ceq [string]$generic.exactCommit -and - [string]$probe.exactCommit -ceq '55fa34cef9a4abee589b9a6d94be1d0361375d71' -and + [string]$probe.exactCommit -ceq '5e17da5f673eedce125b5c66e7dc2fa0abef2851' -and + [long]$probe.engineCiRun -eq 38239451501 -and [bool]$probe.readOnlyCtlModelProbeRequired -and [bool]$probe.noControlWriteOnProbe -and [bool]$probe.physicalQualificationRequired -and From 7d1467817675a53e619428d5b974fa4dc258c9d1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 18:05:50 +0700 Subject: [PATCH 003/115] P10.9L: align fail-closed regression oracles and exact engine SHA admission --- scripts/test-p10-9f-core-routing-trial.ps1 | 2 +- tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs | 3 ++- tests/ARSAS.Tests/F650CommandShoutIntegrationTests.cs | 3 ++- .../ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs | 9 ++++++--- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/scripts/test-p10-9f-core-routing-trial.ps1 b/scripts/test-p10-9f-core-routing-trial.ps1 index cbc406827..c7b71c079 100644 --- a/scripts/test-p10-9f-core-routing-trial.ps1 +++ b/scripts/test-p10-9f-core-routing-trial.ps1 @@ -94,7 +94,7 @@ function Test-P109FCoreRoutingTrial { # Exact P10.9L read-only engine candidate, preserving every historical gate. $probe = $Lock.p109LControlRecoveryTrial $probeHead = ( - [string]$Lock.commit -ceq '55fa34cef9a4abee589b9a6d94be1d0361375d71' -and + [string]$Lock.commit -ceq '5e17da5f673eedce125b5c66e7dc2fa0abef2851' -and [int]$Lock.sourcePullRequest -eq 169 -and $zivIntegrityValid -and $null -ne $generic -and $null -ne $probe -and [string]$generic.exactCommit -ceq 'a43b9b4864a9565c02f544d4135fe157c2fac577' -and diff --git a/tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs b/tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs index 8565339f5..635d08073 100644 --- a/tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs +++ b/tests/ARSAS.Tests/F650CanonicalCommandFailureTests.cs @@ -99,7 +99,8 @@ public void ProducersAndDispatcher_EnforceCanonicalIdentity() var projection = Read("Services/Iec61850StaticControlStatusProjectionService.cs"); Assert.Contains("Full DataObject is command identity", projection, StringComparison.Ordinal); var model = Read("Models/MonitorModels.cs"); - Assert.Contains("DistinctOperable(Signals)", model, StringComparison.Ordinal); + Assert.Contains("DistinctSelected(Signals)", model, StringComparison.Ordinal); + Assert.Contains("ControlSupportsOperate", model, StringComparison.Ordinal); var runtime = Read("Services/Iec61850MonitorRuntime.cs"); Assert.Contains("_activeControlTargets.TryAdd(targetKey, 0)", runtime, StringComparison.Ordinal); Assert.Contains("_activeControlTargets.TryRemove(targetKey, out _)", runtime, StringComparison.Ordinal); diff --git a/tests/ARSAS.Tests/F650CommandShoutIntegrationTests.cs b/tests/ARSAS.Tests/F650CommandShoutIntegrationTests.cs index 4806e2a4a..af6b26438 100644 --- a/tests/ARSAS.Tests/F650CommandShoutIntegrationTests.cs +++ b/tests/ARSAS.Tests/F650CommandShoutIntegrationTests.cs @@ -100,7 +100,8 @@ public void IdentityProjection_AndSingleFlightProtection_StillActive() { var model = Read("Models/MonitorModels.cs"); var runtime = Read("Services/Iec61850MonitorRuntime.cs"); - Assert.Contains("Iec61850ControlIdentity.DistinctOperable(Signals)", model, StringComparison.Ordinal); + Assert.Contains("Iec61850ControlIdentity.DistinctSelected(Signals)", model, StringComparison.Ordinal); + Assert.Contains("ControlSupportsOperate", model, StringComparison.Ordinal); Assert.Contains("_activeControlTargets.TryAdd(targetKey, 0)", runtime, StringComparison.Ordinal); Assert.Contains("_activeControlTargets.TryRemove(targetKey, out _)", runtime, StringComparison.Ordinal); } diff --git a/tests/ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs b/tests/ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs index be1c8b726..07ad4726e 100644 --- a/tests/ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs +++ b/tests/ARSAS.Tests/IoListFatCommandPanelRegressionTests.cs @@ -81,9 +81,12 @@ public void FatCommandPanel_RemainsFailClosedForStatusOnlyOrUnsupportedControls( Assert.Contains("live ctlModel", bridge, StringComparison.OrdinalIgnoreCase); Assert.Contains("StatusOnly stays read-only", bridge, StringComparison.Ordinal); Assert.Contains("device.CommandSignals", bridge, StringComparison.Ordinal); - Assert.Contains("DistinctOperable(Signals)", monitorModels, StringComparison.Ordinal); - // Fail-closed gate migrated into the shared canonical command projection; - // all entry points still exclude unknown/read-only and generic controls. + // Selected control objects remain visible for diagnosis; operating safety + // is separately enforced by the per-object gate and FAT command dispatch. + Assert.Contains("DistinctSelected(Signals)", monitorModels, StringComparison.Ordinal); + Assert.Contains("ControlSupportsOperate", monitorModels, StringComparison.Ordinal); + // Legacy explicit-operability filter remains available for actual execution + // eligibility, never for removing the selected control row from the dock. Assert.Contains("ControlModelResolved && s.ControlSupportsOperate", identity, StringComparison.Ordinal); Assert.Contains("!s.IsGenericControl", identity, StringComparison.Ordinal); Assert.Contains("Status-only controls remain read-only", panel, StringComparison.Ordinal); From 875eeb41a3e5a73acbc85bbfd714a08b0e4f47c4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 12 Oct 2026 18:42:10 +0700 Subject: [PATCH 004/115] P10.9M: decouple control action presentation from live write authorization --- MainWindow.IoFatCommandBridge.cs | 11 +- MainWindow.xaml | 17 ++- MainWindow.xaml.cs | 12 ++ Models/SignalDefinition.cs | 13 ++- Services/DiagnosticReportBuilder.cs | 11 ++ Services/Iec61850ControlStatusEvidence.cs | 42 +++++++ Services/NativeIec61850Client.cs | 3 + .../P109MControlDockPresentationTests.cs | 103 ++++++++++++++++++ 8 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 Services/Iec61850ControlStatusEvidence.cs create mode 100644 tests/ARSAS.Tests/P109MControlDockPresentationTests.cs diff --git a/MainWindow.IoFatCommandBridge.cs b/MainWindow.IoFatCommandBridge.cs index bb3683b12..6da25eec6 100644 --- a/MainWindow.IoFatCommandBridge.cs +++ b/MainWindow.IoFatCommandBridge.cs @@ -23,10 +23,9 @@ public partial class MainWindow if (device is null) return null; - // CommandSignals contains only controls whose live ctlModel has proved that - // operation is allowed and whose UI command semantics are supported. Keep an - // explicit owner mapping so a FAT command cannot accidentally fall back to the - // Engineering tab's currently selected IED in a multi-IED workspace. + // CommandSignals is the selected control-object catalog, including readonly + // objects. Only ControlSupportsOperate authorizes actions. Keep explicit IED + // owner mapping so FAT cannot fall back to another IED in the workspace. device.RefreshCommandSignalProjection(); foreach (var signal in device.Signals.Where(signal => signal.IsControlSignal && signal.IsValidControlObject)) _signalOwners[signal] = device; @@ -40,8 +39,8 @@ internal async Task RefreshIoFatCommandValuesAsync(Iec61850MonitorDevice device) var stopwatch = Stopwatch.StartNew(); var fallbackRead = false; - // Preload is the same serialized live ctlModel authority used by the Engineering - // Command Panel. StatusOnly stays read-only and never enters CommandSignals. + // Preload uses the same live ctlModel authority as Engineering. StatusOnly + // remains visible in the selected catalog but stays action-disabled. await PreloadControlModelsAsync(); device.RefreshCommandSignalProjection(); diff --git a/MainWindow.xaml b/MainWindow.xaml index 7f372c173..0ae314780 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -655,7 +655,7 @@ @@ -682,7 +682,7 @@ - + @@ -783,6 +783,19 @@