From c1972be4265ff82534f6e6c9775a805284d90583 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 13:48:42 +0700 Subject: [PATCH 01/34] fix: select only RCB-backed static DataSets --- ...Iec61850StaticDataSetAuthoritySelection.cs | 135 ++++++++++++------ 1 file changed, 91 insertions(+), 44 deletions(-) diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs index 63844047..c97002b7 100644 --- a/Services/Iec61850StaticDataSetAuthoritySelection.cs +++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs @@ -6,11 +6,16 @@ namespace ArIED61850Tester.Services; /// /// Builds the exact Static DataSet selection used by the report-only workflow. /// -/// A DataSetReference on a browsed/runtime alias is not sufficient authority: several +/// Static report-only mode is RCB-backed by definition. A static DataSet that is not +/// referenced by any configured BRCB/URCB is valid engineering inventory, but it is not +/// a live acquisition source and therefore must not inflate the monitor with permanently +/// unavailable rows. Selection is limited to exact DataSet memberships referenced by a +/// configured ReportControl in either the opened SCL design model or fresh live discovery. +/// +/// A DataSetReference on a browsed/runtime alias is also not sufficient authority: several /// aliases can point at the same static FCDA/FCD member (for example cVal/instCVal or -/// structured measurement descendants). Static mode must select one presentation row -/// for each engine-authoritative DataSet membership, preserving the literal member -/// identity that appears in SCL/ARIEC. +/// structured measurement descendants). Static mode selects one presentation row for each +/// engine-authoritative membership, preserving the literal member identity from SCL/ARIEC. /// public static class Iec61850StaticDataSetAuthoritySelection { @@ -22,60 +27,102 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) if (model is null) return new HashSet(ReferenceEqualityComparer.Instance); + var reportBackedDataSets = BuildReportBackedDataSetReferences(device); + if (reportBackedDataSets.Count == 0) + return new HashSet(ReferenceEqualityComparer.Instance); + var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model); var selected = new HashSet(ReferenceEqualityComparer.Instance); var signals = device.Signals.ToArray(); foreach (var descriptor in mandatory) { - var membership = descriptor.DataSetMemberships + // A descriptor may carry more than one membership. Do not arbitrarily take the + // first DataSet: choose only literal memberships that are backed by configured + // report-control authority. + var memberships = descriptor.DataSetMemberships + .Where(item => reportBackedDataSets.Contains(NormalizeLiteral(item.DataSetReference))) .OrderBy(item => item.DataSetReference, StringComparer.OrdinalIgnoreCase) .ThenBy(item => item.MemberIndex) - .FirstOrDefault(); - if (membership is null) - continue; - - var memberReference = FirstNonEmpty( - membership.CanonicalMemberReference, - membership.OriginalMemberReference, - descriptor.DesignReference, - descriptor.ObservedReference, - descriptor.PrimaryValueReference); - if (string.IsNullOrWhiteSpace(memberReference) || - string.IsNullOrWhiteSpace(membership.DataSetReference)) - continue; - - var candidates = signals - .Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime) - .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) - .Where(signal => LiteralEquals(signal.DisplayReference, memberReference)) .ToArray(); - if (candidates.Length == 0) - continue; - - // Prefer the runtime row already bound to ARIEC's resolved primary value. - // For an unresolved structured member, the inventory-created exact static row - // wins over a generic browsed alias. Never choose by fuzzy/prefix matching. - var chosen = candidates - .OrderByDescending(signal => - !string.IsNullOrWhiteSpace(descriptor.PrimaryValueReference) && - LiteralEquals(signal.ObjectReference, descriptor.PrimaryValueReference)) - .ThenByDescending(signal => - (signal.Source ?? string.Empty).Contains( - "mandatory static DataSet member", - StringComparison.OrdinalIgnoreCase)) - .ThenByDescending(signal => - string.Equals(signal.Category, "DataSet", StringComparison.OrdinalIgnoreCase)) - .ThenByDescending(signal => - string.Equals(signal.Confidence, "High", StringComparison.OrdinalIgnoreCase)) - .First(); - - selected.Add(chosen); + + foreach (var membership in memberships) + { + var memberReference = FirstNonEmpty( + membership.CanonicalMemberReference, + membership.OriginalMemberReference, + descriptor.DesignReference, + descriptor.ObservedReference, + descriptor.PrimaryValueReference); + if (string.IsNullOrWhiteSpace(memberReference) || + string.IsNullOrWhiteSpace(membership.DataSetReference)) + { + continue; + } + + var candidates = signals + .Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime) + .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) + .Where(signal => LiteralEquals(signal.DisplayReference, memberReference)) + .ToArray(); + if (candidates.Length == 0) + continue; + + // Prefer the runtime row already bound to ARIEC's resolved primary value. + // For an unresolved structured member, the inventory-created exact static row + // wins over a generic browsed alias. Never choose by fuzzy/prefix matching. + var chosen = candidates + .OrderByDescending(signal => + !string.IsNullOrWhiteSpace(descriptor.PrimaryValueReference) && + LiteralEquals(signal.ObjectReference, descriptor.PrimaryValueReference)) + .ThenByDescending(signal => + (signal.Source ?? string.Empty).Contains( + "mandatory static DataSet member", + StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(signal => + string.Equals(signal.Category, "DataSet", StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(signal => + string.Equals(signal.Confidence, "High", StringComparison.OrdinalIgnoreCase)) + .First(); + + selected.Add(chosen); + } } return selected; } + /// + /// Returns the literal DataSet references that have configured report-control authority. + /// The union is deliberate: fast SCL reconnect can have a richer design model than the + /// partial live model, while a full discovery can reveal additional valid live RCB + /// bindings. Neither source is allowed to erase the other's exact configured evidence. + /// + public static IReadOnlySet BuildReportBackedDataSetReferences(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + AddReportBackedDataSets(device.SclWorkspace?.DesignModel, result); + AddReportBackedDataSets(device.LiveDiscoveryModel, result); + return result; + } + + private static void AddReportBackedDataSets( + LiveIedModelDiscoveryDocument? model, + HashSet target) + { + if (model is null) + return; + + foreach (var report in model.ReportControls) + { + var dataSetReference = NormalizeLiteral(report.DataSetReference); + if (!string.IsNullOrWhiteSpace(dataSetReference)) + target.Add(dataSetReference); + } + } + private static bool LiteralEquals(string? left, string? right) => string.Equals(NormalizeLiteral(left), NormalizeLiteral(right), StringComparison.OrdinalIgnoreCase); From cc60008ecd16eb73ab7b5b0e148d5666fc2128c9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 13:48:57 +0700 Subject: [PATCH 02/34] fix: resolve indexed static RCB instances safely --- Services/Iec61850StaticRcbReferenceMatcher.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 Services/Iec61850StaticRcbReferenceMatcher.cs diff --git a/Services/Iec61850StaticRcbReferenceMatcher.cs b/Services/Iec61850StaticRcbReferenceMatcher.cs new file mode 100644 index 00000000..8adc6a7c --- /dev/null +++ b/Services/Iec61850StaticRcbReferenceMatcher.cs @@ -0,0 +1,74 @@ +namespace ArIED61850Tester.Services; + +/// +/// Matches one configured SCL ReportControl declaration to the concrete RCB object exposed +/// by the live MMS server. +/// +/// IEC 61850 SCL may describe one indexed ReportControl family while the server exposes +/// concrete instances such as Buffer01 / Buffer02. Exact identity always wins. The only +/// accepted fallback is a decimal indexed instance of the same literal RCB family; arbitrary +/// same-DataSet RCBs are never treated as substitutes. +/// +public static class Iec61850StaticRcbReferenceMatcher +{ + public static bool IsExact(string? configuredReference, string? liveReference) + => string.Equals( + Normalize(configuredReference), + Normalize(liveReference), + StringComparison.OrdinalIgnoreCase); + + public static bool IsConfiguredOrIndexedInstance(string? configuredReference, string? liveReference) + { + var configured = Normalize(configuredReference); + var live = Normalize(liveReference); + if (string.IsNullOrWhiteSpace(configured) || string.IsNullOrWhiteSpace(live)) + return false; + if (string.Equals(configured, live, StringComparison.OrdinalIgnoreCase)) + return true; + + SplitLeaf(configured, out var configuredParent, out var configuredLeaf); + SplitLeaf(live, out var liveParent, out var liveLeaf); + if (!string.Equals(configuredParent, liveParent, StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(configuredLeaf) || + string.IsNullOrWhiteSpace(liveLeaf)) + { + return false; + } + + // If the configured declaration already ends in a digit, treat it as a concrete + // object and require exact identity. This avoids accidental prefix matches such as + // Buffer0 -> Buffer01. + if (char.IsDigit(configuredLeaf[^1]) || + !liveLeaf.StartsWith(configuredLeaf, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var suffix = liveLeaf[configuredLeaf.Length..]; + return suffix.Length > 0 && suffix.All(char.IsDigit); + } + + public static int MatchRank(string? configuredReference, string? liveReference) + { + if (IsExact(configuredReference, liveReference)) + return 0; + return IsConfiguredOrIndexedInstance(configuredReference, liveReference) ? 1 : int.MaxValue; + } + + public static string Normalize(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); + + private static void SplitLeaf(string reference, out string parent, out string leaf) + { + var separator = reference.LastIndexOf('.'); + if (separator < 0) + { + parent = string.Empty; + leaf = reference; + return; + } + + parent = reference[..separator]; + leaf = reference[(separator + 1)..]; + } +} From 20e30e7105b1977ba30af2f2fd3718c9e2730539 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 13:50:30 +0700 Subject: [PATCH 03/34] fix: preserve SCL RCB authority and resolve indexed live instances --- ...veIec61850Client.StaticDataSetReporting.cs | 101 +++++++++++++++--- 1 file changed, 84 insertions(+), 17 deletions(-) diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs index 68cdcd9b..8523f261 100644 --- a/Services/NativeIec61850Client.StaticDataSetReporting.cs +++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs @@ -1,3 +1,4 @@ +using AR.Iec61850.Discovery; using ArIED61850Tester.Models; using ArMms = AR.Iec61850.Mms; @@ -9,9 +10,10 @@ namespace ArIED61850Tester.Services; /// Static mode already has complete configuration authority in the opened/live SCL model: /// exact DataSet membership and configured RCB -> DataSet bindings. Do not run those facts /// through the adaptive Hybrid acquisition planner. The live association is used only to -/// verify that the exact configured RCB and exact DataSet directory exist, then ARIEC's -/// persistent monitor installs the InformationReport receiver, enables RptEna and requests -/// GI. No dynamic DataSet write and no cyclic process-value MMS read is permitted here. +/// verify that the configured RCB (or a concrete indexed instance of that configured family) +/// and exact DataSet directory exist, then ARIEC's persistent monitor installs the +/// InformationReport receiver, enables RptEna and requests GI. No dynamic DataSet write and +/// no cyclic process-value MMS read is permitted here. /// public sealed partial class NativeIec61850Client { @@ -30,8 +32,8 @@ public async Task BuildStaticDataSetReportPlan _deterministicStaticSubscriptions.Clear(); ResetSemanticReportProjectionContext(); - var model = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; - if (model is null) + var projectionModel = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; + if (projectionModel is null) { return StaticPlanningUnavailable( points, @@ -45,7 +47,22 @@ public async Task BuildStaticDataSetReportPlan $"Static DataSet report-only requires an initiated MMS association. Current state: {_session.State}."); } - SetSemanticReportProjectionAuthority(model); + SetSemanticReportProjectionAuthority(projectionModel); + + // Keep SCL design configuration authoritative even when a partial live model exists. + // Fast/cached reconnects can populate LiveDiscoveryModel without reproducing every + // configured ReportControl. Selecting `LiveDiscoveryModel ?? DesignModel` for RCB + // configuration silently erased valid SCL evidence (field symptom: SCL saw Buffer02 + // for Digital, while runtime planning reported staticBRCB=0). The union below keeps + // exact design authority and augments it with any report controls learned live. + var configurationModels = new[] + { + device.SclWorkspace?.DesignModel, + device.LiveDiscoveryModel + } + .Where(model => model is not null) + .Cast() + .ToArray(); // Fresh report discovery is verification, not permission policy. In particular we // deliberately do NOT classify a configured BRCB through the Hybrid availability @@ -76,8 +93,13 @@ public async Task BuildStaticDataSetReportPlan { cancellationToken.ThrowIfCancellationRequested(); - var configuredReports = model.ReportControls + var configuredReports = configurationModels + .SelectMany(configurationModel => configurationModel.ReportControls) .Where(report => SameStaticReference(report.DataSetReference, dataSetGroup.Key)) + .GroupBy( + report => $"{NormalizeStaticReference(report.Reference)}|{NormalizeStaticReference(report.DataSetReference)}", + StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) .OrderByDescending(report => report.Buffered) .ThenBy(report => report.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -85,7 +107,7 @@ public async Task BuildStaticDataSetReportPlan if (configuredReports.Length == 0) { warnings.Add( - $"{dataSetGroup.First().DataSetReference}: no configured BRCB/URCB in the SCL model; {dataSetGroup.Count()} selected point(s) remain explicitly unavailable. No MMS process polling was substituted."); + $"{dataSetGroup.First().DataSetReference}: no configured BRCB/URCB in the SCL/live model union; {dataSetGroup.Count()} selected point(s) remain explicitly unavailable. No MMS process polling was substituted."); continue; } @@ -96,19 +118,56 @@ public async Task BuildStaticDataSetReportPlan } var configured = configuredReports[0]; + var dataSetReference = dataSetGroup.First().DataSetReference.Trim(); + + // SCL can represent an indexed ReportControl family while the MMS server exposes + // concrete instances (for example Buffer -> Buffer01/Buffer02). Exact identity + // remains rank 0. The only fallback accepted here is a decimal indexed instance + // of that same literal RCB family and the same DataSet; arbitrary same-DataSet + // RCB substitution is forbidden. var liveCandidates = discovery.ReportInventory.ReportControls - .Where(candidate => SameStaticReference(candidate.Reference, configured.Reference)) + .Select(candidate => new + { + Candidate = candidate, + Rank = Iec61850StaticRcbReferenceMatcher.MatchRank(configured.Reference, candidate.Reference) + }) + .Where(item => item.Rank != int.MaxValue) + .Where(item => + string.IsNullOrWhiteSpace(item.Candidate.DataSetReference) || + SameStaticReference(item.Candidate.DataSetReference, dataSetReference)) + .OrderBy(item => item.Rank) + .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); - if (liveCandidates.Length != 1) + if (liveCandidates.Length == 0) { + var sameDataSet = discovery.ReportInventory.ReportControls + .Where(candidate => + !string.IsNullOrWhiteSpace(candidate.DataSetReference) && + SameStaticReference(candidate.DataSetReference, dataSetReference)) + .Select(candidate => candidate.Reference) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .OrderBy(reference => reference, StringComparer.OrdinalIgnoreCase) + .Take(8) + .ToArray(); + var observed = sameDataSet.Length == 0 + ? "none" + : string.Join(", ", sameDataSet); warnings.Add( - $"{configured.Reference}: configured RCB was proven by SCL but exact live MMS discovery returned {liveCandidates.Length} matching RCB object(s). Static mode refused to guess and did not poll process values."); + $"{configured.Reference}: configured RCB was proven by SCL but live MMS discovery exposed no exact/indexed-family instance. Same-DataSet live RCBs: {observed}. Static mode refused arbitrary substitution and did not poll process values."); continue; } - var liveRcb = CloneReportControlForPlanning(liveCandidates[0]); - var dataSetReference = dataSetGroup.First().DataSetReference.Trim(); + var bestRank = liveCandidates[0].Rank; + var bestCandidates = liveCandidates.Where(item => item.Rank == bestRank).ToArray(); + if (bestCandidates.Length > 1) + { + warnings.Add( + $"{configured.Reference}: configured indexed RCB family matched {bestCandidates.Length} concrete live instance(s); selected {bestCandidates[0].Candidate.Reference} by literal instance order. No unrelated RCB was substituted."); + } + + var liveSource = bestCandidates[0].Candidate; + var liveRcb = CloneReportControlForPlanning(liveSource); if (!string.IsNullOrWhiteSpace(liveRcb.DataSetReference) && !SameStaticReference(liveRcb.DataSetReference, dataSetReference)) @@ -149,13 +208,16 @@ public async Task BuildStaticDataSetReportPlan if (bindings.Count == 0) continue; + var concreteReportReference = string.IsNullOrWhiteSpace(liveRcb.Reference) + ? configured.Reference + : liveRcb.Reference; var plan = new ReportControlPlan { RelayId = device.DeviceId, RelayName = device.Name, RelayIpAddress = device.IpAddress, IedName = device.Name, - ReportControlReference = configured.Reference, + ReportControlReference = concreteReportReference, DataSetReference = dataSetReference, Mode = "Static DataSet • deterministic configured RCB", AllowDynamicDataSetWrites = false, @@ -171,7 +233,12 @@ public async Task BuildStaticDataSetReportPlan }; var subscriptionWarnings = new List(); - if (string.IsNullOrWhiteSpace(liveCandidates[0].DataSetReference)) + if (!Iec61850StaticRcbReferenceMatcher.IsExact(configured.Reference, concreteReportReference)) + { + subscriptionWarnings.Add( + $"SCL ReportControl family {configured.Reference} resolved to concrete live indexed instance {concreteReportReference}."); + } + if (string.IsNullOrWhiteSpace(liveSource.DataSetReference)) { subscriptionWarnings.Add( "Live RCB DatSet text was not returned; exact SCL RCB->DataSet configuration plus the successfully read live DataSet directory are the deterministic authority."); @@ -187,7 +254,7 @@ public async Task BuildStaticDataSetReportPlan DynamicPoints = Array.Empty(), Steps = new[] { - $"Verify exact configured RCB {configured.Reference} exists on the live association.", + $"Verify configured RCB {configured.Reference} as live object {concreteReportReference}.", $"Use exact ordered live DataSet directory {dataSetReference} ({directory.Members.Count} members).", "Install InformationReport receiver before enabling the RCB.", "Write RptEna=true, then request GI=true.", @@ -391,7 +458,7 @@ private static bool SameStaticReference(string? left, string? right) StringComparison.OrdinalIgnoreCase); private static string NormalizeStaticReference(string? reference) - => (reference ?? string.Empty).Trim().Replace('$', '.'); + => Iec61850StaticRcbReferenceMatcher.Normalize(reference); private static int ParseStaticInteger(string? value) => int.TryParse(value, out var parsed) && parsed > 0 ? parsed : 0; From 2830f3a4a1aae9027e4e97903e9999479ff03439 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 13:51:04 +0700 Subject: [PATCH 04/34] test: guard RCB-backed static selection and indexed instances --- ...ticDataSetReportOnlyModeRegressionTests.cs | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs index 50a41a62..138e3f78 100644 --- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs @@ -40,7 +40,7 @@ public void RuntimeContract_StaticDataSetMode_DoesNotScheduleCyclicMmsProcessPol } [Fact] - public void SharedSclStaticSelection_UsesExactAriecMembershipRows_NotEveryDatasetTaggedAlias() + public void SharedSclStaticSelection_UsesOnlyExactRcbBackedAriecMembershipRows() { var source = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs")); var authority = File.ReadAllText(FindRepoFile("Services/Iec61850StaticDataSetAuthoritySelection.cs")); @@ -54,12 +54,56 @@ public void SharedSclStaticSelection_UsesExactAriecMembershipRows_NotEveryDatase Assert.DoesNotContain("fallback remains available", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model)", authority, StringComparison.Ordinal); + Assert.Contains("BuildReportBackedDataSetReferences(device)", authority, StringComparison.Ordinal); + Assert.Contains("reportBackedDataSets.Contains", authority, StringComparison.Ordinal); + Assert.Contains("device.SclWorkspace?.DesignModel", authority, StringComparison.Ordinal); + Assert.Contains("device.LiveDiscoveryModel", authority, StringComparison.Ordinal); Assert.Contains("LiteralEquals(signal.DataSetReference, membership.DataSetReference)", authority, StringComparison.Ordinal); Assert.Contains("LiteralEquals(signal.DisplayReference, memberReference)", authority, StringComparison.Ordinal); Assert.DoesNotContain("StartsWith(memberReference", authority, StringComparison.Ordinal); Assert.DoesNotContain("Contains(memberReference", authority, StringComparison.Ordinal); } + [Theory] + [InlineData("IEDApplication/LLN0$BR$Buffer", "IEDApplication/LLN0$BR$Buffer01", true)] + [InlineData("IEDApplication/LLN0$BR$Buffer", "IEDApplication/LLN0$BR$Buffer02", true)] + [InlineData("IEDApplication/LLN0$BR$Buffer02", "IEDApplication/LLN0$BR$Buffer02", true)] + [InlineData("IEDApplication/LLN0$BR$Buffer02", "IEDApplication/LLN0$BR$Buffer01", false)] + [InlineData("IEDApplication/LLN0$BR$Buffer", "IEDApplication/LLN0$BR$Other01", false)] + [InlineData("IEDApplication/LLN0$BR$Buffer0", "IEDApplication/LLN0$BR$Buffer01", false)] + public void StaticRcbMatcher_AcceptsOnlyExactOrDecimalIndexedFamilyInstances( + string configured, + string live, + bool expected) + { + Assert.Equal(expected, Iec61850StaticRcbReferenceMatcher.IsConfiguredOrIndexedInstance(configured, live)); + } + + [Fact] + public void StaticRcbMatcher_ExactIdentityAlwaysRanksBeforeIndexedFamily() + { + const string configured = "IEDApplication/LLN0$BR$Buffer"; + Assert.Equal(0, Iec61850StaticRcbReferenceMatcher.MatchRank(configured, configured)); + Assert.Equal(1, Iec61850StaticRcbReferenceMatcher.MatchRank(configured, "IEDApplication/LLN0$BR$Buffer01")); + Assert.Equal(int.MaxValue, Iec61850StaticRcbReferenceMatcher.MatchRank(configured, "IEDApplication/LLN0$BR$Other01")); + } + + [Fact] + public void DeterministicStaticPlanner_PreservesSclConfigurationAndConcreteLiveRcbAuthority() + { + var source = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.StaticDataSetReporting.cs")); + + Assert.Contains("device.SclWorkspace?.DesignModel", source, StringComparison.Ordinal); + Assert.Contains("device.LiveDiscoveryModel", source, StringComparison.Ordinal); + Assert.Contains("configurationModels", source, StringComparison.Ordinal); + Assert.Contains("Iec61850StaticRcbReferenceMatcher.MatchRank", source, StringComparison.Ordinal); + Assert.Contains("ReportControlReference = concreteReportReference", source, StringComparison.Ordinal); + Assert.Contains("Install InformationReport receiver before enabling the RCB", source, StringComparison.Ordinal); + Assert.Contains("Write RptEna=true, then request GI=true", source, StringComparison.Ordinal); + Assert.DoesNotContain("AllowDynamicDataSetWrites = true", source, StringComparison.Ordinal); + Assert.DoesNotContain("PollingPointKeys = points.Select", source, StringComparison.Ordinal); + } + [Fact] public void StaticDataSetRegressionGuard_PreservesFa16ReportOnlyDirection() { @@ -103,8 +147,6 @@ public void SharedSclFat_IsConsumerOfExistingAcquisitionSession_NotASecondMonito Assert.Contains("if (!reuseSharedSclAcquisition)", source, StringComparison.Ordinal); Assert.Contains("Compatibility path for non-shared/legacy FAT only", source, StringComparison.Ordinal); - // The legacy helper may remain for workbook-only FAT, but the shared SCL branch - // must be structurally separate and must start through the normal Engineering owner. var sharedBranchStart = source.IndexOf("if (reuseSharedSclAcquisition)", StringComparison.Ordinal); var legacyBranchStart = source.IndexOf("Compatibility path for non-shared/legacy FAT only", StringComparison.Ordinal); Assert.True(sharedBranchStart >= 0 && legacyBranchStart > sharedBranchStart); From 02c51cb3aa7a5900b134f6168b31f296c69caba6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 13:52:31 +0700 Subject: [PATCH 05/34] fix: union SCL and live static membership authority --- ...Iec61850StaticDataSetAuthoritySelection.cs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs index c97002b7..4140e552 100644 --- a/Services/Iec61850StaticDataSetAuthoritySelection.cs +++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs @@ -23,15 +23,28 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) { ArgumentNullException.ThrowIfNull(device); - var model = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; - if (model is null) + // Do not let a partial live model erase richer opened-SCL membership evidence. This + // is the same authority rule used for configured RCBs: design + live are additive, + // while the live MMS association is still required later to verify/arm acquisition. + var authorityModels = new[] + { + device.SclWorkspace?.DesignModel, + device.LiveDiscoveryModel + } + .Where(model => model is not null) + .Cast() + .Distinct() + .ToArray(); + if (authorityModels.Length == 0) return new HashSet(ReferenceEqualityComparer.Instance); var reportBackedDataSets = BuildReportBackedDataSetReferences(device); if (reportBackedDataSets.Count == 0) return new HashSet(ReferenceEqualityComparer.Instance); - var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model); + var mandatory = authorityModels + .SelectMany(Iec61850DataSetSignalInventoryProjection.GetMandatorySignals) + .ToArray(); var selected = new HashSet(ReferenceEqualityComparer.Instance); var signals = device.Signals.ToArray(); From f9633fbf569941efe2b0214be466a613e69ae501 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 13:55:45 +0700 Subject: [PATCH 06/34] fix: make static authority projection compile explicitly --- Services/Iec61850StaticDataSetAuthoritySelection.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs index 4140e552..363fc82b 100644 --- a/Services/Iec61850StaticDataSetAuthoritySelection.cs +++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs @@ -43,7 +43,7 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) return new HashSet(ReferenceEqualityComparer.Instance); var mandatory = authorityModels - .SelectMany(Iec61850DataSetSignalInventoryProjection.GetMandatorySignals) + .SelectMany(model => Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model)) .ToArray(); var selected = new HashSet(ReferenceEqualityComparer.Instance); var signals = device.Signals.ToArray(); From 3e1141eab6e34bef6308134fa0ca833517f53322 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 13:56:24 +0700 Subject: [PATCH 07/34] fix: keep opened SCL DataSet inventory authoritative across reconnect --- Services/Iec61850DataSetSignalInventoryService.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Services/Iec61850DataSetSignalInventoryService.cs b/Services/Iec61850DataSetSignalInventoryService.cs index a4476b3c..705079fa 100644 --- a/Services/Iec61850DataSetSignalInventoryService.cs +++ b/Services/Iec61850DataSetSignalInventoryService.cs @@ -26,11 +26,11 @@ public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals( { ArgumentNullException.ThrowIfNull(device); - // Signal Selection is also opened directly from an offline CID/SCD workspace. - // In that workflow LiveDiscoveryModel is intentionally null; the SCL design model - // is the authoritative inventory and must not be ignored. Prefer the live model - // only after a real association/discovery has produced one. - var authoritativeModel = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; + // When an SCL workspace is open, its configured DataSet membership remains the + // engineering authority across cached/fast reconnects. A partial live discovery + // model must not erase FCDA/FCD rows that were proven by the opened CID/SCD. For an + // online-only IED with no SCL workspace, live discovery remains the authority. + var authoritativeModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; if (authoritativeModel is null) return EmptyResult(); From 944e0e4069447f38c5d403aa141c308f2aa29d15 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:01:14 +0700 Subject: [PATCH 08/34] test: preserve SCL DataSet authority across fast reconnect --- .../OfflineDataSetSignalSelectionRegressionTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs index b9d397a6..03be0935 100644 --- a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs +++ b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs @@ -6,14 +6,18 @@ namespace ARSAS.Tests; public sealed class OfflineDataSetSignalSelectionRegressionTests { [Fact] - public void DeviceInventoryMerge_FallsBackToOfflineSclDesignModel() + public void DeviceInventoryMerge_PreservesOpenedSclAuthorityAcrossFastReconnect() { var source = File.ReadAllText(FindRepoFile("Services/Iec61850DataSetSignalInventoryService.cs")); Assert.Contains( - "device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel", + "device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", source, StringComparison.Ordinal); + Assert.Contains( + "partial live discovery", + source, + StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain( "if (device.LiveDiscoveryModel is null)\n return EmptyResult();", source, From 7e1faac0197a7d3e2ce8647744f1179ff2a4f61f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:01:32 +0700 Subject: [PATCH 09/34] test: update deterministic static RCB authority guards --- .../DeterministicStaticReportPathRegressionTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs index af8ab324..01c2e359 100644 --- a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs +++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs @@ -8,7 +8,8 @@ public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling() var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); Assert.Contains("Deterministic Static DataSet configured-RCB path", source, StringComparison.Ordinal); - Assert.Contains("model.ReportControls", source, StringComparison.Ordinal); + Assert.Contains("configurationModels", source, StringComparison.Ordinal); + Assert.Contains("configurationModel.ReportControls", source, StringComparison.Ordinal); Assert.Contains("discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal); Assert.Contains("GetDataSetDirectoriesAsync", source, StringComparison.Ordinal); Assert.Contains("MmsReportSubscriptionPlanStatus.ReadyRequiresWrite", source, StringComparison.Ordinal); @@ -24,17 +25,19 @@ public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling() } [Fact] - public void StaticPath_RequiresExactConfiguredRcbAndOrderedLiveDataSetDirectory() + public void StaticPath_RequiresConfiguredRcbFamilyAndOrderedLiveDataSetDirectory() { var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); Assert.Contains("SameStaticReference(report.DataSetReference, dataSetGroup.Key)", source, StringComparison.Ordinal); - Assert.Contains("SameStaticReference(candidate.Reference, configured.Reference)", source, StringComparison.Ordinal); + Assert.Contains("Iec61850StaticRcbReferenceMatcher.MatchRank(configured.Reference, candidate.Reference)", source, StringComparison.Ordinal); + Assert.Contains("arbitrary same-DataSet", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("live DataSet directory could not prove an ordered non-empty member list", source, StringComparison.Ordinal); Assert.Contains("directory.Members", source, StringComparison.Ordinal); Assert.Contains("No MMS process polling was substituted", source, StringComparison.Ordinal); Assert.Contains("SCL binds", source, StringComparison.Ordinal); Assert.Contains("live DatSet reports", source, StringComparison.Ordinal); + Assert.Contains("ReportControlReference = concreteReportReference", source, StringComparison.Ordinal); } [Fact] From 29fee6f7087ecaf39c71a984263d256026480b15 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:47:09 +0700 Subject: [PATCH 10/34] fix(static-dataset): pin receiver-first ARIEC report activation --- engines/ARIEC61850.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 5d8014d0..7a514e3b 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "69bfe70e2c779c7e8268af087bd1a3a38986c0fc", - "sourcePullRequest": 111, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling." + "commit": "4e19516b9ec80ba7fb6d3573014a2399a1188451", + "sourcePullRequest": 112, + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling. PR #112 closes the StaticDataSet startup race: the persistent InformationReport receiver is registered before RptEna=true and GI=true can trigger a fast relay report; failed startup keeps the receiver registered while best-effort disabling RptEna and releasing URCB reservation before unregistering, and this receiver-first static path never defines, deletes, or rebinds a DataSet." } From 689925b4e23c9dd08e0e6331ef9cf75bb074afdb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:47:38 +0700 Subject: [PATCH 11/34] test(static-dataset): guard receiver-first engine pin --- .../OfflineDataSetSignalSelectionRegressionTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs index 03be0935..ae5c0281 100644 --- a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs +++ b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs @@ -82,7 +82,7 @@ public void EngineLock_PreservesReportProjectionAndP62BHistoryAcrossLaterEngineP Assert.Equal("masarray/ARIEC61850", root.GetProperty("repository").GetString()); Assert.Equal("main", root.GetProperty("ref").GetString()); Assert.Matches("^[0-9a-f]{40}$", root.GetProperty("commit").GetString() ?? string.Empty); - Assert.True(root.GetProperty("sourcePullRequest").GetInt32() >= 89); + Assert.True(root.GetProperty("sourcePullRequest").GetInt32() >= 112); Assert.Contains("one descriptor per static DataSet member", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("generic Boolean status structures", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("DataRef-enabled InformationReport ordering", source, StringComparison.OrdinalIgnoreCase); @@ -104,6 +104,10 @@ public void EngineLock_PreservesReportProjectionAndP62BHistoryAcrossLaterEngineP 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 #112", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("receiver is registered before RptEna=true and GI=true", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("before unregistering", source, StringComparison.OrdinalIgnoreCase); + Assert.Contains("never defines, deletes, or rebinds a DataSet", source, StringComparison.OrdinalIgnoreCase); } [Fact] From 8e620be9e0d0e6d44712f6dcd4b49360c6b494c8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:52:47 +0700 Subject: [PATCH 12/34] fix(static-dataset): choose non-occupied indexed RCB instance --- ...veIec61850Client.StaticDataSetReporting.cs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs index 8523f261..55955dae 100644 --- a/Services/NativeIec61850Client.StaticDataSetReporting.cs +++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs @@ -125,7 +125,7 @@ public async Task BuildStaticDataSetReportPlan // remains rank 0. The only fallback accepted here is a decimal indexed instance // of that same literal RCB family and the same DataSet; arbitrary same-DataSet // RCB substitution is forbidden. - var liveCandidates = discovery.ReportInventory.ReportControls + var matchedLiveCandidates = discovery.ReportInventory.ReportControls .Select(candidate => new { Candidate = candidate, @@ -139,8 +139,32 @@ public async Task BuildStaticDataSetReportPlan .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); + // An indexed family can expose several concrete RCBs. Literal instance order is + // not an activation policy: Buffer01 may already be enabled/reserved while + // Buffer02 is the usable instance. Reject only explicit occupancy; unknown state + // remains eligible because some relays omit reservation metadata. Among equally + // matched candidates prefer an explicitly disabled RCB, then stable literal order. + var liveCandidates = matchedLiveCandidates + .Where(item => !ArMms.MmsReportSubscriptionPlanner.IsExplicitlyEnabled(item.Candidate)) + .Where(item => !ArMms.MmsReportSubscriptionPlanner.IsReservedByOtherClient(item.Candidate)) + .OrderBy(item => item.Rank) + .ThenByDescending(item => ArMms.MmsReportSubscriptionPlanner.IsExplicitlyDisabled(item.Candidate)) + .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (liveCandidates.Length == 0) { + if (matchedLiveCandidates.Length > 0) + { + var occupied = string.Join( + ", ", + matchedLiveCandidates.Take(8).Select(item => + $"{item.Candidate.Reference}[RptEna={item.Candidate.EnabledState}, Resv={item.Candidate.ReservationState}, ResvTms={item.Candidate.ReservationTimeSeconds}, Owner={item.Candidate.Owner}]")); + warnings.Add( + $"{configured.Reference}: exact/indexed-family RCB objects were found, but every concrete instance was explicitly enabled or reserved ({occupied}). Static mode will not steal an occupied RCB and did not poll process values."); + continue; + } + var sameDataSet = discovery.ReportInventory.ReportControls .Where(candidate => !string.IsNullOrWhiteSpace(candidate.DataSetReference) && @@ -163,7 +187,7 @@ public async Task BuildStaticDataSetReportPlan if (bestCandidates.Length > 1) { warnings.Add( - $"{configured.Reference}: configured indexed RCB family matched {bestCandidates.Length} concrete live instance(s); selected {bestCandidates[0].Candidate.Reference} by literal instance order. No unrelated RCB was substituted."); + $"{configured.Reference}: configured indexed RCB family matched {bestCandidates.Length} non-occupied concrete live instance(s); selected {bestCandidates[0].Candidate.Reference} by explicit-disabled preference then literal instance order. No unrelated RCB was substituted."); } var liveSource = bestCandidates[0].Candidate; From 163ae04d745539696eea1c5e397d05a1edf295b4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:52:58 +0700 Subject: [PATCH 13/34] fix(static): make SCL report controls authoritative --- ...Iec61850StaticDataSetAuthoritySelection.cs | 46 ++++++++----------- 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs index 363fc82b..5296d816 100644 --- a/Services/Iec61850StaticDataSetAuthoritySelection.cs +++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs @@ -7,10 +7,11 @@ namespace ArIED61850Tester.Services; /// Builds the exact Static DataSet selection used by the report-only workflow. /// /// Static report-only mode is RCB-backed by definition. A static DataSet that is not -/// referenced by any configured BRCB/URCB is valid engineering inventory, but it is not -/// a live acquisition source and therefore must not inflate the monitor with permanently -/// unavailable rows. Selection is limited to exact DataSet memberships referenced by a -/// configured ReportControl in either the opened SCL design model or fresh live discovery. +/// referenced by an authoritative configured BRCB/URCB is valid engineering inventory, +/// but it is not a live acquisition source and therefore must not inflate the monitor with +/// permanently unavailable rows. When an SCL workspace is open, its ReportControl bindings +/// are the configuration authority; live discovery is verification only. For online-only +/// operation with no SCL workspace, live discovery becomes the configuration authority. /// /// A DataSetReference on a browsed/runtime alias is also not sufficient authority: several /// aliases can point at the same static FCDA/FCD member (for example cVal/instCVal or @@ -23,36 +24,26 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) { ArgumentNullException.ThrowIfNull(device); - // Do not let a partial live model erase richer opened-SCL membership evidence. This - // is the same authority rule used for configured RCBs: design + live are additive, - // while the live MMS association is still required later to verify/arm acquisition. - var authorityModels = new[] - { - device.SclWorkspace?.DesignModel, - device.LiveDiscoveryModel - } - .Where(model => model is not null) - .Cast() - .Distinct() - .ToArray(); - if (authorityModels.Length == 0) + // Opened SCL is the engineering authority. A partial or richer live model may verify + // the configuration later, but it must not introduce extra static memberships that + // were never configured in the opened CID/SCD. Online-only mode falls back to live. + var authorityModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; + if (authorityModel is null) return new HashSet(ReferenceEqualityComparer.Instance); var reportBackedDataSets = BuildReportBackedDataSetReferences(device); if (reportBackedDataSets.Count == 0) return new HashSet(ReferenceEqualityComparer.Instance); - var mandatory = authorityModels - .SelectMany(model => Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model)) - .ToArray(); + var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(authorityModel); var selected = new HashSet(ReferenceEqualityComparer.Instance); var signals = device.Signals.ToArray(); foreach (var descriptor in mandatory) { // A descriptor may carry more than one membership. Do not arbitrarily take the - // first DataSet: choose only literal memberships that are backed by configured - // report-control authority. + // first DataSet: choose only literal memberships that are backed by authoritative + // report-control configuration. var memberships = descriptor.DataSetMemberships .Where(item => reportBackedDataSets.Contains(NormalizeLiteral(item.DataSetReference))) .OrderBy(item => item.DataSetReference, StringComparer.OrdinalIgnoreCase) @@ -106,18 +97,17 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) } /// - /// Returns the literal DataSet references that have configured report-control authority. - /// The union is deliberate: fast SCL reconnect can have a richer design model than the - /// partial live model, while a full discovery can reveal additional valid live RCB - /// bindings. Neither source is allowed to erase the other's exact configured evidence. + /// Returns the literal DataSet references that have authoritative ReportControl backing. + /// Opened SCL configuration wins absolutely over extra live ReportControls. Live discovery + /// is used as configuration authority only when no SCL design model is open. /// public static IReadOnlySet BuildReportBackedDataSetReferences(Iec61850MonitorDevice device) { ArgumentNullException.ThrowIfNull(device); var result = new HashSet(StringComparer.OrdinalIgnoreCase); - AddReportBackedDataSets(device.SclWorkspace?.DesignModel, result); - AddReportBackedDataSets(device.LiveDiscoveryModel, result); + var configurationModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; + AddReportBackedDataSets(configurationModel, result); return result; } From 2831ddb018e92cbfa49d208bb9a648cef07915ea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:53:11 +0700 Subject: [PATCH 14/34] test(static-dataset): guard non-occupied indexed RCB selection --- ...DeterministicStaticReportPathRegressionTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs index 01c2e359..3fe8d394 100644 --- a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs +++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs @@ -40,6 +40,20 @@ public void StaticPath_RequiresConfiguredRcbFamilyAndOrderedLiveDataSetDirectory Assert.Contains("ReportControlReference = concreteReportReference", source, StringComparison.Ordinal); } + [Fact] + public void IndexedRcbFamily_PrefersNonOccupiedConcreteInstance() + { + var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); + + Assert.Contains("matchedLiveCandidates", source, StringComparison.Ordinal); + Assert.Contains("MmsReportSubscriptionPlanner.IsExplicitlyEnabled(item.Candidate)", source, StringComparison.Ordinal); + Assert.Contains("MmsReportSubscriptionPlanner.IsReservedByOtherClient(item.Candidate)", source, StringComparison.Ordinal); + Assert.Contains("MmsReportSubscriptionPlanner.IsExplicitlyDisabled(item.Candidate)", source, StringComparison.Ordinal); + Assert.Contains("every concrete instance was explicitly enabled or reserved", source, StringComparison.Ordinal); + Assert.Contains("Static mode will not steal an occupied RCB", source, StringComparison.Ordinal); + Assert.Contains("explicit-disabled preference then literal instance order", source, StringComparison.Ordinal); + } + [Fact] public void HybridEntryPoints_RouteStaticModeToDeterministicPath() { From 42477c08564df6cd739ed4773528619b3421a1f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:53:32 +0700 Subject: [PATCH 15/34] chore(static-dataset): keep physical candidate on proven PR111 engine --- engines/ARIEC61850.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 7a514e3b..5d8014d0 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "4e19516b9ec80ba7fb6d3573014a2399a1188451", - "sourcePullRequest": 112, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling. PR #112 closes the StaticDataSet startup race: the persistent InformationReport receiver is registered before RptEna=true and GI=true can trigger a fast relay report; failed startup keeps the receiver registered while best-effort disabling RptEna and releasing URCB reservation before unregistering, and this receiver-first static path never defines, deletes, or rebinds a DataSet." + "commit": "69bfe70e2c779c7e8268af087bd1a3a38986c0fc", + "sourcePullRequest": 111, + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling." } From 68cfc8dda9f6f6e9c14353a7e26c51b54c421b14 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:54:01 +0700 Subject: [PATCH 16/34] test(static-dataset): keep engine lineage guard on PR111 candidate --- .../OfflineDataSetSignalSelectionRegressionTests.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs index ae5c0281..03be0935 100644 --- a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs +++ b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs @@ -82,7 +82,7 @@ public void EngineLock_PreservesReportProjectionAndP62BHistoryAcrossLaterEngineP Assert.Equal("masarray/ARIEC61850", root.GetProperty("repository").GetString()); Assert.Equal("main", root.GetProperty("ref").GetString()); Assert.Matches("^[0-9a-f]{40}$", root.GetProperty("commit").GetString() ?? string.Empty); - Assert.True(root.GetProperty("sourcePullRequest").GetInt32() >= 112); + Assert.True(root.GetProperty("sourcePullRequest").GetInt32() >= 89); Assert.Contains("one descriptor per static DataSet member", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("generic Boolean status structures", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("DataRef-enabled InformationReport ordering", source, StringComparison.OrdinalIgnoreCase); @@ -104,10 +104,6 @@ public void EngineLock_PreservesReportProjectionAndP62BHistoryAcrossLaterEngineP 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 #112", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("receiver is registered before RptEna=true and GI=true", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("before unregistering", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("never defines, deletes, or rebinds a DataSet", source, StringComparison.OrdinalIgnoreCase); } [Fact] From 5bfddf9500090028f4657f9457d98281f87d603e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:55:24 +0700 Subject: [PATCH 17/34] fix(static): keep SCL RCB authority across all candidates --- ...veIec61850Client.StaticDataSetReporting.cs | 121 +++++++++--------- 1 file changed, 61 insertions(+), 60 deletions(-) diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs index 55955dae..23052403 100644 --- a/Services/NativeIec61850Client.StaticDataSetReporting.cs +++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs @@ -32,7 +32,9 @@ public async Task BuildStaticDataSetReportPlan _deterministicStaticSubscriptions.Clear(); ResetSemanticReportProjectionContext(); - var projectionModel = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; + // Opened SCL is the semantic and configuration authority. A live model is authoritative + // only for online-only operation where no CID/SCD workspace is open. + var projectionModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; if (projectionModel is null) { return StaticPlanningUnavailable( @@ -49,25 +51,20 @@ public async Task BuildStaticDataSetReportPlan SetSemanticReportProjectionAuthority(projectionModel); - // Keep SCL design configuration authoritative even when a partial live model exists. - // Fast/cached reconnects can populate LiveDiscoveryModel without reproducing every - // configured ReportControl. Selecting `LiveDiscoveryModel ?? DesignModel` for RCB - // configuration silently erased valid SCL evidence (field symptom: SCL saw Buffer02 - // for Digital, while runtime planning reported staticBRCB=0). The union below keeps - // exact design authority and augments it with any report controls learned live. - var configurationModels = new[] - { - device.SclWorkspace?.DesignModel, - device.LiveDiscoveryModel - } - .Where(model => model is not null) - .Cast() - .ToArray(); + // P0 authority rule: when an SCL workspace exists, only ReportControls from that + // design model may authorize static acquisition. Fresh live discovery is verification + // and concrete-instance evidence; it must never introduce a peer RCB that can displace + // an explicit SCL binding such as Digital -> Buffer02. + var configurationModel = projectionModel; + var configurationAuthorityLabel = device.SclWorkspace?.DesignModel is not null + ? "opened SCL design model" + : "live discovery model (online-only)"; // Fresh report discovery is verification, not permission policy. In particular we // deliberately do NOT classify a configured BRCB through the Hybrid availability // confidence gate. Some perfectly usable servers expose RptEna and DatSet but omit // enough reservation metadata for that adaptive gate to call the RCB 'Available'. + // Explicit enabled/reserved evidence is still used to avoid stealing an occupied RCB. var discovery = await EnsureDiscoveryForReportingAsync(cancellationToken).ConfigureAwait(false); if (discovery is null) { @@ -93,8 +90,8 @@ public async Task BuildStaticDataSetReportPlan { cancellationToken.ThrowIfCancellationRequested(); - var configuredReports = configurationModels - .SelectMany(configurationModel => configurationModel.ReportControls) + var dataSetReference = dataSetGroup.First().DataSetReference.Trim(); + var configuredReports = configurationModel.ReportControls .Where(report => SameStaticReference(report.DataSetReference, dataSetGroup.Key)) .GroupBy( report => $"{NormalizeStaticReference(report.Reference)}|{NormalizeStaticReference(report.DataSetReference)}", @@ -107,35 +104,32 @@ public async Task BuildStaticDataSetReportPlan if (configuredReports.Length == 0) { warnings.Add( - $"{dataSetGroup.First().DataSetReference}: no configured BRCB/URCB in the SCL/live model union; {dataSetGroup.Count()} selected point(s) remain explicitly unavailable. No MMS process polling was substituted."); + $"{dataSetReference}: no configured BRCB/URCB in the authoritative {configurationAuthorityLabel}; {dataSetGroup.Count()} selected point(s) remain explicitly unavailable. No MMS process polling was substituted."); continue; } - if (configuredReports.Length > 1) - { - warnings.Add( - $"{dataSetGroup.First().DataSetReference}: {configuredReports.Length} configured RCBs reference this DataSet; deterministic Static mode selected {configuredReports[0].Reference} (BRCB preferred, then literal reference order)." ); - } - - var configured = configuredReports[0]; - var dataSetReference = dataSetGroup.First().DataSetReference.Trim(); - - // SCL can represent an indexed ReportControl family while the MMS server exposes - // concrete instances (for example Buffer -> Buffer01/Buffer02). Exact identity - // remains rank 0. The only fallback accepted here is a decimal indexed instance - // of that same literal RCB family and the same DataSet; arbitrary same-DataSet - // RCB substitution is forbidden. - var matchedLiveCandidates = discovery.ReportInventory.ReportControls - .Select(candidate => new - { - Candidate = candidate, - Rank = Iec61850StaticRcbReferenceMatcher.MatchRank(configured.Reference, candidate.Reference) - }) - .Where(item => item.Rank != int.MaxValue) - .Where(item => - string.IsNullOrWhiteSpace(item.Candidate.DataSetReference) || - SameStaticReference(item.Candidate.DataSetReference, dataSetReference)) + // Evaluate every authoritative configured RCB. This is intentionally not + // configuredReports[0]: if several SCL ReportControls legitimately reference the + // same DataSet, one missing/occupied RCB must not hide another configured option. + // A configured family may resolve only to decimal indexed instances of that same + // literal family; arbitrary same-DataSet substitution remains forbidden. + var matchedLiveCandidates = configuredReports + .SelectMany(configured => discovery.ReportInventory.ReportControls + .Select(candidate => new + { + Configured = configured, + Candidate = candidate, + Rank = Iec61850StaticRcbReferenceMatcher.MatchRank( + configured.Reference, + candidate.Reference) + }) + .Where(item => item.Rank != int.MaxValue) + .Where(item => + string.IsNullOrWhiteSpace(item.Candidate.DataSetReference) || + SameStaticReference(item.Candidate.DataSetReference, dataSetReference))) .OrderBy(item => item.Rank) + .ThenByDescending(item => item.Configured.Buffered) + .ThenBy(item => item.Configured.Reference, StringComparer.OrdinalIgnoreCase) .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -143,12 +137,15 @@ public async Task BuildStaticDataSetReportPlan // not an activation policy: Buffer01 may already be enabled/reserved while // Buffer02 is the usable instance. Reject only explicit occupancy; unknown state // remains eligible because some relays omit reservation metadata. Among equally - // matched candidates prefer an explicitly disabled RCB, then stable literal order. + // matched candidates prefer exact identity, then BRCB, explicitly disabled state, + // and finally stable literal order. var liveCandidates = matchedLiveCandidates .Where(item => !ArMms.MmsReportSubscriptionPlanner.IsExplicitlyEnabled(item.Candidate)) .Where(item => !ArMms.MmsReportSubscriptionPlanner.IsReservedByOtherClient(item.Candidate)) .OrderBy(item => item.Rank) + .ThenByDescending(item => item.Configured.Buffered) .ThenByDescending(item => ArMms.MmsReportSubscriptionPlanner.IsExplicitlyDisabled(item.Candidate)) + .ThenBy(item => item.Configured.Reference, StringComparer.OrdinalIgnoreCase) .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -159,12 +156,16 @@ public async Task BuildStaticDataSetReportPlan var occupied = string.Join( ", ", matchedLiveCandidates.Take(8).Select(item => - $"{item.Candidate.Reference}[RptEna={item.Candidate.EnabledState}, Resv={item.Candidate.ReservationState}, ResvTms={item.Candidate.ReservationTimeSeconds}, Owner={item.Candidate.Owner}]")); + $"{item.Configured.Reference}->{item.Candidate.Reference}[RptEna={item.Candidate.EnabledState}, Resv={item.Candidate.ReservationState}, ResvTms={item.Candidate.ReservationTimeSeconds}, Owner={item.Candidate.Owner}]")); warnings.Add( - $"{configured.Reference}: exact/indexed-family RCB objects were found, but every concrete instance was explicitly enabled or reserved ({occupied}). Static mode will not steal an occupied RCB and did not poll process values."); + $"{dataSetReference}: exact/indexed-family RCB objects were found for authoritative configuration, but every concrete instance was explicitly enabled or reserved ({occupied}). Static mode will not steal an occupied RCB and did not poll process values."); continue; } + var configuredNames = string.Join(", ", configuredReports + .Select(report => report.Reference) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .Take(8)); var sameDataSet = discovery.ReportInventory.ReportControls .Where(candidate => !string.IsNullOrWhiteSpace(candidate.DataSetReference) && @@ -178,32 +179,32 @@ public async Task BuildStaticDataSetReportPlan ? "none" : string.Join(", ", sameDataSet); warnings.Add( - $"{configured.Reference}: configured RCB was proven by SCL but live MMS discovery exposed no exact/indexed-family instance. Same-DataSet live RCBs: {observed}. Static mode refused arbitrary substitution and did not poll process values."); + $"{dataSetReference}: authoritative configured RCB(s) [{configuredNames}] had no exact/indexed-family live instance. Same-DataSet live RCBs: {observed}. Static mode refused arbitrary substitution and did not poll process values."); continue; } - var bestRank = liveCandidates[0].Rank; - var bestCandidates = liveCandidates.Where(item => item.Rank == bestRank).ToArray(); - if (bestCandidates.Length > 1) + var selected = liveCandidates[0]; + var configured = selected.Configured; + var liveSource = selected.Candidate; + var liveRcb = CloneReportControlForPlanning(liveSource); + + if (configuredReports.Length > 1 || liveCandidates.Length > 1) { warnings.Add( - $"{configured.Reference}: configured indexed RCB family matched {bestCandidates.Length} non-occupied concrete live instance(s); selected {bestCandidates[0].Candidate.Reference} by explicit-disabled preference then literal instance order. No unrelated RCB was substituted."); + $"{dataSetReference}: evaluated {configuredReports.Length} authoritative configured RCB(s) and {liveCandidates.Length} non-occupied exact/indexed live match(es); selected {configured.Reference} -> {liveSource.Reference}. {configurationAuthorityLabel} remained authoritative."); } - var liveSource = bestCandidates[0].Candidate; - var liveRcb = CloneReportControlForPlanning(liveSource); - if (!string.IsNullOrWhiteSpace(liveRcb.DataSetReference) && !SameStaticReference(liveRcb.DataSetReference, dataSetReference)) { warnings.Add( - $"{configured.Reference}: SCL binds {dataSetReference}, but live DatSet reports {liveRcb.DataSetReference}. Static mode refused the mismatch instead of guessing or polling."); + $"{configured.Reference}: authoritative configuration binds {dataSetReference}, but live DatSet reports {liveRcb.DataSetReference}. Static mode refused the mismatch instead of guessing or polling."); continue; } - // Missing live DatSet text is not treated as a reason to discard correct SCL - // configuration. The exact DataSet directory below is still required and is the - // ordered mapping authority for InformationReport values. + // Missing live DatSet text is not treated as a reason to discard correct + // authoritative configuration. The exact DataSet directory below is still required + // and is the ordered mapping authority for InformationReport values. if (string.IsNullOrWhiteSpace(liveRcb.DataSetReference)) liveRcb.DataSetReference = dataSetReference; @@ -260,12 +261,12 @@ public async Task BuildStaticDataSetReportPlan if (!Iec61850StaticRcbReferenceMatcher.IsExact(configured.Reference, concreteReportReference)) { subscriptionWarnings.Add( - $"SCL ReportControl family {configured.Reference} resolved to concrete live indexed instance {concreteReportReference}."); + $"Configured ReportControl family {configured.Reference} resolved to concrete live indexed instance {concreteReportReference}."); } if (string.IsNullOrWhiteSpace(liveSource.DataSetReference)) { subscriptionWarnings.Add( - "Live RCB DatSet text was not returned; exact SCL RCB->DataSet configuration plus the successfully read live DataSet directory are the deterministic authority."); + "Live RCB DatSet text was not returned; exact authoritative RCB->DataSet configuration plus the successfully read live DataSet directory are the deterministic authority."); } var subscription = new ArMms.MmsReportSubscriptionPlan @@ -278,7 +279,7 @@ public async Task BuildStaticDataSetReportPlan DynamicPoints = Array.Empty(), Steps = new[] { - $"Verify configured RCB {configured.Reference} as live object {concreteReportReference}.", + $"Verify authoritative configured RCB {configured.Reference} as live object {concreteReportReference}.", $"Use exact ordered live DataSet directory {dataSetReference} ({directory.Members.Count} members).", "Install InformationReport receiver before enabling the RCB.", "Write RptEna=true, then request GI=true.", From e0a2edf54cdfb3f2024a54f214e17a3b0c981965 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:55:59 +0700 Subject: [PATCH 18/34] test(static): guard SCL authority and candidate evaluation --- ...ministicStaticReportPathRegressionTests.cs | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs index 3fe8d394..dd51cfc0 100644 --- a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs +++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs @@ -8,7 +8,7 @@ public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling() var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); Assert.Contains("Deterministic Static DataSet configured-RCB path", source, StringComparison.Ordinal); - Assert.Contains("configurationModels", source, StringComparison.Ordinal); + Assert.Contains("var configurationModel = projectionModel", source, StringComparison.Ordinal); Assert.Contains("configurationModel.ReportControls", source, StringComparison.Ordinal); Assert.Contains("discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal); Assert.Contains("GetDataSetDirectoriesAsync", source, StringComparison.Ordinal); @@ -18,6 +18,7 @@ public void StaticPath_BypassesAdaptiveHybridPlannerAndPolling() Assert.Contains("PollingPointKeys = Array.Empty()", source, StringComparison.Ordinal); Assert.Contains("PollingFallbackSignalCount = 0", source, StringComparison.Ordinal); + Assert.DoesNotContain("configurationModels", source, StringComparison.Ordinal); Assert.DoesNotContain("MmsCapabilityAwareHybridReportAcquisitionPlanner", source, StringComparison.Ordinal); Assert.DoesNotContain("BuildDynamicPlan", source, StringComparison.Ordinal); Assert.DoesNotContain("DefineNamedVariableList", source, StringComparison.Ordinal); @@ -30,16 +31,31 @@ public void StaticPath_RequiresConfiguredRcbFamilyAndOrderedLiveDataSetDirectory var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); Assert.Contains("SameStaticReference(report.DataSetReference, dataSetGroup.Key)", source, StringComparison.Ordinal); - Assert.Contains("Iec61850StaticRcbReferenceMatcher.MatchRank(configured.Reference, candidate.Reference)", source, StringComparison.Ordinal); + Assert.Contains("Iec61850StaticRcbReferenceMatcher.MatchRank", source, StringComparison.Ordinal); Assert.Contains("arbitrary same-DataSet", source, StringComparison.OrdinalIgnoreCase); Assert.Contains("live DataSet directory could not prove an ordered non-empty member list", source, StringComparison.Ordinal); Assert.Contains("directory.Members", source, StringComparison.Ordinal); Assert.Contains("No MMS process polling was substituted", source, StringComparison.Ordinal); - Assert.Contains("SCL binds", source, StringComparison.Ordinal); + Assert.Contains("authoritative configuration binds", source, StringComparison.Ordinal); Assert.Contains("live DatSet reports", source, StringComparison.Ordinal); Assert.Contains("ReportControlReference = concreteReportReference", source, StringComparison.Ordinal); } + [Fact] + public void StaticPath_SclAuthorityCannotBeDisplacedByLivePeerRcb() + { + var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); + + Assert.Contains("device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", source, StringComparison.Ordinal); + Assert.Contains("only ReportControls from that", source, StringComparison.Ordinal); + Assert.Contains("design model may authorize static acquisition", source, StringComparison.Ordinal); + Assert.Contains("var configurationModel = projectionModel", source, StringComparison.Ordinal); + Assert.Contains("SelectMany(configured => discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal); + Assert.DoesNotContain("SelectMany(configurationModel => configurationModel.ReportControls)", source, StringComparison.Ordinal); + Assert.DoesNotContain("configuredReports[0]", source, StringComparison.Ordinal); + Assert.Contains("remained authoritative", source, StringComparison.Ordinal); + } + [Fact] public void IndexedRcbFamily_PrefersNonOccupiedConcreteInstance() { @@ -51,7 +67,7 @@ public void IndexedRcbFamily_PrefersNonOccupiedConcreteInstance() Assert.Contains("MmsReportSubscriptionPlanner.IsExplicitlyDisabled(item.Candidate)", source, StringComparison.Ordinal); Assert.Contains("every concrete instance was explicitly enabled or reserved", source, StringComparison.Ordinal); Assert.Contains("Static mode will not steal an occupied RCB", source, StringComparison.Ordinal); - Assert.Contains("explicit-disabled preference then literal instance order", source, StringComparison.Ordinal); + Assert.Contains("prefer exact identity, then BRCB, explicitly disabled state", source, StringComparison.Ordinal); } [Fact] From 1cfaa05ad8e45ea4862b76fbfb7c8b01f2678d9c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:56:31 +0700 Subject: [PATCH 19/34] test(static): enforce design-first RCB authority --- ...taticDataSetReportOnlyModeRegressionTests.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs index 138e3f78..07b0c330 100644 --- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs @@ -53,11 +53,14 @@ public void SharedSclStaticSelection_UsesOnlyExactRcbBackedAriecMembershipRows() Assert.DoesNotContain("UseStaticDataSetWithMmsFallback", source, StringComparison.Ordinal); Assert.DoesNotContain("fallback remains available", source, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model)", authority, StringComparison.Ordinal); + Assert.Contains("var authorityModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", authority, StringComparison.Ordinal); + Assert.Contains("Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(authorityModel)", authority, StringComparison.Ordinal); Assert.Contains("BuildReportBackedDataSetReferences(device)", authority, StringComparison.Ordinal); Assert.Contains("reportBackedDataSets.Contains", authority, StringComparison.Ordinal); - Assert.Contains("device.SclWorkspace?.DesignModel", authority, StringComparison.Ordinal); - Assert.Contains("device.LiveDiscoveryModel", authority, StringComparison.Ordinal); + Assert.Contains("var configurationModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", authority, StringComparison.Ordinal); + Assert.Contains("AddReportBackedDataSets(configurationModel, result)", authority, StringComparison.Ordinal); + Assert.DoesNotContain("AddReportBackedDataSets(device.SclWorkspace?.DesignModel, result)", authority, StringComparison.Ordinal); + Assert.DoesNotContain("AddReportBackedDataSets(device.LiveDiscoveryModel, result)", authority, StringComparison.Ordinal); Assert.Contains("LiteralEquals(signal.DataSetReference, membership.DataSetReference)", authority, StringComparison.Ordinal); Assert.Contains("LiteralEquals(signal.DisplayReference, memberReference)", authority, StringComparison.Ordinal); Assert.DoesNotContain("StartsWith(memberReference", authority, StringComparison.Ordinal); @@ -93,13 +96,15 @@ public void DeterministicStaticPlanner_PreservesSclConfigurationAndConcreteLiveR { var source = File.ReadAllText(FindRepoFile("Services/NativeIec61850Client.StaticDataSetReporting.cs")); - Assert.Contains("device.SclWorkspace?.DesignModel", source, StringComparison.Ordinal); - Assert.Contains("device.LiveDiscoveryModel", source, StringComparison.Ordinal); - Assert.Contains("configurationModels", source, StringComparison.Ordinal); + Assert.Contains("device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel", source, StringComparison.Ordinal); + Assert.Contains("var configurationModel = projectionModel", source, StringComparison.Ordinal); Assert.Contains("Iec61850StaticRcbReferenceMatcher.MatchRank", source, StringComparison.Ordinal); + Assert.Contains("SelectMany(configured => discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal); Assert.Contains("ReportControlReference = concreteReportReference", source, StringComparison.Ordinal); Assert.Contains("Install InformationReport receiver before enabling the RCB", source, StringComparison.Ordinal); Assert.Contains("Write RptEna=true, then request GI=true", source, StringComparison.Ordinal); + Assert.DoesNotContain("configurationModels", source, StringComparison.Ordinal); + Assert.DoesNotContain("configuredReports[0]", source, StringComparison.Ordinal); Assert.DoesNotContain("AllowDynamicDataSetWrites = true", source, StringComparison.Ordinal); Assert.DoesNotContain("PollingPointKeys = points.Select", source, StringComparison.Ordinal); } From c955d008d4a8dc34a0209ef15ba57fdbdd07f8c1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 14:59:29 +0700 Subject: [PATCH 20/34] fix(static): include owner evidence in RCB selection --- ...veIec61850Client.StaticDataSetReporting.cs | 71 +++++++++++++++---- 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs index 23052403..c2239e42 100644 --- a/Services/NativeIec61850Client.StaticDataSetReporting.cs +++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs @@ -61,10 +61,10 @@ public async Task BuildStaticDataSetReportPlan : "live discovery model (online-only)"; // Fresh report discovery is verification, not permission policy. In particular we - // deliberately do NOT classify a configured BRCB through the Hybrid availability - // confidence gate. Some perfectly usable servers expose RptEna and DatSet but omit - // enough reservation metadata for that adaptive gate to call the RCB 'Available'. - // Explicit enabled/reserved evidence is still used to avoid stealing an occupied RCB. + // deliberately do NOT require the adaptive Hybrid availability gate to classify a + // configured BRCB as Available. Some perfectly usable servers omit enough reservation + // metadata to remain Unknown. Explicit enabled/reserved/owner evidence is still used + // to avoid stealing an occupied RCB. var discovery = await EnsureDiscoveryForReportingAsync(cancellationToken).ConfigureAwait(false); if (discovery is null) { @@ -75,6 +75,11 @@ public async Task BuildStaticDataSetReportPlan : LastErrorMessage); } + var callerOwnedRcbReferences = _reportMonitorSessions.Values + .Select(session => NormalizeStaticReference(session.ReportControl.Reference)) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var reportPlans = new List(); var warnings = new List(); var coveredPointKeys = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -133,16 +138,37 @@ public async Task BuildStaticDataSetReportPlan .ThenBy(item => item.Candidate.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); + // Use ARIEC's operational evaluator so Owner is part of the same occupancy + // decision as RptEna/Resv/ResvTms. Directory evidence is intentionally omitted at + // this stage: Unknown is still eligible and the exact DataSet directory is verified + // before the plan is armed. If a currently active session already owns the RCB, + // preserve that fact as UsedByCaller instead of misclassifying it as foreign use. + var evaluatedLiveCandidates = matchedLiveCandidates + .Select(item => new + { + item.Configured, + item.Candidate, + item.Rank, + Availability = ArMms.MmsRcbAvailabilityEvaluator.Evaluate( + item.Candidate, + dataSetDirectory: null, + callerOwned: callerOwnedRcbReferences.Contains( + NormalizeStaticReference(item.Candidate.Reference))) + }) + .ToArray(); + // An indexed family can expose several concrete RCBs. Literal instance order is - // not an activation policy: Buffer01 may already be enabled/reserved while - // Buffer02 is the usable instance. Reject only explicit occupancy; unknown state - // remains eligible because some relays omit reservation metadata. Among equally - // matched candidates prefer exact identity, then BRCB, explicitly disabled state, - // and finally stable literal order. - var liveCandidates = matchedLiveCandidates + // not an activation policy: Buffer01 may already be enabled/reserved/owned while + // Buffer02 is the usable instance. Reject only explicit operational blockers; + // Unknown remains eligible because some relays omit reservation metadata. Among + // equally matched candidates prefer exact identity, caller-owned/known-safe state, + // BRCB, explicitly disabled state, and finally stable literal order. + var liveCandidates = evaluatedLiveCandidates + .Where(item => StaticRcbAvailabilityRank(item.Availability.Availability) != int.MaxValue) .Where(item => !ArMms.MmsReportSubscriptionPlanner.IsExplicitlyEnabled(item.Candidate)) .Where(item => !ArMms.MmsReportSubscriptionPlanner.IsReservedByOtherClient(item.Candidate)) .OrderBy(item => item.Rank) + .ThenBy(item => StaticRcbAvailabilityRank(item.Availability.Availability)) .ThenByDescending(item => item.Configured.Buffered) .ThenByDescending(item => ArMms.MmsReportSubscriptionPlanner.IsExplicitlyDisabled(item.Candidate)) .ThenBy(item => item.Configured.Reference, StringComparer.OrdinalIgnoreCase) @@ -151,14 +177,14 @@ public async Task BuildStaticDataSetReportPlan if (liveCandidates.Length == 0) { - if (matchedLiveCandidates.Length > 0) + if (evaluatedLiveCandidates.Length > 0) { var occupied = string.Join( ", ", - matchedLiveCandidates.Take(8).Select(item => - $"{item.Configured.Reference}->{item.Candidate.Reference}[RptEna={item.Candidate.EnabledState}, Resv={item.Candidate.ReservationState}, ResvTms={item.Candidate.ReservationTimeSeconds}, Owner={item.Candidate.Owner}]")); + evaluatedLiveCandidates.Take(8).Select(item => + $"{item.Configured.Reference}->{item.Candidate.Reference}[availability={item.Availability.Availability}, RptEna={item.Candidate.EnabledState}, Resv={item.Candidate.ReservationState}, ResvTms={item.Candidate.ReservationTimeSeconds}, Owner={item.Candidate.Owner}]")); warnings.Add( - $"{dataSetReference}: exact/indexed-family RCB objects were found for authoritative configuration, but every concrete instance was explicitly enabled or reserved ({occupied}). Static mode will not steal an occupied RCB and did not poll process values."); + $"{dataSetReference}: exact/indexed-family RCB objects were found for authoritative configuration, but every concrete instance was explicitly unavailable/in-use ({occupied}). Static mode will not steal an occupied RCB and did not poll process values."); continue; } @@ -186,12 +212,13 @@ public async Task BuildStaticDataSetReportPlan var selected = liveCandidates[0]; var configured = selected.Configured; var liveSource = selected.Candidate; + var liveAvailability = selected.Availability; var liveRcb = CloneReportControlForPlanning(liveSource); if (configuredReports.Length > 1 || liveCandidates.Length > 1) { warnings.Add( - $"{dataSetReference}: evaluated {configuredReports.Length} authoritative configured RCB(s) and {liveCandidates.Length} non-occupied exact/indexed live match(es); selected {configured.Reference} -> {liveSource.Reference}. {configurationAuthorityLabel} remained authoritative."); + $"{dataSetReference}: evaluated {configuredReports.Length} authoritative configured RCB(s) and {liveCandidates.Length} non-occupied exact/indexed live match(es); selected {configured.Reference} -> {liveSource.Reference} ({liveAvailability.Availability}). {configurationAuthorityLabel} remained authoritative."); } if (!string.IsNullOrWhiteSpace(liveRcb.DataSetReference) && @@ -263,6 +290,11 @@ public async Task BuildStaticDataSetReportPlan subscriptionWarnings.Add( $"Configured ReportControl family {configured.Reference} resolved to concrete live indexed instance {concreteReportReference}."); } + if (liveAvailability.Availability == ArMms.MmsRcbOperationalAvailability.Unknown) + { + subscriptionWarnings.Add( + $"Live RCB availability for {concreteReportReference} remains Unknown ({liveAvailability.Reason}). Static mode permits this because identity/configuration are authoritative and no explicit in-use evidence was observed."); + } if (string.IsNullOrWhiteSpace(liveSource.DataSetReference)) { subscriptionWarnings.Add( @@ -476,6 +508,15 @@ private static NativeHybridReportPlanningResult StaticPlanningUnavailable( UncoveredSignalCount = points.Count }; + private static int StaticRcbAvailabilityRank(ArMms.MmsRcbOperationalAvailability availability) + => availability switch + { + ArMms.MmsRcbOperationalAvailability.UsedByCaller => 0, + ArMms.MmsRcbOperationalAvailability.Available => 1, + ArMms.MmsRcbOperationalAvailability.Unknown => 2, + _ => int.MaxValue + }; + private static bool SameStaticReference(string? left, string? right) => string.Equals( NormalizeStaticReference(left), From 9fd5d84ecf1b689c391f7b9c4ccab43ae6c8dc09 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:00:30 +0700 Subject: [PATCH 21/34] test(static): cover owner-aware RCB occupancy --- .../DeterministicStaticReportPathRegressionTests.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs index dd51cfc0..c5e570f8 100644 --- a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs +++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs @@ -62,12 +62,17 @@ public void IndexedRcbFamily_PrefersNonOccupiedConcreteInstance() var source = Read("Services/NativeIec61850Client.StaticDataSetReporting.cs"); Assert.Contains("matchedLiveCandidates", source, StringComparison.Ordinal); + Assert.Contains("evaluatedLiveCandidates", source, StringComparison.Ordinal); + Assert.Contains("MmsRcbAvailabilityEvaluator.Evaluate", source, StringComparison.Ordinal); + Assert.Contains("callerOwnedRcbReferences", source, StringComparison.Ordinal); Assert.Contains("MmsReportSubscriptionPlanner.IsExplicitlyEnabled(item.Candidate)", source, StringComparison.Ordinal); Assert.Contains("MmsReportSubscriptionPlanner.IsReservedByOtherClient(item.Candidate)", source, StringComparison.Ordinal); Assert.Contains("MmsReportSubscriptionPlanner.IsExplicitlyDisabled(item.Candidate)", source, StringComparison.Ordinal); - Assert.Contains("every concrete instance was explicitly enabled or reserved", source, StringComparison.Ordinal); + Assert.Contains("StaticRcbAvailabilityRank", source, StringComparison.Ordinal); + Assert.Contains("Owner={item.Candidate.Owner}", source, StringComparison.Ordinal); + Assert.Contains("every concrete instance was explicitly unavailable/in-use", source, StringComparison.Ordinal); Assert.Contains("Static mode will not steal an occupied RCB", source, StringComparison.Ordinal); - Assert.Contains("prefer exact identity, then BRCB, explicitly disabled state", source, StringComparison.Ordinal); + Assert.Contains("prefer exact identity, caller-owned/known-safe state", source, StringComparison.Ordinal); } [Fact] From 3ce8b3af1ac4d87fe3439247860f42f66e88bf08 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:11:54 +0700 Subject: [PATCH 22/34] Fix static planner regression guard false positive --- .../ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs index c5e570f8..ac7637b0 100644 --- a/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs +++ b/tests/ARSAS.Tests/DeterministicStaticReportPathRegressionTests.cs @@ -52,7 +52,7 @@ public void StaticPath_SclAuthorityCannotBeDisplacedByLivePeerRcb() Assert.Contains("var configurationModel = projectionModel", source, StringComparison.Ordinal); Assert.Contains("SelectMany(configured => discovery.ReportInventory.ReportControls", source, StringComparison.Ordinal); Assert.DoesNotContain("SelectMany(configurationModel => configurationModel.ReportControls)", source, StringComparison.Ordinal); - Assert.DoesNotContain("configuredReports[0]", source, StringComparison.Ordinal); + Assert.DoesNotContain("var configured = configuredReports[0]", source, StringComparison.Ordinal); Assert.Contains("remained authoritative", source, StringComparison.Ordinal); } From 64587c3621c77893d4db1e5d1f081a6500dd9c37 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:12:32 +0700 Subject: [PATCH 23/34] Tighten static planner no-first-RCB regression guard --- tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs index 07b0c330..0e9e804b 100644 --- a/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs +++ b/tests/ARSAS.Tests/StaticDataSetReportOnlyModeRegressionTests.cs @@ -104,7 +104,7 @@ public void DeterministicStaticPlanner_PreservesSclConfigurationAndConcreteLiveR Assert.Contains("Install InformationReport receiver before enabling the RCB", source, StringComparison.Ordinal); Assert.Contains("Write RptEna=true, then request GI=true", source, StringComparison.Ordinal); Assert.DoesNotContain("configurationModels", source, StringComparison.Ordinal); - Assert.DoesNotContain("configuredReports[0]", source, StringComparison.Ordinal); + Assert.DoesNotContain("var configured = configuredReports[0]", source, StringComparison.Ordinal); Assert.DoesNotContain("AllowDynamicDataSetWrites = true", source, StringComparison.Ordinal); Assert.DoesNotContain("PollingPointKeys = points.Select", source, StringComparison.Ordinal); } From 00a5dc8ee4ae3114e3b4a287aff54eb2fa092915 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:13:44 +0700 Subject: [PATCH 24/34] Fix P0 regression guard comment --- .../NativeIec61850Client.StaticDataSetReporting.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Services/NativeIec61850Client.StaticDataSetReporting.cs b/Services/NativeIec61850Client.StaticDataSetReporting.cs index c2239e42..32d5173a 100644 --- a/Services/NativeIec61850Client.StaticDataSetReporting.cs +++ b/Services/NativeIec61850Client.StaticDataSetReporting.cs @@ -113,11 +113,11 @@ public async Task BuildStaticDataSetReportPlan continue; } - // Evaluate every authoritative configured RCB. This is intentionally not - // configuredReports[0]: if several SCL ReportControls legitimately reference the - // same DataSet, one missing/occupied RCB must not hide another configured option. - // A configured family may resolve only to decimal indexed instances of that same - // literal family; arbitrary same-DataSet substitution remains forbidden. + // Evaluate every authoritative configured RCB instead of selecting only the first. + // If several SCL ReportControls legitimately reference the same DataSet, one + // missing/occupied RCB must not hide another configured option. A configured family + // may resolve only to decimal indexed instances of that same literal family; + // arbitrary same-DataSet substitution remains forbidden. var matchedLiveCandidates = configuredReports .SelectMany(configured => discovery.ReportInventory.ReportControls .Select(candidate => new From be61b069d5bf261770bb13bbe54067a3e4f7b524 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:30:09 +0700 Subject: [PATCH 25/34] Pin ARSAS to PR111 semantic projection hardening --- engines/ARIEC61850.lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 5d8014d0..94c589a8 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "69bfe70e2c779c7e8268af087bd1a3a38986c0fc", + "commit": "0d7525bd330900917fb9f6d15a46059dc3d7a70a", "sourcePullRequest": 111, - "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is now attempted before generic structured-value heuristics so TotPF and similar members can publish their exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values so ARSAS can preserve relay-native quality/timestamp without MMS polling." + "purpose": "Pins the exact ARIEC61850 engine used by ARSAS while preserving the reviewed reporting/control ancestry. PR #76 preserves unresolved static DataSet members; PR #77 canonicalizes cross-logical-device SCL references; PR #78 keeps one descriptor per static DataSet member while separating the resolved runtime primary leaf from original FCDA/FCD identity; PR #79 projects generic Boolean status structures to scalar stVal while preserving quality/timestamp; PR #80 normalizes validated DataRef-enabled InformationReport ordering; PR #81 accepts valid zero OptFlds reports while quarantining unmapped canonical report metadata; PR #84 routes exact PrimaryValue residuals through dynamic reporting before MMS polling; PR #85 evaluates association capabilities before automatic dynamic mutation; PR #86 records dynamic-attempt failure/skip evidence and best-effort rollback. PR #87 restores baseline-safe static precedence. PR #88 adds a fail-closed single-member DefineNamedVariableList -> GetNamedVariableListAttributes -> DeleteNamedVariableList probation with exact invoke/request/response/routing/member/association/cleanup evidence. PR #89 quarantines automatic full dynamic DataSet activation because a successful one-member NVL probation does not guarantee association survival; it also preserves safe instMag/mag and instCVal/cVal projection while ambiguous structures remain raw. PR #90 / field-proven engine a18e550d07f7bbe4ff7753c180b02615075f6292 preserves G1/G1.1 Smart Control: signed primitive constraints, ordered SBO/SBOw-to-Operate wire evidence, StationControl origin compatibility, and explicit MMS Write DataAccessError including object-access-denied. G2 PR #91 adds qualification-only bounded multi-member DefineNamedVariableList/GetNamedVariableListAttributes/DeleteNamedVariableList evidence with exact ordered read-back, encoded request/PDU evidence and fail-closed cleanup; PR #92 adds the 1/4/8/16/32 qualification ladder, deterministic bisection and explicit EnvelopeQualified acceptance; PR #93 adds a default-disabled ExplicitCommissioning coordinator with hard attempt budget, exact-set failure localization and fresh-association stop semantics; PR #94 adds identity-bound qualification profiles and prevents ProductionEligible unless RCB activation, an actual correctly mapped InformationReport, and all G2.6 physical regression gates are proven. G2.4 engine PR #95 retains the commissioning-only transactional URCB TrgOps/OptFlds lease. P0 physically proved the corrected IEC 61850 MMS TrgOps reserved-bit mapping: bit 0 reserved, bits 1..5 dchg/qchg/dupd/integrity/GI, so dchg+GI encodes canonically as 0244; P0 also separates raw BER equality from IEC significant-bit equality and provides a one-URCB TrgOps-only micro-probe that never writes OptFlds, DatSet, Resv, RptEna, GI or any DataSet service. P1 adds a dedicated one-URCB OptFlds-only capture/write/readback/finally-restore micro-probe for reason-for-inclusion + data-set-name, canonical target 061800, using ten-bit significant-value comparison while never writing TrgOps, DatSet, Resv, RptEna, GI, Define/Delete DataSet, starting a report monitor, or changing profile state. The G2.4 Owner correction exposes the exact local TCP address of the active MMS association and fail-closed decodes a server RCB Owner as a 4-byte IPv4 or 16-byte IPv6 address; physical SIPROTEC Owner C0A851F0 decodes to 192.168.81.240 and may prove caller ownership only when it exactly matches the active local TCP endpoint. Owner mismatch or unsupported encoding remains a hard failure. Original RCB values remain captured for restore, raw BER evidence is retained, and Production automatic dynamic BRCB/URCB activation remains quarantined until a compatible ProductionEligible profile is consumed by a later G2 phase. FAT P5.3 engine PR #103 resolves intermediate structured static DataSet members such as MMXU A.phsA and PPV.phsAB only to typed descendants below the exact FCDA boundary, selects a unique semantic primary runtime leaf such as cVal.mag.f without crossing sibling phases, preserves original static membership identity, and leaves genuinely ambiguous structures unresolved rather than guessing. FAT P5.4 engine PR #106 adds fail-closed model-backed InformationReport projection for structured static DataSet members: an exact report member reference now resolves independently of sparse decoder-side report value position, while DataSet scope still prevents duplicate static memberships from collapsing; when a report omits the member reference, static DataSet index remains the unique fail-closed fallback. All schema-proven scalar descendants are fanned out without selecting a sibling phase, and schema mismatch preserves raw projection instead of guessing. ARSAS supplies the per-IED LiveDiscovery/SCL planning model at the report receive seam. PR #111 is a narrow continuation on the exact b9ee5fc ARSAS engine baseline: exact static DataSet/SCL semantic schema is attempted before generic structured-value heuristics so TotPF and similar members publish exact scalar leaves; generic projection remains the fail-closed fallback, and report q/t companions are ordered ahead of semantic scalar values. P1 hardening at 0d7525bd330900917fb9f6d15a46059dc3d7a70a also makes semantic expansion return the resolved authoritative member identity and replaces generic output by report-value position after semantic success, so an InformationReport that omits MemberReference but resolves uniquely through static DataSet index cannot leak unrooted projected-mx-pair leaves alongside exact semantic values." } From aee3c34df3ca3126f4294575b4282a39387ba427 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:32:47 +0700 Subject: [PATCH 26/34] Align TotPF regression with PR111 P1 engine pin --- tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs index 3594d1ac..a82711b9 100644 --- a/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs +++ b/tests/ARSAS.Tests/P0LifecycleAndTotPfRegressionTests.cs @@ -50,7 +50,10 @@ public void StaticReport_TotPf_UsesExactSemanticEngineAuthorityWithoutMmsFallbac var projection = Read("Services/StaticDataSetReportProjectionAccumulator.cs"); Assert.Contains("\"sourcePullRequest\": 111", engineLock, StringComparison.Ordinal); - Assert.Contains("69bfe70e2c779c7e8268af087bd1a3a38986c0fc", engineLock, StringComparison.Ordinal); + Assert.Contains("0d7525bd330900917fb9f6d15a46059dc3d7a70a", engineLock, StringComparison.Ordinal); + Assert.Contains("P1 hardening", engineLock, StringComparison.Ordinal); + Assert.Contains("report-value position", engineLock, StringComparison.Ordinal); + Assert.Contains("omits MemberReference", engineLock, StringComparison.Ordinal); Assert.Contains("MmsSemanticReportValueProjector.Project", semanticBridge, StringComparison.Ordinal); Assert.Contains("session.StaticReportProjection.Project", runtime, StringComparison.Ordinal); Assert.Contains("MMS process fallback is disabled", runtime, StringComparison.Ordinal); From 26ee18094a57f1db5b18db83d9ce875e4604f70c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 20:15:54 +0700 Subject: [PATCH 27/34] Stabilize FAT live grid scrolling on Build 1868 baseline --- IoListTestingWindow.FatScrollStability.cs | 47 +++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 IoListTestingWindow.FatScrollStability.cs diff --git a/IoListTestingWindow.FatScrollStability.cs b/IoListTestingWindow.FatScrollStability.cs new file mode 100644 index 00000000..5e034078 --- /dev/null +++ b/IoListTestingWindow.FatScrollStability.cs @@ -0,0 +1,47 @@ +using System.Windows; +using System.Windows.Controls; + +namespace ArIED61850Tester; + +/// +/// Bench-only scroll stability for the FAT v2 grid. +/// +/// Keep the proven Build #1868 FAT schema/lifecycle untouched. WPF recycling may briefly +/// reuse a realized cell/row while the operator scrolls, which can paint another signal's +/// Runtime.CurrentValue for one frame. Standard virtualization keeps virtualization enabled +/// but prevents recycled containers from being reassigned across FAT rows. Column +/// virtualization is disabled because FAT has only a small fixed column set and correctness +/// of LIVE VALUE / VALUE 1 / VALUE 2 presentation is more important than recycling 9 cells. +/// +/// Deliberately no live-point subscriptions, no Dispatcher loop, no MMS read, no RCB/DataSet +/// mutation, and no evidence mutation are introduced here. +/// +public partial class IoListTestingWindow +{ + private static readonly bool FatScrollStabilityClassHandlerRegistered = + RegisterFatScrollStabilityClassHandler(); + + private static bool RegisterFatScrollStabilityClassHandler() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(ApplyFatScrollStability)); + return true; + } + + private static void ApplyFatScrollStability(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window) + return; + + var grid = window._fatSignalsGrid ?? FindVisualDescendant(window); + if (grid == null) + return; + + grid.EnableRowVirtualization = true; + grid.EnableColumnVirtualization = false; + VirtualizingPanel.SetIsVirtualizing(grid, true); + VirtualizingPanel.SetVirtualizationMode(grid, VirtualizationMode.Standard); + } +} From 1989b9c08efd397434c77d62cc530ac1fedb3658 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 20:16:07 +0700 Subject: [PATCH 28/34] Guard Build 1868 FAT schema while stabilizing live scroll --- .../FatScrollStabilityRegressionTests.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs diff --git a/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs b/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs new file mode 100644 index 00000000..47d80f88 --- /dev/null +++ b/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs @@ -0,0 +1,50 @@ +namespace ARSAS.Tests; + +public sealed class FatScrollStabilityRegressionTests +{ + [Fact] + public void Recovery_KeepsBuild1868Value1Value2GridContract() + { + var ux = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatV2Ux.cs")); + + Assert.Contains("Header = \"TEST\"", ux, StringComparison.Ordinal); + Assert.Contains("TextColumn(\"SIGNAL\"", ux, StringComparison.Ordinal); + Assert.Contains("TextColumn(\"IEC REFERENCE\"", ux, StringComparison.Ordinal); + Assert.Contains("TextColumn(\"TYPE\"", ux, StringComparison.Ordinal); + Assert.Contains("Header = \"LIVE VALUE\"", ux, StringComparison.Ordinal); + Assert.Contains("Header = slot == FatValueSlot.Value1 ? \"VALUE 1\" : \"VALUE 2\"", ux, StringComparison.Ordinal); + Assert.Contains("TextColumn(\"STATUS\"", ux, StringComparison.Ordinal); + Assert.Contains("TextColumn(\"RESULT\"", ux, StringComparison.Ordinal); + Assert.DoesNotContain("ON · RELAY TIME", ux, StringComparison.Ordinal); + Assert.DoesNotContain("OFF · RELAY TIME", ux, StringComparison.Ordinal); + } + + [Fact] + public void Recovery_UsesNonRecyclingVirtualizationWithoutAddingLiveSubscriptions() + { + var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatScrollStability.cs")); + + Assert.Contains("grid.EnableRowVirtualization = true;", source, StringComparison.Ordinal); + Assert.Contains("grid.EnableColumnVirtualization = false;", source, StringComparison.Ordinal); + Assert.Contains("VirtualizationMode.Standard", source, StringComparison.Ordinal); + Assert.DoesNotContain("PropertyChanged +=", source, StringComparison.Ordinal); + Assert.DoesNotContain("CollectionChanged +=", source, StringComparison.Ordinal); + Assert.DoesNotContain("Dispatcher.BeginInvoke", source, StringComparison.Ordinal); + Assert.DoesNotContain("Runtime.CurrentValue =", source, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} From b593e56215fe6d1ece540bb380a18f25b0d14606 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 22:19:11 +0700 Subject: [PATCH 29/34] Harden Build 1868 FAT recovery regression gates --- .../FatScrollStabilityRegressionTests.cs | 70 +++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs b/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs index 47d80f88..77101ac0 100644 --- a/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs @@ -30,21 +30,81 @@ public void Recovery_UsesNonRecyclingVirtualizationWithoutAddingLiveSubscription Assert.DoesNotContain("PropertyChanged +=", source, StringComparison.Ordinal); Assert.DoesNotContain("CollectionChanged +=", source, StringComparison.Ordinal); Assert.DoesNotContain("Dispatcher.BeginInvoke", source, StringComparison.Ordinal); + Assert.DoesNotContain("Dispatcher.Invoke", source, StringComparison.Ordinal); + Assert.DoesNotContain("DispatcherTimer", source, StringComparison.Ordinal); + Assert.DoesNotContain("Task.Run", source, StringComparison.Ordinal); Assert.DoesNotContain("Runtime.CurrentValue =", source, StringComparison.Ordinal); } + [Fact] + public void Recovery_ScrollPatchCannotOwnFatMembershipSessionOrAcquisition() + { + var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatScrollStability.cs")); + + Assert.DoesNotContain("ItemsSource =", source, StringComparison.Ordinal); + Assert.DoesNotContain(".Filter =", source, StringComparison.Ordinal); + Assert.DoesNotContain("Columns.Clear", source, StringComparison.Ordinal); + Assert.DoesNotContain("StartSession", source, StringComparison.Ordinal); + Assert.DoesNotContain("PrepareIoTestIedForFatAsync", source, StringComparison.Ordinal); + Assert.DoesNotContain("ApplyStaticDataSetSelection", source, StringComparison.Ordinal); + Assert.DoesNotContain("ReportControl", source, StringComparison.Ordinal); + Assert.DoesNotContain("Iec61850", source, StringComparison.Ordinal); + Assert.DoesNotContain("Storage", source, StringComparison.Ordinal); + Assert.DoesNotContain("Evidence", source, StringComparison.Ordinal); + } + + [Fact] + public void Recovery_KeepsGoldenFatMembershipAndEngineeringReturnPath() + { + var fatUx = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatV2Ux.cs")); + var fatModeSwitch = File.ReadAllText(FindRepoFile("IoListTestingWindow.WorkspaceModeSwitch.cs")); + var mainModeSwitch = File.ReadAllText(FindRepoFile("MainWindow.WorkspaceModeSwitch.cs")); + var sharedScl = File.ReadAllText(FindRepoFile("MainWindow.SharedSclWorkspace.cs")); + var fatHost = File.ReadAllText(FindRepoFile("MainWindow.IoTesting.cs")); + + Assert.Contains("point.WorkspaceSelected", fatUx, StringComparison.Ordinal); + Assert.Contains("point.IsIncludedInFat", fatUx, StringComparison.Ordinal); + + Assert.Contains("Engineering Workspace", fatModeSwitch, StringComparison.Ordinal); + Assert.Contains("Return to Engineering without unloading this FAT project", fatModeSwitch, StringComparison.Ordinal); + Assert.Contains("owner.ShowEngineeringWorkspaceFromFat(this)", fatModeSwitch, StringComparison.Ordinal); + Assert.Contains("Hide();", fatModeSwitch, StringComparison.Ordinal); + + Assert.Contains("_loadedIoFatWindow", mainModeSwitch, StringComparison.Ordinal); + Assert.Contains("ShowLoadedIoFatWorkspace", mainModeSwitch, StringComparison.Ordinal); + Assert.Contains("CurrentEngineeringSclSourcePaths", mainModeSwitch, StringComparison.Ordinal); + Assert.Contains("OpenSclFatSourcesAsync(sharedSources, selectionMode: null)", mainModeSwitch, StringComparison.Ordinal); + + Assert.Contains("_sharedSclSelectionAuthorityDeviceIds", sharedScl, StringComparison.Ordinal); + Assert.Contains("RegisterLoadedIoFatWindow(window)", fatHost, StringComparison.Ordinal); + Assert.DoesNotContain("window.ShowDialog();", fatHost, StringComparison.Ordinal); + } + + [Fact] + public void Recovery_DoesNotReintroducePerCellLiveValueAuthorityPatch() + { + var root = FindRepoRoot(); + var forbiddenPath = Path.Combine(root, "IoListTestingWindow.LiveValueAuthority.cs"); + + Assert.False( + File.Exists(forbiddenPath), + "The per-cell LIVE VALUE authority patch is forbidden on the Build #1868 recovery branch because it can fan out UI-thread subscriptions and regress FAT lifecycle behavior."); + } + private static string FindRepoFile(string relativePath) + => Path.Combine(FindRepoRoot(), relativePath); + + private static string FindRepoRoot() { DirectoryInfo? directory = new(AppContext.BaseDirectory); while (directory != null) { - var candidate = Path.Combine(directory.FullName, relativePath); - if (File.Exists(candidate)) - return candidate; + if (File.Exists(Path.Combine(directory.FullName, "IoListTestingWindow.FatV2Ux.cs"))) + return directory.FullName; directory = directory.Parent; } - throw new FileNotFoundException( - $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + throw new DirectoryNotFoundException( + $"Could not locate repository root from '{AppContext.BaseDirectory}'."); } } From 2484aaccc975775d0112645d306cd118835280f9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 22:31:19 +0700 Subject: [PATCH 30/34] Fix FAT virtualization crash by applying mode before first measure --- IoListTestingWindow.FatScrollStability.cs | 44 +++++++++++------------ 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/IoListTestingWindow.FatScrollStability.cs b/IoListTestingWindow.FatScrollStability.cs index 5e034078..14173752 100644 --- a/IoListTestingWindow.FatScrollStability.cs +++ b/IoListTestingWindow.FatScrollStability.cs @@ -1,42 +1,40 @@ -using System.Windows; using System.Windows.Controls; namespace ArIED61850Tester; /// -/// Bench-only scroll stability for the FAT v2 grid. +/// Scroll stability for the FAT v2 grid. /// -/// Keep the proven Build #1868 FAT schema/lifecycle untouched. WPF recycling may briefly -/// reuse a realized cell/row while the operator scrolls, which can paint another signal's -/// Runtime.CurrentValue for one frame. Standard virtualization keeps virtualization enabled -/// but prevents recycled containers from being reassigned across FAT rows. Column -/// virtualization is disabled because FAT has only a small fixed column set and correctness -/// of LIVE VALUE / VALUE 1 / VALUE 2 presentation is more important than recycling 9 cells. +/// WPF does not allow VirtualizingPanel.VirtualizationMode to be changed after the +/// ItemsHost has entered Measure. The previous Loaded-class-handler implementation was +/// therefore invalid and could tear down the FAT render path with InvalidOperationException. /// -/// Deliberately no live-point subscriptions, no Dispatcher loop, no MMS read, no RCB/DataSet -/// mutation, and no evidence mutation are introduced here. +/// Apply the narrow virtualization policy during Window initialization, before the first +/// layout pass: keep row virtualization enabled, use Standard (non-recycling) containers, +/// and disable column virtualization for the small fixed FAT column set. +/// +/// Deliberately no live-point subscriptions, Dispatcher work, MMS reads, RCB/DataSet +/// mutation, ItemsSource/filter mutation, session mutation, or evidence mutation occur here. /// public partial class IoListTestingWindow { - private static readonly bool FatScrollStabilityClassHandlerRegistered = - RegisterFatScrollStabilityClassHandler(); - - private static bool RegisterFatScrollStabilityClassHandler() + protected override void OnInitialized(EventArgs e) { - EventManager.RegisterClassHandler( - typeof(IoListTestingWindow), - FrameworkElement.LoadedEvent, - new RoutedEventHandler(ApplyFatScrollStability)); - return true; + base.OnInitialized(e); + ApplyFatScrollStabilityBeforeFirstMeasure(); } - private static void ApplyFatScrollStability(object sender, RoutedEventArgs e) + private void ApplyFatScrollStabilityBeforeFirstMeasure() { - if (sender is not IoListTestingWindow window) + var grid = _fatSignalsGrid ?? FindVisualDescendant(this); + if (grid == null) return; - var grid = window._fatSignalsGrid ?? FindVisualDescendant(window); - if (grid == null) + // Fail closed rather than ever changing VirtualizationMode after WPF has measured + // the ItemsHost. OnInitialized is expected to run before this point; this guard turns + // any future lifecycle drift into "no scroll patch" instead of an application-wide + // UI exception. + if (grid.IsMeasureValid) return; grid.EnableRowVirtualization = true; From a7c5881f2804d85f541dae2b97dd6ab4130e84bf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 22:31:42 +0700 Subject: [PATCH 31/34] Guard FAT virtualization against post-measure mode changes --- .../ARSAS.Tests/FatScrollStabilityRegressionTests.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs b/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs index 77101ac0..8207974b 100644 --- a/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs @@ -20,13 +20,23 @@ public void Recovery_KeepsBuild1868Value1Value2GridContract() } [Fact] - public void Recovery_UsesNonRecyclingVirtualizationWithoutAddingLiveSubscriptions() + public void Recovery_UsesNonRecyclingVirtualizationBeforeFirstMeasureWithoutLiveSubscriptions() { var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatScrollStability.cs")); + Assert.Contains("protected override void OnInitialized(EventArgs e)", source, StringComparison.Ordinal); + Assert.Contains("ApplyFatScrollStabilityBeforeFirstMeasure();", source, StringComparison.Ordinal); + Assert.Contains("if (grid.IsMeasureValid)", source, StringComparison.Ordinal); Assert.Contains("grid.EnableRowVirtualization = true;", source, StringComparison.Ordinal); Assert.Contains("grid.EnableColumnVirtualization = false;", source, StringComparison.Ordinal); Assert.Contains("VirtualizationMode.Standard", source, StringComparison.Ordinal); + + // VirtualizationMode is immutable after the ItemsHost has entered Measure. Never + // regress to the late Loaded handler that caused the physical-bench UI exception. + Assert.DoesNotContain("FrameworkElement.LoadedEvent", source, StringComparison.Ordinal); + Assert.DoesNotContain("RegisterClassHandler", source, StringComparison.Ordinal); + Assert.DoesNotContain("Loaded +=", source, StringComparison.Ordinal); + Assert.DoesNotContain("PropertyChanged +=", source, StringComparison.Ordinal); Assert.DoesNotContain("CollectionChanged +=", source, StringComparison.Ordinal); Assert.DoesNotContain("Dispatcher.BeginInvoke", source, StringComparison.Ordinal); From 64fe513830402f4a8e4937ac7d9ffbd12a8ee034 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 22:32:24 +0700 Subject: [PATCH 32/34] Find FAT grid through logical tree before WPF layout --- IoListTestingWindow.FatScrollStability.cs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/IoListTestingWindow.FatScrollStability.cs b/IoListTestingWindow.FatScrollStability.cs index 14173752..e54f9ffe 100644 --- a/IoListTestingWindow.FatScrollStability.cs +++ b/IoListTestingWindow.FatScrollStability.cs @@ -1,3 +1,4 @@ +using System.Windows; using System.Windows.Controls; namespace ArIED61850Tester; @@ -26,7 +27,10 @@ protected override void OnInitialized(EventArgs e) private void ApplyFatScrollStabilityBeforeFirstMeasure() { - var grid = _fatSignalsGrid ?? FindVisualDescendant(this); + // Before the Window template is applied, Window.Content is in the logical tree but + // may not yet be reachable through VisualTreeHelper. Use the logical tree here so + // the declared FAT DataGrid is found deterministically before its ItemsHost measures. + var grid = _fatSignalsGrid ?? FindLogicalDescendant(this); if (grid == null) return; @@ -42,4 +46,21 @@ private void ApplyFatScrollStabilityBeforeFirstMeasure() VirtualizingPanel.SetIsVirtualizing(grid, true); VirtualizingPanel.SetVirtualizationMode(grid, VirtualizationMode.Standard); } + + private static T? FindLogicalDescendant(DependencyObject root) where T : DependencyObject + { + foreach (var child in LogicalTreeHelper.GetChildren(root)) + { + if (child is T typed) + return typed; + if (child is not DependencyObject dependencyObject) + continue; + + var nested = FindLogicalDescendant(dependencyObject); + if (nested != null) + return nested; + } + + return null; + } } From 1a34206e46896e3e87d1ca4fb4cdcc4c200564f7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 22:36:42 +0700 Subject: [PATCH 33/34] Restore golden FAT virtualization regression contract --- .../FatScrollStabilityRegressionTests.cs | 58 ++++++------------- 1 file changed, 19 insertions(+), 39 deletions(-) diff --git a/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs b/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs index 8207974b..56d23d3e 100644 --- a/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs +++ b/tests/ARSAS.Tests/FatScrollStabilityRegressionTests.cs @@ -20,47 +20,27 @@ public void Recovery_KeepsBuild1868Value1Value2GridContract() } [Fact] - public void Recovery_UsesNonRecyclingVirtualizationBeforeFirstMeasureWithoutLiveSubscriptions() + public void Recovery_RestoresGoldenWpfVirtualizationAndForbidsRuntimeModeMutation() { - var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatScrollStability.cs")); - - Assert.Contains("protected override void OnInitialized(EventArgs e)", source, StringComparison.Ordinal); - Assert.Contains("ApplyFatScrollStabilityBeforeFirstMeasure();", source, StringComparison.Ordinal); - Assert.Contains("if (grid.IsMeasureValid)", source, StringComparison.Ordinal); - Assert.Contains("grid.EnableRowVirtualization = true;", source, StringComparison.Ordinal); - Assert.Contains("grid.EnableColumnVirtualization = false;", source, StringComparison.Ordinal); - Assert.Contains("VirtualizationMode.Standard", source, StringComparison.Ordinal); - - // VirtualizationMode is immutable after the ItemsHost has entered Measure. Never - // regress to the late Loaded handler that caused the physical-bench UI exception. - Assert.DoesNotContain("FrameworkElement.LoadedEvent", source, StringComparison.Ordinal); - Assert.DoesNotContain("RegisterClassHandler", source, StringComparison.Ordinal); - Assert.DoesNotContain("Loaded +=", source, StringComparison.Ordinal); - - Assert.DoesNotContain("PropertyChanged +=", source, StringComparison.Ordinal); - Assert.DoesNotContain("CollectionChanged +=", source, StringComparison.Ordinal); - Assert.DoesNotContain("Dispatcher.BeginInvoke", source, StringComparison.Ordinal); - Assert.DoesNotContain("Dispatcher.Invoke", source, StringComparison.Ordinal); - Assert.DoesNotContain("DispatcherTimer", source, StringComparison.Ordinal); - Assert.DoesNotContain("Task.Run", source, StringComparison.Ordinal); - Assert.DoesNotContain("Runtime.CurrentValue =", source, StringComparison.Ordinal); - } + var root = FindRepoRoot(); + var xaml = File.ReadAllText(Path.Combine(root, "IoListTestingWindow.xaml")); - [Fact] - public void Recovery_ScrollPatchCannotOwnFatMembershipSessionOrAcquisition() - { - var source = File.ReadAllText(FindRepoFile("IoListTestingWindow.FatScrollStability.cs")); - - Assert.DoesNotContain("ItemsSource =", source, StringComparison.Ordinal); - Assert.DoesNotContain(".Filter =", source, StringComparison.Ordinal); - Assert.DoesNotContain("Columns.Clear", source, StringComparison.Ordinal); - Assert.DoesNotContain("StartSession", source, StringComparison.Ordinal); - Assert.DoesNotContain("PrepareIoTestIedForFatAsync", source, StringComparison.Ordinal); - Assert.DoesNotContain("ApplyStaticDataSetSelection", source, StringComparison.Ordinal); - Assert.DoesNotContain("ReportControl", source, StringComparison.Ordinal); - Assert.DoesNotContain("Iec61850", source, StringComparison.Ordinal); - Assert.DoesNotContain("Storage", source, StringComparison.Ordinal); - Assert.DoesNotContain("Evidence", source, StringComparison.Ordinal); + // The physical-bench failure proved that VirtualizationMode must never be mutated + // from Loaded/OnInitialized/runtime code. Restore the Build #1868 XAML-owned policy. + Assert.Contains("EnableRowVirtualization=\"True\"", xaml, StringComparison.Ordinal); + Assert.Contains("EnableColumnVirtualization=\"True\"", xaml, StringComparison.Ordinal); + Assert.Contains("VirtualizingPanel.IsVirtualizing=\"True\"", xaml, StringComparison.Ordinal); + Assert.Contains("VirtualizingPanel.VirtualizationMode=\"Recycling\"", xaml, StringComparison.Ordinal); + + Assert.False( + File.Exists(Path.Combine(root, "IoListTestingWindow.FatScrollStability.cs")), + "Do not reintroduce a runtime FAT virtualization patch. WPF throws if VirtualizationMode is changed after the ItemsHost has entered Measure."); + + foreach (var file in Directory.EnumerateFiles(root, "IoListTestingWindow*.cs", SearchOption.TopDirectoryOnly)) + { + var source = File.ReadAllText(file); + Assert.DoesNotContain("VirtualizingPanel.SetVirtualizationMode", source, StringComparison.Ordinal); + } } [Fact] From b66e55253da145f1794e398f285b7b64e4e17868 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 22:36:48 +0700 Subject: [PATCH 34/34] Remove unsafe runtime FAT virtualization mutation --- IoListTestingWindow.FatScrollStability.cs | 66 ----------------------- 1 file changed, 66 deletions(-) delete mode 100644 IoListTestingWindow.FatScrollStability.cs diff --git a/IoListTestingWindow.FatScrollStability.cs b/IoListTestingWindow.FatScrollStability.cs deleted file mode 100644 index e54f9ffe..00000000 --- a/IoListTestingWindow.FatScrollStability.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Windows; -using System.Windows.Controls; - -namespace ArIED61850Tester; - -/// -/// Scroll stability for the FAT v2 grid. -/// -/// WPF does not allow VirtualizingPanel.VirtualizationMode to be changed after the -/// ItemsHost has entered Measure. The previous Loaded-class-handler implementation was -/// therefore invalid and could tear down the FAT render path with InvalidOperationException. -/// -/// Apply the narrow virtualization policy during Window initialization, before the first -/// layout pass: keep row virtualization enabled, use Standard (non-recycling) containers, -/// and disable column virtualization for the small fixed FAT column set. -/// -/// Deliberately no live-point subscriptions, Dispatcher work, MMS reads, RCB/DataSet -/// mutation, ItemsSource/filter mutation, session mutation, or evidence mutation occur here. -/// -public partial class IoListTestingWindow -{ - protected override void OnInitialized(EventArgs e) - { - base.OnInitialized(e); - ApplyFatScrollStabilityBeforeFirstMeasure(); - } - - private void ApplyFatScrollStabilityBeforeFirstMeasure() - { - // Before the Window template is applied, Window.Content is in the logical tree but - // may not yet be reachable through VisualTreeHelper. Use the logical tree here so - // the declared FAT DataGrid is found deterministically before its ItemsHost measures. - var grid = _fatSignalsGrid ?? FindLogicalDescendant(this); - if (grid == null) - return; - - // Fail closed rather than ever changing VirtualizationMode after WPF has measured - // the ItemsHost. OnInitialized is expected to run before this point; this guard turns - // any future lifecycle drift into "no scroll patch" instead of an application-wide - // UI exception. - if (grid.IsMeasureValid) - return; - - grid.EnableRowVirtualization = true; - grid.EnableColumnVirtualization = false; - VirtualizingPanel.SetIsVirtualizing(grid, true); - VirtualizingPanel.SetVirtualizationMode(grid, VirtualizationMode.Standard); - } - - private static T? FindLogicalDescendant(DependencyObject root) where T : DependencyObject - { - foreach (var child in LogicalTreeHelper.GetChildren(root)) - { - if (child is T typed) - return typed; - if (child is not DependencyObject dependencyObject) - continue; - - var nested = FindLogicalDescendant(dependencyObject); - if (nested != null) - return nested; - } - - return null; - } -}