From cfd2a54baf3dce700adb2ea78ec13b9321bd9509 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 10:00:08 +0700 Subject: [PATCH 01/16] fix(m217k): project typed ST enum status FCD tuples for C264 Mod/PhyHealth without parsing Structure text --- .../Mms/MmsReportValueProjector.cs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsReportValueProjector.cs b/src/AR.Iec61850/Mms/MmsReportValueProjector.cs index ead27b50..3f501a87 100644 --- a/src/AR.Iec61850/Mms/MmsReportValueProjector.cs +++ b/src/AR.Iec61850/Mms/MmsReportValueProjector.cs @@ -310,6 +310,14 @@ private static bool TryProjectKnownDataObjectStruct( } } + // C264/IEC status FCD members such as Mod and PhyHealth are whole + // structured ST DataObjects, not the selected scalar stVal leaf. + // Trust only a *typed* MMS tuple (integer enumeration, decoded q, + // decoded UTC time, optional Boolean stSeld). Never peel the first + // element from an arbitrary human-readable Structure(...) summary. + if (TryProjectEnumeratedStatusStruct(reference, fc, value, reason, receivedAt, out projected)) + return true; + if (TryProjectBooleanStatusStruct(reference, fc, value, reason, receivedAt, out projected)) return true; @@ -334,6 +342,51 @@ private static bool TryProjectKnownDataObjectStruct( return false; } + private static bool TryProjectEnumeratedStatusStruct( + string reference, + string fc, + MmsDataValue value, + string reason, + DateTimeOffset receivedAt, + out IReadOnlyList projected) + { + projected = Array.Empty(); + // Constrain the standard status DO names and exact ST FC. A valid q/t + // tuple is not enough to authorize a guessed semantic leaf elsewhere. + if (!string.Equals(fc, "ST", StringComparison.OrdinalIgnoreCase) || + LastSegment(reference).ToUpperInvariant() is not ("MOD" or "BEH" or "HEALTH" or "PHYHEALTH") || + value.Kind != MmsDataKind.Structure || + value.Children.Count is not (3 or 4) || + value.Children[0].Kind is not (MmsDataKind.Integer or MmsDataKind.Unsigned) || + (value.Children.Count == 4 && value.Children[3].Kind != MmsDataKind.Boolean)) + return false; + + var q = Iec61850QualityDecoder.Decode(value.Children[1]); + var t = Iec61850TimestampDecoder.Decode(value.Children[2]); + if (!q.IsDecoded || !t.IsDecoded) + return false; + + // Extra field (if any) was type-checked but is not given an invented + // path. The exact IED schema can project stSeld independently. + var stValReference = Combine(reference, "stVal"); + projected = + [ + new MmsReportProjectedSignalCandidate + { + Reference = stValReference, + FunctionalConstraint = "ST", + Value = DisplayScalar(stValReference, value.Children[0]), + Quality = q.Validity, + Timestamp = t.DisplayTime, + Reason = reason, + UpdatedAt = receivedAt, + IsProjectedChild = true, + ProjectionStatus = "projected-typed-enumerated-status" + } + ]; + return true; + } + private static bool TryProjectBooleanStatusStruct( string reference, string fc, From 54bdc4b123bb824f19589c839ca15e79a50becab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 10:00:39 +0700 Subject: [PATCH 02/16] test(m217k): physical C264 typed Mod/Beh/PhyHealth ST FCD and strict malformed-tuple fail closed --- .../Mms/MmsReportValueProjectorTests.cs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs index 3a109e77..0dff372b 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsReportValueProjectorTests.cs @@ -193,6 +193,94 @@ public void Project_Does_Not_Misclassify_NonBoolean_Three_Field_Struct_As_Status Assert.Equal("direct", update.ProjectionStatus); } + [Theory] + [InlineData("C264CONTROL/CILO1.Mod", true)] + [InlineData("C264CONTROL/CSWI1.Mod", true)] + [InlineData("C264SYSTEM/LPHD1.PhyHealth", false)] + [InlineData("C264CONTROL/CILO1.Beh", false)] + public void C264_Typed_ST_Status_FCD_Projects_StVal_Quality_Time( + string memberReference, bool hasStSeld) + { + // Exact IEC 61850 ST declaration order in the supplied C264 IID: + // enumerated stVal, quality q, UTC t, optionally Boolean stSeld. + // The old generic projector left Mod and PhyHealth as Structure(...). + var at = new DateTimeOffset(2026, 10, 17, 2, 24, 5, TimeSpan.Zero); + var children = new List + { + MmsDataValue.Integer(1), + MmsDataValue.BitString(3, [0x00, 0x00]), + MmsDataValue.UtcTime(new Iec61850UtcTime(at, 0)) + }; + if (hasStSeld) + children.Add(MmsDataValue.Boolean(false)); + var frame = new MmsReportFrame + { + ReceivedAt = at, + Values = + [ + new MmsReportValue + { + Index = 0, + Member = new MmsDataSetDirectoryMember + { + UserReference = memberReference, + FunctionalConstraint = "ST" + }, + Value = MmsDataValue.Structure(children), + ReasonForInclusion = ["general-interrogation"] + } + ] + }; + + var projected = MmsReportValueProjector.Project(frame); + var update = Assert.Single(projected.Updates); + Assert.Equal(memberReference + ".stVal", update.Reference); + Assert.Equal("1", update.Value); // no invented on/off enum mapping + Assert.Equal("good", update.Quality); + Assert.Contains("2026-10-17", update.Timestamp, StringComparison.Ordinal); + Assert.True(update.HasValue); + Assert.True(update.HasQuality); + Assert.True(update.HasTimestamp); + Assert.Equal("projected-typed-enumerated-status", update.ProjectionStatus); + Assert.DoesNotContain(projected.Warnings, x => x.StartsWith("REPORT_RAW_STRUCT", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("MX", false, false, false)] + [InlineData("ST", true, false, false)] + [InlineData("ST", false, true, false)] + [InlineData("ST", false, false, true)] + public void Typed_Enum_Status_Rejects_Unverified_Tuple_Shape( + string fc, bool wrongQuality, bool wrongTimestamp, bool wrongFourth) + { + var at = new DateTimeOffset(2026, 10, 17, 2, 24, 5, TimeSpan.Zero); + var payload = MmsDataValue.Structure([ + MmsDataValue.Integer(1), + wrongQuality ? MmsDataValue.Boolean(false) : MmsDataValue.BitString(3, [0x00, 0x00]), + wrongTimestamp ? MmsDataValue.Boolean(false) : MmsDataValue.UtcTime(new Iec61850UtcTime(at, 0)), + wrongFourth ? MmsDataValue.Integer(99) : MmsDataValue.Boolean(false) + ]); + var frame = new MmsReportFrame + { + ReceivedAt = at, + Values = [new MmsReportValue + { + Index = 0, + Member = new MmsDataSetDirectoryMember + { + UserReference = "C264CONTROL/CILO1.Mod", + FunctionalConstraint = fc + }, + Value = payload + }] + }; + var projected = MmsReportValueProjector.Project(frame); + Assert.DoesNotContain(projected.Updates, + x => x.Reference.Equals("C264CONTROL/CILO1.Mod.stVal", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(projected.Warnings, + x => x.StartsWith("REPORT_RAW_STRUCT:", StringComparison.OrdinalIgnoreCase)); + } + [Theory] [InlineData(0x40, "off")] [InlineData(0x80, "on")] From e61989f1178f87034385684fa31465bc87270081 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 10:03:23 +0700 Subject: [PATCH 03/16] ci(m217k): run engine Windows test gate on pinned PR169 stacked branch --- .github/workflows/dotnet-ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index f10c0ab4..967b0a7d 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -20,7 +20,9 @@ on: - "*.slnx" - "Directory.Build.props" pull_request: - branches: [ main, feat/495-dataset-type-closure ] + # The live ARSAS product pins PR #169 until targeted engine changes pass CI. + # Stacked engine PRs must run the exact same Windows build/test gate. + branches: [ main, feat/495-dataset-type-closure, fix/p10-9l-readonly-ctlmodel-probe ] workflow_dispatch: permissions: From 51c2eefd61d6bcbe1116fda6b781b74bb9cf06da Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 10:05:18 +0700 Subject: [PATCH 04/16] test(m217k): incomplete live schema must not suppress typed C264 ST FCD report fallback --- .../MmsSemanticReportValueProjectorTests.cs | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index 8fb40dc2..00e95eba 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -191,6 +191,107 @@ public void Schema_Mismatch_Fails_Closed_And_Preserves_Raw_Projection() update.ProjectionStatus.Equals("semantic-structured-leaf", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void C264_Incomplete_Live_Schema_Still_Projects_Strictly_Typed_Mod_FCD() + { + // C264 smart discovery can expose stVal without type-probing every + // sibling. That partial model must not suppress a fully typed ST + // Report containing stVal, q, t and stSeld. + const string root = "C264CONTROL/CILO1.Mod"; + const string dataSet = "C264CONTROL/LLN0.LLN0BRptStDs"; + var schema = new LiveIedModelDiscoveryDocument + { + IedName = "C264", + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "C264CONTROL", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "CILO1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = root, + Name = "Mod", + Attributes = + [ + new LiveIedDataAttributeModel + { + ObjectReference = root + ".stVal", + AttributePath = "stVal", + FunctionalConstraint = "ST" + } + ] + } + ] + } + ] + } + ], + DataSets = + [ + new LiveIedDataSetModel + { + Reference = dataSet, + Domain = "C264CONTROL", + LogicalNode = "LLN0", + Name = "LLN0BRptStDs", + MemberCount = 1, + Members = + [ + new LiveIedDataSetMemberModel + { + Index = 0, + Reference = root, + FunctionalConstraint = "ST", + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + } + ] + } + ] + }; + var at = new DateTimeOffset(2026, 10, 17, 2, 24, 5, TimeSpan.Zero); + var frame = new MmsReportFrame + { + ReceivedAt = at, + Header = new MmsReportHeader { DataSetReference = dataSet }, + Values = + [ + new MmsReportValue + { + Index = 0, + Member = new MmsDataSetDirectoryMember + { + UserReference = root, + FunctionalConstraint = "ST" + }, + Value = MmsDataValue.Structure([ + MmsDataValue.Integer(1), + MmsDataValue.BitString(3, [0x00, 0x00]), + MmsDataValue.UtcTime(new Iec61850UtcTime(at, 0)), + MmsDataValue.Boolean(false) + ]) + } + ] + }; + + var projected = MmsSemanticReportValueProjector.Project( + frame, MmsReportSemanticProjectionContext.Create(schema)); + var scalar = Assert.Single(projected.Updates); + Assert.Equal(root + ".stVal", scalar.Reference); + Assert.Equal("good", scalar.Quality); + Assert.True(scalar.HasTimestamp); + Assert.DoesNotContain(projected.Warnings, + x => x.StartsWith("REPORT_RAW_STRUCT:", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(projected.Updates, + x => x.Value.StartsWith("Structure(", StringComparison.OrdinalIgnoreCase)); + } + private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( string objectReference, string dataSetReference) From 593399eaf3f2d0ecd64ac2d0d1444f11a707b06c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:13:20 +0700 Subject: [PATCH 05/16] test(identity): expose false BCU truncation and unverified heuristic confidence --- .../GlobalIdentityEvidenceRegressionTests.cs | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Discovery/GlobalIdentityEvidenceRegressionTests.cs diff --git a/tests/AR.Iec61850.Tests/Discovery/GlobalIdentityEvidenceRegressionTests.cs b/tests/AR.Iec61850.Tests/Discovery/GlobalIdentityEvidenceRegressionTests.cs new file mode 100644 index 00000000..67204cad --- /dev/null +++ b/tests/AR.Iec61850.Tests/Discovery/GlobalIdentityEvidenceRegressionTests.cs @@ -0,0 +1,106 @@ +using AR.Iec61850.Discovery; +using System.Xml.Linq; + +namespace AR.Iec61850.Tests.Discovery; + +/// +/// Independently constructed IEC 61850 domain/SCL topology; no device capture or external-client fixture. +/// +public sealed class GlobalIdentityEvidenceRegressionTests +{ + private static readonly string[] Domains = + [ + "SIEBCUCTRL", "SIEBCUMEAS", "SIEBCUPROT", "SIEBCUUD1", "SIEBCUUD2" + ]; + + [Fact] + public void MultipleDomains_NeverStripAnUnprovenSuffixFromCommonIdentity() + { + var inferred = LiveIedIdentityResolver.Resolve(Domains, "192.0.2.20"); + Assert.Equal("SIEBCU", inferred.IedName); + Assert.Equal("MmsDomainCommonPrefix", inferred.Source); + Assert.True(inferred.Confidence < LiveIedDiscoveryConfidenceLevel.High); + Assert.Equal("CTRL", inferred.LogicalDeviceAliases["SIEBCUCTRL"]); + Assert.Equal("UD2", inferred.LogicalDeviceAliases["SIEBCUUD2"]); + Assert.Equal(Domains.Length, inferred.LogicalDeviceAliases.Count); + } + + [Fact] + public void DomainSuffixHeuristic_MustNotClaimVerifiedWireIdentity() + { + var inferred = LiveIedIdentityResolver.Resolve( + ["LABCTRLLD0", "LABCTRLPROT", "LABCTRLMEAS"], "192.0.2.20"); + Assert.Equal("LABCTRL", inferred.IedName); + Assert.True(inferred.Confidence < LiveIedDiscoveryConfidenceLevel.High); + Assert.Equal("MmsDomainKnownLogicalDeviceSuffix", inferred.Source); + } + + [Fact] + public void OperatorDisplayAlias_IsNotWireVerified() + { + var inferred = LiveIedIdentityResolver.Resolve(Domains, "192.0.2.20", + explicitIedName: "OPERATOR_LABEL"); + Assert.Equal("OPERATOR_LABEL", inferred.IedName); + Assert.Equal("ExplicitOverride", inferred.Source); + Assert.True(inferred.Confidence < LiveIedDiscoveryConfidenceLevel.High); + } + + [Fact] + public void UniqueSclTopology_PromotesFullName_AndWrongEndpointOrApFailsClosed() + { + var verified = TrustedSclIedIdentityMatcher.TryMatch( + SyntheticScl(), Domains, "192.0.2.20", "SIEBCU"); + Assert.NotNull(verified); + Assert.Equal("SIEBCU", verified.IedName); + Assert.Equal("TrustedSclExactDomainMatch", verified.Source); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.High, verified.Confidence); + Assert.Equal("UD1", verified.LogicalDeviceAliases["SIEBCUUD1"]); + + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + SyntheticScl(), Domains, "192.0.2.21", "SIEBCU")); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + SyntheticScl(), Domains[..4], "192.0.2.20", "SIEBCU")); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + SyntheticScl(), Domains, "192.0.2.20", "ANOTHER")); + + var ambiguous = SyntheticScl(); + XNamespace ns = "http://www.iec.ch/61850/2003/SCL"; + var ied = Assert.Single(ambiguous.Descendants(ns + "IED")); + var duplicate = new XElement(Assert.Single(ied.Elements(ns + "AccessPoint"))); + duplicate.SetAttributeValue("name", "P2"); + ied.Add(duplicate); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + ambiguous, Domains, "192.0.2.20", "SIEBCU")); + } + + [Fact] + public void SclWithoutEndpointEvidence_CannotPromoteToVerifiedIdentity() + { + var scl = SyntheticScl(); + XNamespace ns = "http://www.iec.ch/61850/2003/SCL"; + scl.Root!.Element(ns + "Communication")!.Remove(); + Assert.Null(TrustedSclIedIdentityMatcher.TryMatch( + scl, Domains, "192.0.2.20", "SIEBCU")); + } + + private static XDocument SyntheticScl() + { + XNamespace ns = "http://www.iec.ch/61850/2003/SCL"; + string[] instances = ["CTRL", "MEAS", "PROT", "UD1", "UD2"]; + return new XDocument(new XElement(ns + "SCL", + new XElement(ns + "Communication", + new XElement(ns + "SubNetwork", + new XElement(ns + "ConnectedAP", + new XAttribute("iedName", "SIEBCU"), + new XAttribute("apName", "P1"), + new XElement(ns + "Address", + new XElement(ns + "P", + new XAttribute("type", "IP"), "192.0.2.20"))))), + new XElement(ns + "IED", new XAttribute("name", "SIEBCU"), + new XElement(ns + "AccessPoint", + new XAttribute("name", "P1"), + new XElement(ns + "Server", + instances.Select(inst => new XElement(ns + "LDevice", + new XAttribute("inst", inst)))))))); + } +} From 533419447c1827aa78d3184a7f007be568736751 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:15:44 +0700 Subject: [PATCH 06/16] fix(identity): retain full MMS domain prefix and require unique SCL endpoint --- .../Discovery/LiveIedIdentityResolver.cs | 27 +++++-------------- .../Discovery/TrustedSclIedIdentityMatcher.cs | 16 +++++------ .../Discovery/LiveIedIdentityResolverTests.cs | 8 +++--- 3 files changed, 18 insertions(+), 33 deletions(-) diff --git a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs index 8508a176..317b8cdb 100644 --- a/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs +++ b/src/AR.Iec61850/Discovery/LiveIedIdentityResolver.cs @@ -38,7 +38,7 @@ public static LiveIedIdentity Resolve( if (!string.IsNullOrWhiteSpace(explicitIedName)) { var name = explicitIedName.Trim(); - return Create(name, "ExplicitOverride", LiveIedDiscoveryConfidenceLevel.Exact, false, [name], materialized, + return Create(name, "ExplicitOverride", LiveIedDiscoveryConfidenceLevel.Medium, false, [name], materialized, [$"IED name was supplied explicitly as '{name}'."]); } @@ -64,9 +64,7 @@ public static LiveIedIdentity Resolve( if (distinctSuffixCandidates.Length == 1) { var name = distinctSuffixCandidates[0]; - var confidence = materialized.Length > 1 - ? LiveIedDiscoveryConfidenceLevel.High - : LiveIedDiscoveryConfidenceLevel.Medium; + var confidence = LiveIedDiscoveryConfidenceLevel.Medium; var evidence = suffixMatches .Select(match => $"MMS domain '{match.Domain}' matched the logical-device suffix pattern for IED '{name}'.") .ToArray(); @@ -89,9 +87,7 @@ public static LiveIedIdentity Resolve( var commonPrefix = InferCommonPrefix(materialized); if (!string.IsNullOrWhiteSpace(commonPrefix)) { - var confidence = materialized.Length >= 3 - ? LiveIedDiscoveryConfidenceLevel.High - : LiveIedDiscoveryConfidenceLevel.Medium; + var confidence = LiveIedDiscoveryConfidenceLevel.Medium; return Create( commonPrefix, "MmsDomainCommonPrefix", @@ -99,7 +95,7 @@ public static LiveIedIdentity Resolve( false, [commonPrefix], materialized, - [$"IED name '{commonPrefix}' was derived from the common prefix of {materialized.Length} MMS domain(s)."]); + [$"Provisional IED-name candidate '{commonPrefix}' comes from {materialized.Length} MMS domains; this is not verified wire identity."]); } // Retain the useful single-domain behavior (for example OLSF501LD0), but do not @@ -112,7 +108,7 @@ public static LiveIedIdentity Resolve( return Create( name, "MmsDomainKnownLogicalDeviceSuffix", - materialized.Length == 1 ? LiveIedDiscoveryConfidenceLevel.Medium : LiveIedDiscoveryConfidenceLevel.High, + LiveIedDiscoveryConfidenceLevel.Medium, false, distinctSuffixCandidates, materialized, @@ -235,18 +231,9 @@ private static string InferCommonPrefix(IReadOnlyList domains) return string.Empty; } + // An exact shared prefix can itself end in a common logical-device stem. + // Only verified model evidence can justify removing that stem. prefix = TrimBoundary(prefix); - foreach (var stem in KnownLogicalDeviceStems) - { - if (prefix.EndsWith(stem, StringComparison.OrdinalIgnoreCase) && prefix.Length > stem.Length) - { - var withoutStem = TrimBoundary(prefix[..^stem.Length]); - if (IsViableName(withoutStem)) - prefix = withoutStem; - break; - } - } - return IsViableName(prefix) ? prefix : string.Empty; } diff --git a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs index a3b8918a..42f51717 100644 --- a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs +++ b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs @@ -71,20 +71,18 @@ public static class TrustedSclIedIdentityMatcher .Where(x => string.Equals((string?)x.Attribute("iedName"),name,StringComparison.OrdinalIgnoreCase) && string.Equals((string?)x.Attribute("apName"),apName,StringComparison.OrdinalIgnoreCase)) .ToArray() ?? Array.Empty(); - if (connected.Length > 1) continue; + // A uniquely matched endpoint is required before promoting SCL identity. + if (connected.Length != 1 || string.IsNullOrWhiteSpace(connectedHost)) continue; var ipValues = connected.SingleOrDefault()? .Element(Scl + "Address")?.Elements(Scl + "P") .Where(x => string.Equals((string?)x.Attribute("type"),"IP",StringComparison.OrdinalIgnoreCase)) .Select(x => x.Value.Trim()).Take(2).ToArray() ?? Array.Empty(); - if (ipValues.Length > 1) continue; + if (ipValues.Length != 1) continue; var ip = ipValues.SingleOrDefault(); - if (!string.IsNullOrWhiteSpace(ip) && !string.IsNullOrWhiteSpace(connectedHost)) - { - if (!IPAddress.TryParse(ip,out var designIp) || - !IPAddress.TryParse(connectedHost.Trim(),out var observedIp) || - !designIp.Equals(observedIp)) - continue; // never promote a model for a different endpoint - } + if (!IPAddress.TryParse(ip, out var designIp) || + !IPAddress.TryParse(connectedHost!.Trim(), out var observedIp) || + !designIp.Equals(observedIp)) + continue; // Never promote a model with an unverified endpoint. matches.Add((name, apName, aliases, ip)); } } diff --git a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs index 308799b6..f08fbfaa 100644 --- a/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/LiveIedIdentityResolverTests.cs @@ -25,7 +25,7 @@ public void Resolve_Uses_All_Domains_To_Confirm_The_Ied_Name() "192.0.2.10"); Assert.Equal("OLSF501", identity.IedName); - Assert.Equal(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); Assert.False(identity.IsAmbiguous); Assert.Equal("PROT", identity.LogicalDeviceAliases["OLSF501PROT"]); } @@ -45,7 +45,7 @@ public void Resolve_Does_Not_Promote_A_Nested_Mu_Domain_To_The_Physical_Ied_Name Assert.Equal("OCR7SJ8", identity.IedName); Assert.Equal("MmsDomainCommonPrefix", identity.Source); - Assert.Equal(LiveIedDiscoveryConfidenceLevel.High, identity.Confidence); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); Assert.Equal("Mod2_MU1", identity.LogicalDeviceAliases["OCR7SJ8Mod2_MU1"]); Assert.DoesNotContain("OCR7SJ8Mod2", identity.CandidateNames); } @@ -69,7 +69,7 @@ public void Resolve_Uses_Explicit_Name_Over_Live_Heuristic() Assert.Equal("COMMISSIONED_IED", identity.IedName); Assert.Equal("ExplicitOverride", identity.Source); - Assert.Equal(LiveIedDiscoveryConfidenceLevel.Exact, identity.Confidence); + Assert.Equal(LiveIedDiscoveryConfidenceLevel.Medium, identity.Confidence); } [Fact] @@ -81,7 +81,7 @@ public void Binding_Identity_Resolver_Uses_The_Live_Domain_Resolver() Assert.Equal("OLSF501", identity.DisplayName); Assert.Equal("MmsDomainKnownLogicalDeviceSuffix", identity.Source); - Assert.Equal("High", identity.Confidence); + Assert.Equal("Medium", identity.Confidence); Assert.Equal("LD1", identity.LogicalDeviceAliases["OLSF501LD1"]); } From d92c7503b610519915377fd07b825538a2ebae5f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:19:53 +0700 Subject: [PATCH 07/16] fix(identity): reject ambiguous SCL AP topology; assert confidence categories explicitly --- .../Discovery/TrustedSclIedIdentityMatcher.cs | 4 +++- .../Discovery/GlobalIdentityEvidenceRegressionTests.cs | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs index 42f51717..dc429c42 100644 --- a/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs +++ b/src/AR.Iec61850/Discovery/TrustedSclIedIdentityMatcher.cs @@ -30,6 +30,7 @@ public static class TrustedSclIedIdentityMatcher if (observed.Count == 0) return null; var matches = new List<(string Ied, string Ap, Dictionary Aliases, string? Ip)>(); + var topologyMatchCount = 0; foreach (var ied in trustedScl.Root.Elements(Scl + "IED")) { var name = ((string?)ied.Attribute("name") ?? "").Trim(); @@ -65,6 +66,7 @@ public static class TrustedSclIedIdentityMatcher } } if (invalid || aliases.Count != observed.Count) continue; + topologyMatchCount++; // AP ambiguity is structural even if another AP lacks endpoint evidence. var connected = trustedScl.Root.Element(Scl + "Communication")? .Descendants(Scl + "ConnectedAP") @@ -88,7 +90,7 @@ public static class TrustedSclIedIdentityMatcher } // A single IED can have multiple APs and the same LD topology. This // remains ambiguous until AP is independently selected/proven. - if (matches.Count != 1) return null; + if (topologyMatchCount != 1 || matches.Count != 1) return null; var match=matches[0]; return new LiveIedIdentity { diff --git a/tests/AR.Iec61850.Tests/Discovery/GlobalIdentityEvidenceRegressionTests.cs b/tests/AR.Iec61850.Tests/Discovery/GlobalIdentityEvidenceRegressionTests.cs index 67204cad..139190fc 100644 --- a/tests/AR.Iec61850.Tests/Discovery/GlobalIdentityEvidenceRegressionTests.cs +++ b/tests/AR.Iec61850.Tests/Discovery/GlobalIdentityEvidenceRegressionTests.cs @@ -19,7 +19,8 @@ public void MultipleDomains_NeverStripAnUnprovenSuffixFromCommonIdentity() var inferred = LiveIedIdentityResolver.Resolve(Domains, "192.0.2.20"); Assert.Equal("SIEBCU", inferred.IedName); Assert.Equal("MmsDomainCommonPrefix", inferred.Source); - Assert.True(inferred.Confidence < LiveIedDiscoveryConfidenceLevel.High); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.High, inferred.Confidence); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.Exact, inferred.Confidence); Assert.Equal("CTRL", inferred.LogicalDeviceAliases["SIEBCUCTRL"]); Assert.Equal("UD2", inferred.LogicalDeviceAliases["SIEBCUUD2"]); Assert.Equal(Domains.Length, inferred.LogicalDeviceAliases.Count); @@ -31,7 +32,8 @@ public void DomainSuffixHeuristic_MustNotClaimVerifiedWireIdentity() var inferred = LiveIedIdentityResolver.Resolve( ["LABCTRLLD0", "LABCTRLPROT", "LABCTRLMEAS"], "192.0.2.20"); Assert.Equal("LABCTRL", inferred.IedName); - Assert.True(inferred.Confidence < LiveIedDiscoveryConfidenceLevel.High); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.High, inferred.Confidence); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.Exact, inferred.Confidence); Assert.Equal("MmsDomainKnownLogicalDeviceSuffix", inferred.Source); } @@ -42,7 +44,8 @@ public void OperatorDisplayAlias_IsNotWireVerified() explicitIedName: "OPERATOR_LABEL"); Assert.Equal("OPERATOR_LABEL", inferred.IedName); Assert.Equal("ExplicitOverride", inferred.Source); - Assert.True(inferred.Confidence < LiveIedDiscoveryConfidenceLevel.High); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.High, inferred.Confidence); + Assert.NotEqual(LiveIedDiscoveryConfidenceLevel.Exact, inferred.Confidence); } [Fact] From 3152ab0a49ee53c432ae3b694aa8a47e60839262 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:22:26 +0700 Subject: [PATCH 08/16] test(rcb): reject exact-named control bound to a different live DataSet --- ...msCanonicalStaticRcbTargetResolverTests.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs index 12bcbef0..4408ee6d 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs @@ -80,6 +80,26 @@ public void Unresolved_Segment_Does_Not_Guess_Name_Family() warning => warning.Contains("No sibling/name-family guess", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void ConfiguredRcbWithContradictoryLiveDataSet_MustNotBecomeOperationalTarget() + { + var coverage = Coverage( + dataSet: "LD0/LLN0.Events", + configured: new CanonicalReportControl + { + Reference = "LD0/LLN0.BR.Rpt01", + DataSetReference = "LD0/LLN0.Events", + Buffered = true + }); + var inventory = Inventory( + Candidate("LD0/LLN0.BR.Rpt01", "LD0/LLN0.Unrelated", buffered: true)); + + var result = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory); + + Assert.False(result.HasTargets); + Assert.Equal("unresolved", Assert.Single(result.Segments).Resolution); + } + private static CanonicalStaticReportCoveragePlan Coverage( string dataSet, CanonicalReportControl configured) From 36ec638ab76b477c5d31c89f282dd42316ad0568 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:23:55 +0700 Subject: [PATCH 09/16] feat(rcb): introduce deterministic target-scoped read-only candidate pagination --- .../MmsCanonicalStaticRcbCandidatePager.cs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCandidatePager.cs diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCandidatePager.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCandidatePager.cs new file mode 100644 index 00000000..5864916a --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCandidatePager.cs @@ -0,0 +1,108 @@ +using AR.Iec61850.Engineering.Canonical; + +namespace AR.Iec61850.Mms; + +/// +/// Deterministic read-only candidate window. Known nonmatching DatSet bindings are +/// excluded; unknown bindings remain eligible for a bounded, resumable MMS read. +/// A catalog size is never an operation-size failure by itself. +/// +public static class MmsCanonicalStaticRcbCandidatePager +{ + public static MmsCanonicalStaticRcbCandidatePage BuildPage( + CanonicalStaticReportCoveragePlan coverage, + IEnumerable liveCandidates, + int maxPerPage, + int offset = 0) + { + ArgumentNullException.ThrowIfNull(coverage); + ArgumentNullException.ThrowIfNull(liveCandidates); + + var targets = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var configuredReferences = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var segment in coverage.Segments) + { + if (segment.SelectedSignalReferences.Length == 0) + continue; + var dataSet = MmsRcbAvailabilityEvaluator.NormalizeReference(segment.DataSetReference); + if (string.IsNullOrWhiteSpace(dataSet)) + continue; + + foreach (var report in segment.ReportControls) + { + var domain = DomainOf(report.MmsDomain, report.Reference); + if (domain.Length == 0) + continue; + var key = FamilyKey(domain, report.Buffered); + if (!targets.TryGetValue(key, out var bindings)) + targets[key] = bindings = new HashSet(StringComparer.OrdinalIgnoreCase); + bindings.Add(dataSet); + + var configured = MmsRcbAvailabilityEvaluator.NormalizeReference(report.Reference); + if (configured.Length > 0) + configuredReferences.Add(configured); + } + } + + var eligible = liveCandidates + .Where(candidate => + { + var domain = DomainOf(candidate.Domain, candidate.Reference); + if (!targets.TryGetValue(FamilyKey(domain, candidate.Buffered), out var bindings)) + return false; + var boundDataSet = MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference); + return boundDataSet.Length == 0 || bindings.Contains(boundDataSet); + }) + .Where(candidate => !string.IsNullOrWhiteSpace(candidate.Reference)) + .DistinctBy(candidate => MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.Reference), + StringComparer.OrdinalIgnoreCase) + .OrderBy(candidate => + { + var binding = MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.DataSetReference); + if (binding.Length > 0) return 0; // already bound to selected DataSet by live evidence + return configuredReferences.Contains( + MmsRcbAvailabilityEvaluator.NormalizeReference(candidate.Reference)) ? 1 : 2; + }) + .ThenBy(candidate => candidate.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(candidate => candidate.Reference, StringComparer.OrdinalIgnoreCase) + .ThenBy(candidate => candidate.Reference, StringComparer.Ordinal) + .ToArray(); + + var start = Math.Clamp(offset, 0, eligible.Length); + var windowSize = Math.Clamp(maxPerPage, 1, 512); + var page = eligible.Skip(start).Take(windowSize).ToArray(); + var next = start + page.Length; + + return new MmsCanonicalStaticRcbCandidatePage + { + Candidates = page, + EligibleCandidateCount = eligible.Length, + ExaminedCandidateCount = page.Length, + DeferredCandidateCount = eligible.Length - next, + NextOffset = next < eligible.Length ? next : null + }; + } + + private static string DomainOf(string? domain, string? reference) + { + if (!string.IsNullOrWhiteSpace(domain)) + return domain.Trim(); + var value = reference?.Trim() ?? string.Empty; + var slash = value.IndexOf('/'); + return slash > 0 ? value[..slash] : string.Empty; + } + + private static string FamilyKey(string domain, bool buffered) + => domain + (buffered ? "|BR" : "|RP"); +} + +public sealed class MmsCanonicalStaticRcbCandidatePage +{ + public IReadOnlyList Candidates { get; init; } + = Array.Empty(); + public int EligibleCandidateCount { get; init; } + public int ExaminedCandidateCount { get; init; } + public int DeferredCandidateCount { get; init; } + public int? NextOffset { get; init; } + public bool HasContinuation => NextOffset.HasValue; +} From 34b1fb511af1e77364ffc7d5817dde95822485bd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:24:51 +0700 Subject: [PATCH 10/16] fix(rcb): replace global candidate rejection with exact-scope resumable read windows --- .../MmsCanonicalStaticLiveReconciliation.cs | 61 ++++++++----------- 1 file changed, 25 insertions(+), 36 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs index d2f3b82e..ccabdafa 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticLiveReconciliation.cs @@ -55,6 +55,9 @@ public sealed class MmsCanonicalStaticLiveReconciliationOptions /// The operation fails closed rather than reading an arbitrary subset. /// public int MaxReportControlCandidates { get; init; } = 64; + + /// Zero-based continuation offset for one bounded read-only candidate window. + public int CandidateOffset { get; init; } } public sealed class MmsCanonicalStaticLiveReconciliationResult @@ -64,6 +67,9 @@ public sealed class MmsCanonicalStaticLiveReconciliationResult public MmsReportInventory Inventory { get; init; } = new(); public int EnumeratedVariableCount { get; init; } public int CandidateReportControlCount { get; init; } + public int ExaminedCandidateCount { get; init; } + public int DeferredCandidateCount { get; init; } + public int? NextCandidateOffset { get; init; } public int DataSetBoundReportControlCount { get; init; } public IReadOnlyList Warnings { get; init; } = Array.Empty(); @@ -72,8 +78,8 @@ public sealed class MmsCanonicalStaticLiveReconciliationResult public string Summary => $"targeted live RCB reconciliation: status={Status}, domains={Plan.Domains.Count}, " + - $"rawVariables={EnumeratedVariableCount}, candidates={CandidateReportControlCount}, " + - $"datasetBound={DataSetBoundReportControlCount}, authority={Inventory.Authority}."; + $"rawVariables={EnumeratedVariableCount}, eligibleCandidates={CandidateReportControlCount}, " + + $"examined={ExaminedCandidateCount}, deferred={DeferredCandidateCount}, next={NextCandidateOffset?.ToString() ?? "none"}, datasetBound={DataSetBoundReportControlCount}, authority={Inventory.Authority}."; } public sealed class MmsCanonicalStaticSmartPreparationResult @@ -265,38 +271,15 @@ public async Task ReconcileCanonical DomainVariableLists = new Dictionary>(StringComparer.Ordinal) }); - var targetsByDomain = plan.Domains.ToDictionary( - target => target.Domain, - StringComparer.Ordinal); - - var candidates = observed.ReportControls - .Where(candidate => - targetsByDomain.TryGetValue(candidate.Domain, out var target) && - ((candidate.Buffered && target.NeedsBuffered) || - (!candidate.Buffered && target.NeedsUnbuffered))) - .OrderBy(candidate => candidate.Domain, StringComparer.Ordinal) - .ThenByDescending(candidate => candidate.Buffered) - .ThenBy(candidate => candidate.Reference, StringComparer.Ordinal) - .ToArray(); - - var maxCandidates = Math.Clamp(options.MaxReportControlCandidates, 1, 512); - if (candidates.Length > maxCandidates) - { - return new MmsCanonicalStaticLiveReconciliationResult - { - Plan = plan, - Status = MmsCanonicalStaticLiveReconciliationStatus.CandidateBudgetExceeded, - Inventory = emptyInventory, - EnumeratedVariableCount = variables.Values.Sum(list => list.Count), - CandidateReportControlCount = candidates.Length, - Warnings = plan.Warnings - .Concat(new[] - { - $"Target domains expose {candidates.Length} required-family RCB candidates, exceeding live reconciliation budget {maxCandidates}; no partial RCB read subset was started." - }) - .ToArray() - }; - } + // Only the selected static coverage determines the candidate scope. + // A large server-wide RCB catalog is not itself a failure. Unknown + // DatSet bindings are inspected in a bounded, resumable window. + var candidatePage = MmsCanonicalStaticRcbCandidatePager.BuildPage( + coverage, + observed.ReportControls, + options.MaxReportControlCandidates, + options.CandidateOffset); + var candidates = candidatePage.Candidates; var inventory = new MmsReportInventory { @@ -326,11 +309,14 @@ await TryReadReportAttributeSmartAsync( break; } - var complete = IsMmsInitiated && chains.All(chain => chain.IsComplete); + var complete = IsMmsInitiated && chains.All(chain => chain.IsComplete) && !candidatePage.HasContinuation; var warnings = plan.Warnings .Concat(chains .Where(chain => !chain.IsComplete) .Select(chain => $"Target domain '{chain.Domain}' NamedVariable enumeration is partial: {chain.Message}")) + .Concat(candidatePage.HasContinuation + ? new[] { $"Deferred {candidatePage.DeferredCandidateCount} target-scoped RCB candidates; resume at offset {candidatePage.NextOffset} with a new bounded read-only request." } + : Array.Empty()) .Concat(candidates .Where(candidate => string.IsNullOrWhiteSpace(candidate.DataSetReference)) .Select(candidate => $"Live RCB '{candidate.Reference}' did not expose a usable DatSet binding.")) @@ -347,7 +333,10 @@ await TryReadReportAttributeSmartAsync( : MmsCanonicalStaticLiveReconciliationStatus.Partial, Inventory = inventory, EnumeratedVariableCount = variables.Values.Sum(list => list.Count), - CandidateReportControlCount = candidates.Length, + CandidateReportControlCount = candidatePage.EligibleCandidateCount, + ExaminedCandidateCount = candidatePage.ExaminedCandidateCount, + DeferredCandidateCount = candidatePage.DeferredCandidateCount, + NextCandidateOffset = candidatePage.NextOffset, DataSetBoundReportControlCount = candidates.Count(candidate => !string.IsNullOrWhiteSpace(candidate.DataSetReference)), Warnings = warnings From 689e4728264f79ffe61ede7902263ff5e4f3cbfc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:25:21 +0700 Subject: [PATCH 11/16] fix(rcb): never promote name-only RCB identity to operational DataSet binding --- .../MmsCanonicalStaticRcbTargetResolver.cs | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs index da44d3b8..84630f9a 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbTargetResolver.cs @@ -27,11 +27,10 @@ public sealed class MmsCanonicalStaticRcbTargetResolution /// inventing indexed names. /// /// Exact live identity is accepted from either: -/// 1) an exact configured RCB reference match, or -/// 2) a live RCB whose live DatSet binding exactly matches the canonical covered DataSet -/// and whose BRCB/URCB family is declared for that segment. +/// a live RCB whose live DatSet binding exactly matches the canonical covered DataSet +/// and whose BRCB/URCB family is declared for that segment. /// -/// If neither evidence path is available the segment remains unresolved. The caller may +/// If that evidence is unavailable the segment remains unresolved. The caller may /// explicitly choose a broader diagnostic probe, but this resolver never guesses a sibling /// RCB merely because its name looks similar. /// @@ -64,10 +63,6 @@ public static MmsCanonicalStaticRcbTargetResolution Resolve( .Distinct() .ToHashSet(); - var exactReferenceMatches = liveInventory.ReportControls - .Where(candidate => configuredReferences.Contains(Normalize(candidate.Reference))) - .ToArray(); - var exactDataSetMatches = canonicalDataSet.Length == 0 ? Array.Empty() : liveInventory.ReportControls @@ -81,8 +76,9 @@ public static MmsCanonicalStaticRcbTargetResolution Resolve( configuredBufferedFamilies.Contains(candidate.Buffered)) .ToArray(); - var matched = exactReferenceMatches - .Concat(exactDataSetMatches) + // A matching RCB name alone is insufficient for safe operational + // targeting. Require a live DatSet binding for every selected RCB. + var matched = exactDataSetMatches .Where(candidate => !string.IsNullOrWhiteSpace(candidate.Reference)) .DistinctBy(candidate => Normalize(candidate.Reference), StringComparer.OrdinalIgnoreCase) .OrderBy(candidate => candidate.Reference, StringComparer.OrdinalIgnoreCase) @@ -91,16 +87,12 @@ public static MmsCanonicalStaticRcbTargetResolution Resolve( foreach (var candidate in matched) allTargets.Add(candidate.Reference); - var resolution = matched.Length > 0 - ? exactDataSetMatches.Length > 0 - ? "exact-live-dataset-binding" - : "exact-configured-rcb-reference" - : "unresolved"; + var resolution = matched.Length > 0 ? "exact-live-dataset-binding" : "unresolved"; if (matched.Length == 0) { warnings.Add( - $"Canonical static DataSet {segment.DataSetReference} covers selected signal(s), but no concrete live RCB could be proven by exact reference or exact live DatSet binding. No sibling/name-family guess was made."); + $"Canonical static DataSet {segment.DataSetReference} covers selected signal(s), but no concrete live RCB has a verified exact live DatSet binding. No sibling/name-family guess was made."); } segments.Add(new MmsCanonicalStaticRcbTargetSegment From 614090ff41465ba294f4749d696b36574b216f34 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:25:38 +0700 Subject: [PATCH 12/16] test(rcb): guard unknown DatSet and contradictory binding --- .../Mms/MmsCanonicalStaticRcbTargetResolverTests.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs index 4408ee6d..e2c6173e 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbTargetResolverTests.cs @@ -33,7 +33,7 @@ public void Exact_Live_Dataset_Binding_Targets_Only_Covered_Rcb_Family() } [Fact] - public void Exact_Configured_Reference_Can_Target_When_Live_Dataset_Binding_Is_Not_Yet_Populated() + public void Exact_Configured_Reference_Remains_Unresolved_Without_Live_Dataset_Binding() { var coverage = Coverage( dataSet: "LD0/LLN0.Events", @@ -50,8 +50,9 @@ public void Exact_Configured_Reference_Can_Target_When_Live_Dataset_Binding_Is_N var resolution = MmsCanonicalStaticRcbTargetResolver.Resolve(coverage, inventory); - Assert.Equal(new[] { "LD0/LLN0$BR$Rpt01" }, resolution.ExactLiveReportControlReferences); - Assert.Equal("exact-configured-rcb-reference", resolution.Segments.Single().Resolution); + Assert.False(resolution.HasTargets); + Assert.Empty(resolution.ExactLiveReportControlReferences); + Assert.Equal("unresolved", resolution.Segments.Single().Resolution); } [Fact] From 1ec0ec50c9e97ab1b65a340f146717a47e2b876a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:26:22 +0700 Subject: [PATCH 13/16] test(rcb): synthetic 66/300 RCB target-scope, continuation and ordering --- ...msCanonicalStaticRcbCandidatePagerTests.cs | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCandidatePagerTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCandidatePagerTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCandidatePagerTests.cs new file mode 100644 index 00000000..26c97576 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsCanonicalStaticRcbCandidatePagerTests.cs @@ -0,0 +1,120 @@ +using AR.Iec61850.Engineering.Canonical; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsCanonicalStaticRcbCandidatePagerTests +{ + [Fact] + public void SixtySixRcbs_WithTwoExactLiveDatSetMatches_ScansOnlyMatchingTwo() + { + var catalog = Enumerable.Range(0, 66).Select(index => Candidate( + $"LABLD/LLN0.BR.Rpt{index:000}", + index is 19 or 64 ? "LABLD/LLN0.Events" : "LABLD/LLN0.Unrelated")).ToArray(); + catalog[19].EnabledState = "true"; // busy candidate is never written to by this planner + catalog[64].EnabledState = "false"; + + var page = MmsCanonicalStaticRcbCandidatePager.BuildPage( + Coverage(), catalog, maxPerPage: 8); + + Assert.Equal(2, page.EligibleCandidateCount); + Assert.Equal(2, page.ExaminedCandidateCount); + Assert.Equal(0, page.DeferredCandidateCount); + Assert.False(page.HasContinuation); + Assert.Equal( + ["LABLD/LLN0.BR.Rpt019", "LABLD/LLN0.BR.Rpt064"], + page.Candidates.Select(candidate => candidate.Reference).ToArray()); + Assert.Equal("true", catalog[19].EnabledState); + Assert.Equal("false", catalog[64].EnabledState); + } + + [Fact] + public void ThreeHundredUnknownBindings_AreBoundedResumableAndOrderIndependent() + { + var catalog = Enumerable.Range(0, 300) + .Select(index => Candidate($"LABLD/LLN0.BR.Rpt{index:000}", "")) + .ToArray(); + const int window = 37; + var first = MmsCanonicalStaticRcbCandidatePager.BuildPage( + Coverage(), catalog, maxPerPage: window); + var permuted = MmsCanonicalStaticRcbCandidatePager.BuildPage( + Coverage(), catalog.Reverse(), maxPerPage: window); + + Assert.Equal(300, first.EligibleCandidateCount); + Assert.Equal(window, first.ExaminedCandidateCount); + Assert.Equal(263, first.DeferredCandidateCount); + Assert.Equal(37, first.NextOffset); + Assert.Equal( + first.Candidates.Select(candidate => candidate.Reference), + permuted.Candidates.Select(candidate => candidate.Reference)); + Assert.Equal("LABLD/LLN0.BR.Rpt299", first.Candidates[0].Reference); + + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var pages = 0; + int? next = 0; + while (next.HasValue) + { + var page = MmsCanonicalStaticRcbCandidatePager.BuildPage( + Coverage(), catalog, maxPerPage: window, offset: next.Value); + pages++; + Assert.InRange(page.ExaminedCandidateCount, 1, window); + foreach (var candidate in page.Candidates) + Assert.True(visited.Add(candidate.Reference)); + next = page.NextOffset; + } + + Assert.Equal(300, visited.Count); + Assert.Equal(9, pages); + } + + [Fact] + public void MixedFamiliesAndKnownWrongBindings_DoNotConsumeTheSelectedBudget() + { + var catalog = Enumerable.Range(0, 200) + .Select(index => Candidate($"LABLD/LLN0.RP.Other{index:000}", "")) + .Concat(Enumerable.Range(0, 100) + .Select(index => Candidate($"LABLD/LLN0.BR.Rpt{index:000}", + "LABLD/LLN0.Other", buffered: true))) + .Append(Candidate("LABLD/LLN0.BR.Rpt299", "LABLD/LLN0.Events")) + .ToArray(); + var page = MmsCanonicalStaticRcbCandidatePager.BuildPage( + Coverage(), catalog, maxPerPage: 1); + + Assert.Equal(1, page.EligibleCandidateCount); + Assert.Equal("LABLD/LLN0.BR.Rpt299", Assert.Single(page.Candidates).Reference); + Assert.False(page.HasContinuation); + } + + private static CanonicalStaticReportCoveragePlan Coverage() + => new() + { + Segments = + [ + new CanonicalStaticReportCoverageSegment + { + DataSetReference = "LABLD/LLN0.Events", + SelectedSignalReferences = ["LABLD/XCBR1.Pos.stVal"], + ReportControls = + [ + new CanonicalReportControl + { + MmsDomain = "LABLD", + Buffered = true, + Reference = "LABLD/LLN0.BR.Rpt299", + DataSetReference = "LABLD/LLN0.Events" + } + ] + } + ] + }; + + private static MmsReportControlCandidate Candidate( + string reference, string dataSet, bool buffered = true) + => new() + { + Domain = "LABLD", + Reference = reference, + Buffered = buffered, + DataSetReference = dataSet + }; +} From 80128cc5f56c5d9599abf51df06bfb18377d9063 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:30:00 +0700 Subject: [PATCH 14/16] fix(rcb): reject structurally inconsistent RP/BR candidate family evidence --- .../MmsCanonicalStaticRcbCandidatePager.cs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCandidatePager.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCandidatePager.cs index 5864916a..19602c63 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCandidatePager.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCandidatePager.cs @@ -47,6 +47,8 @@ public static MmsCanonicalStaticRcbCandidatePage BuildPage( var eligible = liveCandidates .Where(candidate => { + if (!HasConsistentReportFamily(candidate)) + return false; var domain = DomainOf(candidate.Domain, candidate.Reference); if (!targets.TryGetValue(FamilyKey(domain, candidate.Buffered), out var bindings)) return false; @@ -83,6 +85,46 @@ public static MmsCanonicalStaticRcbCandidatePage BuildPage( }; } + private static bool HasConsistentReportFamily(MmsReportControlCandidate candidate) + { + var fc = candidate.FunctionalConstraint?.Trim() ?? string.Empty; + if (fc.Equals("BR", StringComparison.OrdinalIgnoreCase) && !candidate.Buffered) + return false; + if (fc.Equals("RP", StringComparison.OrdinalIgnoreCase) && candidate.Buffered) + return false; + + // Reference and typed family must never contradict one another. + var reference = candidate.Reference.Replace(' + { + if (!string.IsNullOrWhiteSpace(domain)) + return domain.Trim(); + var value = reference?.Trim() ?? string.Empty; + var slash = value.IndexOf('/'); + return slash > 0 ? value[..slash] : string.Empty; + } + + private static string FamilyKey(string domain, bool buffered) + => domain + (buffered ? "|BR" : "|RP"); +} + +public sealed class MmsCanonicalStaticRcbCandidatePage +{ + public IReadOnlyList Candidates { get; init; } + = Array.Empty(); + public int EligibleCandidateCount { get; init; } + public int ExaminedCandidateCount { get; init; } + public int DeferredCandidateCount { get; init; } + public int? NextOffset { get; init; } + public bool HasContinuation => NextOffset.HasValue; +} +, '.'); + if (reference.Contains(".BR.", StringComparison.OrdinalIgnoreCase)) + return candidate.Buffered; + if (reference.Contains(".RP.", StringComparison.OrdinalIgnoreCase)) + return !candidate.Buffered; + return true; + } + private static string DomainOf(string? domain, string? reference) { if (!string.IsNullOrWhiteSpace(domain)) From 1ed776a689c8707be5db09a6318c4a7613229f95 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:34:10 +0700 Subject: [PATCH 15/16] fix(rcb): restore syntactically valid source and reject RP/BR family contradictions --- .../MmsCanonicalStaticRcbCandidatePager.cs | 27 ++----------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCandidatePager.cs b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCandidatePager.cs index 19602c63..5b865723 100644 --- a/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCandidatePager.cs +++ b/src/AR.Iec61850/Mms/MmsCanonicalStaticRcbCandidatePager.cs @@ -93,31 +93,8 @@ private static bool HasConsistentReportFamily(MmsReportControlCandidate candidat if (fc.Equals("RP", StringComparison.OrdinalIgnoreCase) && candidate.Buffered) return false; - // Reference and typed family must never contradict one another. - var reference = candidate.Reference.Replace(' - { - if (!string.IsNullOrWhiteSpace(domain)) - return domain.Trim(); - var value = reference?.Trim() ?? string.Empty; - var slash = value.IndexOf('/'); - return slash > 0 ? value[..slash] : string.Empty; - } - - private static string FamilyKey(string domain, bool buffered) - => domain + (buffered ? "|BR" : "|RP"); -} - -public sealed class MmsCanonicalStaticRcbCandidatePage -{ - public IReadOnlyList Candidates { get; init; } - = Array.Empty(); - public int EligibleCandidateCount { get; init; } - public int ExaminedCandidateCount { get; init; } - public int DeferredCandidateCount { get; init; } - public int? NextOffset { get; init; } - public bool HasContinuation => NextOffset.HasValue; -} -, '.'); + // The MMS reference's RP/BR segment is stronger than a cached Boolean. + var reference = candidate.Reference.Replace('$', '.'); if (reference.Contains(".BR.", StringComparison.OrdinalIgnoreCase)) return candidate.Buffered; if (reference.Contains(".RP.", StringComparison.OrdinalIgnoreCase)) From 9630dd7a1806b4ae3172ad52d612a8def0bbb796 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:48:08 +0700 Subject: [PATCH 16/16] test(mms): RED malformed boolean/int tags must not become observed false/zero --- ...P0TypedMmsNonFabricationRegressionTests.cs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/P0TypedMmsNonFabricationRegressionTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/P0TypedMmsNonFabricationRegressionTests.cs b/tests/AR.Iec61850.Tests/Mms/P0TypedMmsNonFabricationRegressionTests.cs new file mode 100644 index 00000000..11fa3a55 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/P0TypedMmsNonFabricationRegressionTests.cs @@ -0,0 +1,35 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +/// +/// Synthetic BER failure and exact zero-value fixtures; no device/customer capture. +/// +public sealed class P0TypedMmsNonFabricationRegressionTests +{ + [Theory] + [InlineData("8300", 3)] // boolean: empty payload cannot mean false + [InlineData("8500", 5)] // signed integer: empty payload cannot mean zero + [InlineData("8600", 6)] // unsigned integer: empty payload cannot mean zero + [InlineData("8509000000000000000000", 5)] // signed integer wider than Int64 + [InlineData("8609000000000000000000", 6)] // unsigned integer wider than UInt64 + public void InvalidTypedPayload_MustNotPublishFabricatedScalar(string hex, int expectedTag) + { + var value = Assert.Single(MmsDataCodec.DecodeAllData(Convert.FromHexString(hex))); + Assert.Equal(MmsDataKind.Unknown, value.Kind); + Assert.Equal(expectedTag, value.UnknownTagNumber); + Assert.Null(value.Value); + } + + [Theory] + [InlineData("830100", MmsDataKind.Boolean)] + [InlineData("850100", MmsDataKind.Integer)] + [InlineData("860100", MmsDataKind.Unsigned)] + public void RealWireFalseOrZero_RemainsObserved(string hex, MmsDataKind expected) + { + var value = Assert.Single(MmsDataCodec.DecodeAllData(Convert.FromHexString(hex))); + Assert.Equal(expected, value.Kind); + Assert.NotNull(value.Value); + Assert.Equal("0", Convert.ToInt64(value.Value).ToString(System.Globalization.CultureInfo.InvariantCulture)); + } +}